diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..d48c759
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,2 @@
+.idea
+.vscode
\ No newline at end of file
diff --git a/backend/app/actors/ProjectActor.scala b/backend/app/actors/ProjectActor.scala
new file mode 100644
index 0000000..bdb4c72
--- /dev/null
+++ b/backend/app/actors/ProjectActor.scala
@@ -0,0 +1,50 @@
+package actors
+
+import dto.websocket.OutgoingMessage
+import org.apache.pekko.actor.{Actor, ActorRef, Props}
+import play.api.Logger
+import play.api.libs.json.{JsValue, Json}
+
+/**
+ * Actor that manages WebSocket connections for a specific project.
+ */
+object ProjectActor {
+ def props(projectId: Int): Props = Props(new ProjectActor(projectId))
+
+ case class Join(userId: Int, out: ActorRef)
+ case class Leave(userId: Int)
+ case class Broadcast(message: OutgoingMessage)
+}
+
+/**
+ * Actor that manages WebSocket connections for a specific project.
+ * It keeps track of connected users and broadcasts messages to them.
+ *
+ * @param projectId the ID of the project
+ */
+class ProjectActor(projectId: Int) extends Actor {
+ import ProjectActor._
+
+ private var members = Map.empty[Int, ActorRef]
+
+ def receive: Receive = {
+ case Join(userId, out) =>
+ members += userId -> out
+ Logger("actors").info(
+ s"UserId $userId joined project $projectId. Total members: ${members.size}"
+ )
+
+ case Leave(userId) =>
+ members -= userId
+ Logger("actors").info(
+ s"UserId with $userId left project $projectId. Total members: ${members.size}"
+ )
+
+ case Broadcast(msg) =>
+ val js: JsValue = Json.toJson(msg)
+ members.values.foreach(_ ! js)
+ Logger("actors").info(
+ s"Broadcasted message to project $projectId members: ${members.size} users"
+ )
+ }
+}
diff --git a/backend/app/actors/ProjectActorRegistry.scala b/backend/app/actors/ProjectActorRegistry.scala
new file mode 100644
index 0000000..fc87c4d
--- /dev/null
+++ b/backend/app/actors/ProjectActorRegistry.scala
@@ -0,0 +1,34 @@
+package actors
+
+import org.apache.pekko.actor.{Actor, ActorRef, Props}
+import dto.websocket.OutgoingMessage
+import modules.ActorNames
+
+object ProjectActorRegistry {
+ def props: Props = Props(new ProjectActorRegistry)
+
+ case class GetProjectActor(projectId: Int)
+ case class BroadcastToProject(projectId: Int, message: OutgoingMessage)
+}
+
+class ProjectActorRegistry extends Actor {
+ import ProjectActorRegistry._
+ import ProjectActor._
+
+ private var projectActors = Map.empty[Int, ActorRef]
+
+ def receive: Receive = {
+ case GetProjectActor(projectId) =>
+ // create new ProjectActor if not exists
+ val actor = projectActors.getOrElse(projectId, {
+ val newActor = context.actorOf(ProjectActor.props(projectId), s"${ActorNames.ProjectActorPrefix}$projectId")
+ projectActors += projectId -> newActor
+ newActor
+ })
+ // return ActorRef for requester
+ sender() ! actor
+
+ case BroadcastToProject(projectId, message) =>
+ projectActors.get(projectId).foreach(_ ! Broadcast(message))
+ }
+}
diff --git a/backend/app/actors/ProjectClientActor.scala b/backend/app/actors/ProjectClientActor.scala
new file mode 100644
index 0000000..c2ad4d7
--- /dev/null
+++ b/backend/app/actors/ProjectClientActor.scala
@@ -0,0 +1,72 @@
+package actors
+
+import org.apache.pekko.actor.{Actor, ActorRef, Props}
+import org.apache.pekko.pattern.{ask, pipe}
+import org.apache.pekko.util.Timeout
+
+import scala.concurrent.ExecutionContext
+import scala.concurrent.duration._
+
+/**
+ * Actor that manages a WebSocket connection for a user in a specific project.
+ */
+object ProjectClientActor {
+ def props(out: ActorRef,
+ userId: Int,
+ projectId: Int,
+ registry: ActorRef): Props =
+ Props(new ProjectClientActor(out, userId, projectId, registry))
+
+ /**
+ * Message indicating that the ProjectActor has been found in the registry.
+ * @param projectRef the ActorRef of the ProjectActor
+ */
+ private case class RegistryFound(projectRef: ActorRef)
+}
+
+/**
+ * Actor that manages a WebSocket connection for a user in a specific project.
+ * It registers the user with the ProjectActor upon creation and deregisters
+ * upon termination.
+ *
+ * @param out the ActorRef to send messages to the WebSocket
+ * @param userId the ID of the user
+ * @param projectId the ID of the project
+ * @param registry the ActorRef of the ProjectActorRegistry
+ */
+class ProjectClientActor(out: ActorRef,
+ userId: Int,
+ projectId: Int,
+ registry: ActorRef)
+ extends Actor {
+ import ProjectActor._
+ import ProjectActorRegistry._
+ import ProjectClientActor._
+
+ // Execution context and timeout for ask pattern
+ implicit val ec: ExecutionContext = context.dispatcher
+ implicit val timeout: Timeout = Timeout(3.seconds)
+
+ // Reference to the ProjectActor, once found
+ private var projectRefOpt: Option[ActorRef] = None
+
+ // On start, ask the registry for the ProjectActor
+ override def preStart(): Unit = {
+ (registry ? GetProjectActor(projectId))
+ .mapTo[ActorRef]
+ .map(RegistryFound) pipeTo self
+ }
+
+ // On stop, inform the ProjectActor that the user is leaving
+ override def postStop(): Unit = {
+ projectRefOpt.foreach(_ ! Leave(userId))
+ super.postStop()
+ }
+
+ // Handle incoming messages
+ def receive: Receive = {
+ case RegistryFound(projectRef) =>
+ projectRefOpt = Some(projectRef)
+ projectRef ! Join(userId, out)
+ }
+}
diff --git a/backend/app/controllers/AuthenticatedAction.scala b/backend/app/controllers/AuthenticatedAction.scala
index af49ab3..3e93d3e 100644
--- a/backend/app/controllers/AuthenticatedAction.scala
+++ b/backend/app/controllers/AuthenticatedAction.scala
@@ -1,5 +1,7 @@
package controllers
+import org.apache.pekko.stream.scaladsl.Flow
+import play.api.libs.json.JsValue
import play.api.mvc._
import services.{CookieService, JwtService, UserToken}
@@ -62,3 +64,35 @@ class AuthenticatedActionWithUser @Inject()(
}
}
}
+
+class AuthenticatedWebSocket @Inject()(
+ jwtService: JwtService,
+ cookieService: CookieService
+ )(implicit ec: ExecutionContext) {
+
+ def apply(
+ block: UserToken ⇒ Future[Either[Result, Flow[JsValue, JsValue, _]]]
+ ): WebSocket = {
+ WebSocket.acceptOrResult[JsValue, JsValue] { requestHeader ⇒
+ cookieService.getTokenFromRequest(requestHeader) match {
+ case Some(token) ⇒
+ jwtService.validateToken(token) match {
+ case Success(userToken) ⇒
+ block(userToken)
+ case Failure(ex) ⇒
+ Future.successful(
+ Left(
+ Results
+ .Unauthorized(s"Invalid token: ${ex.getMessage}")
+ .withCookies(cookieService.createExpiredAuthCookie())
+ )
+ )
+ }
+ case None ⇒
+ Future.successful(
+ Left(Results.Unauthorized("No authentication token found"))
+ )
+ }
+ }
+ }
+}
diff --git a/backend/app/controllers/ColumnController.scala b/backend/app/controllers/ColumnController.scala
index 8aea609..3057bf8 100644
--- a/backend/app/controllers/ColumnController.scala
+++ b/backend/app/controllers/ColumnController.scala
@@ -112,15 +112,15 @@ class ColumnController @Inject()(
}
}
- /** PATCH /columns/:columnId/position */
- def updatePosition(columnId: Int): Action[JsValue] =
+ /** PATCH /projects/:projectId/columns/:columnId/position */
+ def updatePosition(projectId: Int, columnId: Int): Action[JsValue] =
authenticatedActionWithUser.async(parse.json) { request =>
implicit val messages: Messages = request.messages
val updatedBy = request.userToken.userId
handleJsonValidation[UpdateColumnPositionRequest](request.body) {
- updatePositionDto =>
+ updateColumnPositionDto =>
columnService
- .updatePosition(columnId, updatePositionDto, updatedBy)
+ .updatePosition(projectId, columnId, updateColumnPositionDto, updatedBy)
.map { _ =>
Ok(
Json.toJson(
diff --git a/backend/app/controllers/WebSocketController.scala b/backend/app/controllers/WebSocketController.scala
new file mode 100644
index 0000000..41fa4d5
--- /dev/null
+++ b/backend/app/controllers/WebSocketController.scala
@@ -0,0 +1,37 @@
+package controllers
+
+import modules.ActorNames
+import org.apache.pekko.actor.{ActorRef, ActorSystem}
+import org.apache.pekko.stream.Materializer
+import play.api.libs.json.JsValue
+import play.api.libs.streams.ActorFlow
+import play.api.mvc._
+import services.ProjectService
+
+import javax.inject._
+import scala.concurrent.ExecutionContext
+
+@Singleton
+class WebSocketController @Inject()(
+ cc: ControllerComponents,
+ authenticatedWebSocket: AuthenticatedWebSocket,
+ @Named(ActorNames.ProjectActorRegistry) projectActorRegistry: ActorRef,
+ projectService: ProjectService
+ )(implicit system: ActorSystem, mat: Materializer, ec: ExecutionContext)
+ extends AbstractController(cc) {
+
+ def joinProject(projectId: Int): WebSocket = authenticatedWebSocket { userToken =>
+ projectService.isUserInActiveProject(userToken.userId, projectId).map { exists =>
+ if (exists) {
+ Right(
+ ActorFlow.actorRef[JsValue, JsValue] { out =>
+ actors.ProjectClientActor.props(out, userToken.userId, projectId, projectActorRegistry)
+ }
+ )
+ } else {
+ Left(Results.Forbidden("User is not a member of this project"))
+ }
+ }
+ }
+}
+
diff --git a/backend/app/dto/websocket/OutgoingMessage.scala b/backend/app/dto/websocket/OutgoingMessage.scala
new file mode 100644
index 0000000..f041acb
--- /dev/null
+++ b/backend/app/dto/websocket/OutgoingMessage.scala
@@ -0,0 +1,12 @@
+package dto.websocket
+
+import dto.websocket.board.BoardMessage
+import play.api.libs.json.Writes
+
+trait OutgoingMessage
+
+object OutgoingMessage {
+ implicit val writes: Writes[OutgoingMessage] = {
+ case bm: BoardMessage => BoardMessage.writes.writes(bm)
+ }
+}
diff --git a/backend/app/dto/websocket/board/BoardMessage.scala b/backend/app/dto/websocket/board/BoardMessage.scala
new file mode 100644
index 0000000..05d22c8
--- /dev/null
+++ b/backend/app/dto/websocket/board/BoardMessage.scala
@@ -0,0 +1,19 @@
+package dto.websocket.board
+
+import play.api.libs.json._
+import dto.websocket.OutgoingMessage
+
+sealed trait BoardMessage extends OutgoingMessage
+
+case class ColumnMoved(columnId: Int, newPosition: Int) extends BoardMessage
+case class TaskMoved(taskId: Int, fromColumnId: Int, toColumnId: Int, newPosition: Int) extends BoardMessage
+
+object BoardMessage {
+ implicit val columnMovedFormat: OFormat[ColumnMoved] = Json.format[ColumnMoved]
+ implicit val taskMovedFormat: OFormat[TaskMoved] = Json.format[TaskMoved]
+
+ implicit val writes: Writes[BoardMessage] = Writes {
+ case cm: ColumnMoved => Json.obj("type" -> "columnMoved") ++ Json.toJsObject(cm)
+ case tm: TaskMoved => Json.obj("type" -> "taskMoved") ++ Json.toJsObject(tm)
+ }
+}
\ No newline at end of file
diff --git a/backend/app/modules/ActorsModule.scala b/backend/app/modules/ActorsModule.scala
new file mode 100644
index 0000000..a04eaa6
--- /dev/null
+++ b/backend/app/modules/ActorsModule.scala
@@ -0,0 +1,28 @@
+package modules
+
+import actors.ProjectActorRegistry
+import com.google.inject.name.Named
+import com.google.inject.{AbstractModule, Provides, Singleton}
+import org.apache.pekko.actor.{ActorRef, ActorSystem, Props}
+import play.api.Logger
+
+/**
+ * Constants for actor names to ensure consistency across the application.
+ */
+object ActorNames {
+ final val ProjectActorRegistry = "project-actor-registry"
+ final val ProjectActorPrefix = "project-actor-"
+}
+
+/**
+ * Module to provide actor instances.
+ */
+class ActorsModule extends AbstractModule {
+ @Provides
+ @Singleton
+ @Named(ActorNames.ProjectActorRegistry)
+ def provideProjectActorRegistry(system: ActorSystem): ActorRef = {
+ Logger("actors").info("Creating ProjectActorRegistry actor")
+ system.actorOf(Props[ProjectActorRegistry], ActorNames.ProjectActorRegistry)
+ }
+}
diff --git a/backend/app/repositories/ColumnRepository.scala b/backend/app/repositories/ColumnRepository.scala
index 1707768..abf275c 100644
--- a/backend/app/repositories/ColumnRepository.scala
+++ b/backend/app/repositories/ColumnRepository.scala
@@ -85,8 +85,12 @@ class ColumnRepository @Inject()(
def findStatusIfUserInProject(columnId: Int, userId: Int): DBIO[Option[ColumnStatus]] = {
val query = for {
- (c, p) <- columns join projects on (_.projectId === _.id)
- if c.id === columnId.bind && p.createdBy === userId.bind
+ (((c, p), up)) <- columns
+ .join(projects).on(_.projectId === _.id)
+ .join(userProjects).on { case ((c, p), up) =>
+ p.id === up.projectId && up.userId === userId.bind
+ }
+ if c.id === columnId.bind
} yield c.status
query.result.headOption
diff --git a/backend/app/repositories/UserWorkspaceRepository.scala b/backend/app/repositories/UserWorkspaceRepository.scala
deleted file mode 100644
index 074fa38..0000000
--- a/backend/app/repositories/UserWorkspaceRepository.scala
+++ /dev/null
@@ -1,29 +0,0 @@
-package repositories
-
-import models.entities.UserWorkspace
-import models.tables.TableRegistry
-import play.api.db.slick.{DatabaseConfigProvider, HasDatabaseConfigProvider}
-import slick.jdbc.JdbcProfile
-
-import javax.inject.{Inject, Singleton}
-import scala.concurrent.ExecutionContext
-
-@Singleton
-class UserWorkspaceRepository @Inject()(
- protected val dbConfigProvider: DatabaseConfigProvider
-)(implicit ec: ExecutionContext)
- extends HasDatabaseConfigProvider[JdbcProfile] {
-
- import profile.api._
- private val userWorkspaces = TableRegistry.userWorkspaces
-
- /**
- * Creates a DBIO action to insert a new UserWorkspace record into the database.
- * The action will only be executed when passed to the `db.run` method.
- * @param userWorkspace The UserWorkspace entity to insert.
- * @return A DBIO action that returns the number of rows affected.
- */
- def insertAction(userWorkspace: UserWorkspace): DBIO[Int] = {
- userWorkspaces += userWorkspace
- }
-}
diff --git a/backend/app/services/BroadcastService.scala b/backend/app/services/BroadcastService.scala
new file mode 100644
index 0000000..192997b
--- /dev/null
+++ b/backend/app/services/BroadcastService.scala
@@ -0,0 +1,24 @@
+package services
+
+import org.apache.pekko.actor.ActorRef
+import org.apache.pekko.util.Timeout
+import actors.ProjectActorRegistry
+import com.google.inject.name.Named
+import dto.websocket.OutgoingMessage
+import modules.ActorNames
+
+import javax.inject.Inject
+import scala.concurrent.Future
+import scala.concurrent.duration._
+
+class BroadcastService @Inject() (@Named(ActorNames.ProjectActorRegistry) registry: ActorRef) {
+ import ProjectActorRegistry._
+
+ implicit val timeout: Timeout = Timeout(3.seconds)
+
+ // Broadcast to project actor
+ def broadcastToProject(projectId: Int, message: OutgoingMessage): Future[Unit] = {
+ registry ! BroadcastToProject(projectId, message)
+ Future.successful(())
+ }
+}
diff --git a/backend/app/services/ColumnService.scala b/backend/app/services/ColumnService.scala
index 84676a2..1cf16ac 100644
--- a/backend/app/services/ColumnService.scala
+++ b/backend/app/services/ColumnService.scala
@@ -2,6 +2,7 @@ package services
import dto.request.column.{CreateColumnRequest, UpdateColumnPositionRequest, UpdateColumnRequest}
import dto.response.column.{ColumnSummariesResponse, ColumnWithTasksResponse}
+import dto.websocket.board.ColumnMoved
import exception.AppException
import models.Enums.ColumnStatus
import models.Enums.ColumnStatus.ColumnStatus
@@ -17,6 +18,7 @@ import scala.concurrent.{ExecutionContext, Future}
class ColumnService @Inject()(
columnRepository: ColumnRepository,
projectRepository: ProjectRepository,
+ broadcastService: BroadcastService,
protected val dbConfigProvider: DatabaseConfigProvider
)(implicit ec: ExecutionContext)
extends HasDatabaseConfigProvider[JdbcProfile] {
@@ -157,7 +159,10 @@ class ColumnService @Inject()(
errorMsg = "Only archived columns can be deleted"
)
- def updatePosition(columnId: Int, request: UpdateColumnPositionRequest, userId: Int): Future[Int] = {
+ def updatePosition(projectId: Int,
+ columnId: Int,
+ request: UpdateColumnPositionRequest,
+ userId: Int): Future[Int] = {
val action = for {
maybeStatus <- columnRepository.findStatusIfUserInProject(
columnId,
@@ -173,7 +178,14 @@ class ColumnService @Inject()(
}
} yield updatedRows
- db.run(action)
+ val resultF: Future[Int] = db.run(action)
+ resultF.foreach { _ =>
+ broadcastService.broadcastToProject(
+ projectId,
+ ColumnMoved(columnId, request.position)
+ )
+ }
+ resultF
}
def getArchivedColumns(projectId: Int, userId: Int): Future[Seq[ColumnSummariesResponse]] = {
diff --git a/backend/app/services/ProjectService.scala b/backend/app/services/ProjectService.scala
index 6443e3e..00331bd 100644
--- a/backend/app/services/ProjectService.scala
+++ b/backend/app/services/ProjectService.scala
@@ -184,4 +184,8 @@ class ProjectService @Inject()(
db.run(action)
}
+ def isUserInActiveProject(userId: Int, projectId: Int): Future[Boolean] = {
+ db.run(projectRepository.isUserInActiveProject(userId, projectId))
+ }
+
}
diff --git a/backend/build.sbt b/backend/build.sbt
index 216e11b..00324d9 100644
--- a/backend/build.sbt
+++ b/backend/build.sbt
@@ -69,8 +69,7 @@ lazy val root = (project in file("."))
coverageExcludedPackages := Seq(
"controllers\\.javascript\\..*",
"controllers.Reverse.*",
- "dto\\.request\\..*",
- "dto\\.response\\..*",
+ "dto\\..*",
"filters\\..*",
"mappers\\..*",
"models\\..*",
diff --git a/backend/conf/application.conf b/backend/conf/application.conf
index 57f066d..9134b60 100644
--- a/backend/conf/application.conf
+++ b/backend/conf/application.conf
@@ -16,6 +16,7 @@ include "security.conf"
# Enable modules
play.modules.enabled += "modules.SecurityModule"
play.modules.enabled += "modules.DatabaseModule"
+play.modules.enabled += "modules.ActorsModule"
# Enable filters
play.filters.enabled += "play.filters.gzip.GzipFilter"
diff --git a/backend/conf/logback.xml b/backend/conf/logback.xml
index ab6c2b1..43c5253 100644
--- a/backend/conf/logback.xml
+++ b/backend/conf/logback.xml
@@ -41,6 +41,7 @@
+
diff --git a/backend/conf/routes b/backend/conf/routes
index 4769bfe..4336785 100644
--- a/backend/conf/routes
+++ b/backend/conf/routes
@@ -45,7 +45,7 @@ PATCH /api/projects/:projectId/columns/:columnId controllers.ColumnController
PATCH /api/columns/:columnId/archive controllers.ColumnController.archive(columnId: Int)
PATCH /api/columns/:columnId/restore controllers.ColumnController.restore(columnId: Int)
DELETE /api/columns/:columnId controllers.ColumnController.delete(columnId: Int)
-PATCH /api/columns/:columnId/position controllers.ColumnController.updatePosition(columnId: Int)
+PATCH /api/projects/:projectId/columns/:columnId/position controllers.ColumnController.updatePosition(projectId: Int, columnId: Int)
# Task routes
POST /api/columns/:columnId/tasks controllers.TaskController.create(columnId: Int)
@@ -53,4 +53,7 @@ PATCH /api/tasks/:taskId controllers.TaskController.update(taskId:
GET /api/tasks/:taskId controllers.TaskController.getById(taskId: Int)
PATCH /api/tasks/:taskId/archive controllers.TaskController.archive(taskId: Int)
PATCH /api/tasks/:taskId/restore controllers.TaskController.restore(taskId: Int)
-DELETE /api/tasks/:taskId controllers.TaskController.delete(taskId: Int)
\ No newline at end of file
+DELETE /api/tasks/:taskId controllers.TaskController.delete(taskId: Int)
+
+#Web socket
+GET /ws/project/:projectId controllers.WebSocketController.joinProject(projectId: Int)
\ No newline at end of file
diff --git a/backend/test/controllers/ColumnControllerSpec.scala b/backend/test/controllers/ColumnControllerSpec.scala
index f83973b..5bd0da4 100644
--- a/backend/test/controllers/ColumnControllerSpec.scala
+++ b/backend/test/controllers/ColumnControllerSpec.scala
@@ -1,6 +1,10 @@
package controllers
-import dto.request.column.{CreateColumnRequest, UpdateColumnPositionRequest, UpdateColumnRequest}
+import dto.request.column.{
+ CreateColumnRequest,
+ UpdateColumnPositionRequest,
+ UpdateColumnRequest
+}
import exception.AppException
import org.scalatest.BeforeAndAfterAll
import org.scalatest.concurrent.ScalaFutures
@@ -12,7 +16,7 @@ import play.api.mvc.Cookie
import play.api.test.Helpers._
import play.api.test._
import play.api.{Application, Configuration}
-import services.{ColumnService, JwtService, ProjectService, UserToken, WorkspaceService}
+import services.{JwtService, ProjectService, UserToken, WorkspaceService}
class ColumnControllerSpec
extends PlaySpec
@@ -49,7 +53,6 @@ class ColumnControllerSpec
override def beforeAll(): Unit = {
val workspaceService = inject[WorkspaceService]
val projectService = inject[ProjectService]
- val columnService = inject[ColumnService]
await(
workspaceService.createWorkspace(
@@ -214,11 +217,11 @@ class ColumnControllerSpec
}
"delete column successfully" in {
- val archiveRequest = FakeRequest(PATCH, "/api/columns/1/archive")
+ val archiveRequest = FakeRequest(PATCH, "/api/columns/2/archive")
.withCookies(Cookie(cookieName, fakeToken))
route(app, archiveRequest).get
- val request = FakeRequest(DELETE, "/api/columns/1")
+ val request = FakeRequest(DELETE, "/api/columns/2")
.withCookies(Cookie(cookieName, fakeToken))
val result = route(app, request).get
@@ -227,4 +230,20 @@ class ColumnControllerSpec
.as[String] mustBe "Column deleted successfully"
}
}
+
+ "update column position successfully" in {
+ val columnPosition = 100
+
+ val body = Json.toJson(UpdateColumnPositionRequest(columnPosition))
+ val request = FakeRequest(PATCH, "/api/projects/1/columns/1/position")
+ .withCookies(Cookie(cookieName, fakeToken))
+ .withBody(body)
+ .withHeaders(CONTENT_TYPE -> "application/json")
+
+ val result = route(app, request).get
+
+ status(result) mustBe OK
+ (contentAsJson(result) \ "message")
+ .as[String] mustBe "Column position updated successfully"
+ }
}
diff --git a/backend/test/controllers/WebSocketControllerISpec.scala b/backend/test/controllers/WebSocketControllerISpec.scala
new file mode 100644
index 0000000..686df18
--- /dev/null
+++ b/backend/test/controllers/WebSocketControllerISpec.scala
@@ -0,0 +1,123 @@
+package controllers
+
+import akka.actor.ActorSystem
+import akka.stream.Materializer
+import org.scalatest.BeforeAndAfterAll
+import org.scalatest.concurrent.ScalaFutures
+import org.scalatestplus.play._
+import org.scalatestplus.play.guice.GuiceOneAppPerSuite
+import play.api.inject.guice.GuiceApplicationBuilder
+import play.api.mvc.Cookie
+import play.api.test.Helpers._
+import play.api.test.{FakeRequest, Injecting}
+import play.api.{Application, Configuration}
+import services.{JwtService, ProjectService, UserToken, WorkspaceService}
+
+import scala.concurrent.{ExecutionContext, Future}
+
+class WebSocketControllerISpec
+ extends PlaySpec
+ with Injecting
+ with GuiceOneAppPerSuite
+ with ScalaFutures
+ with BeforeAndAfterAll {
+
+ override implicit def fakeApplication(): Application = {
+ new GuiceApplicationBuilder()
+ .configure(
+ "config.resource" -> "application.test.conf",
+ "slick.dbs.default.db.url"
+ -> s"jdbc:h2:mem:websocket;DB_CLOSE_DELAY=-1;DB_CLOSE_ON_EXIT=FALSE;MODE=PostgreSQL;DATABASE_TO_UPPER=false"
+ )
+ .build()
+ }
+
+ implicit lazy val system: ActorSystem = app.injector.instanceOf[ActorSystem]
+ implicit lazy val mat: Materializer = app.injector.instanceOf[Materializer]
+ implicit lazy val ec: ExecutionContext =
+ app.injector.instanceOf[ExecutionContext]
+ lazy val controller: WebSocketController =
+ app.injector.instanceOf[WebSocketController]
+
+ lazy val config: Configuration = app.configuration
+ lazy val defaultAdminEmail: String =
+ config.getOptional[String]("admin.email").getOrElse("admin@mail.com")
+ lazy val defaultAdminName: String =
+ config.getOptional[String]("admin.name").getOrElse("Administrator")
+ lazy val cookieName: String =
+ config.getOptional[String]("cookie.name").getOrElse("auth_token")
+
+ def fakeToken: String = {
+ val jwtService = inject[JwtService]
+ jwtService
+ .generateToken(UserToken(1, defaultAdminName, defaultAdminEmail))
+ .getOrElse(throw new RuntimeException("JWT token not generated"))
+ }
+
+ override def beforeAll(): Unit = {
+ val workspaceService = inject[WorkspaceService]
+ val projectService = inject[ProjectService]
+
+ await(
+ workspaceService.createWorkspace(
+ dto.request.workspace.CreateWorkspaceRequest("Workspace test"),
+ 1
+ )
+ )
+
+ await(
+ // Create a project with default columns
+ projectService.createProject(
+ dto.request.project.CreateProjectRequest("Project test"),
+ 1,
+ 1
+ )
+ )
+ }
+
+ "WebSocketController#joinProject" should {
+
+ "reject if user is not in project" in {
+ // fake user token → projectService trả về false
+
+ val request = FakeRequest(GET, "/ws/projects/123")
+ .withCookies(
+ Cookie(cookieName, fakeToken)
+ )
+ val inExpectedProjectId = -1
+
+ val wsFuture = controller.joinProject(inExpectedProjectId).apply(request)
+
+ val either = await(wsFuture)
+
+ either.isLeft mustBe true
+
+ val result = either.left.get
+
+ status(Future.successful(result)) mustBe FORBIDDEN
+ contentAsString(Future.successful(result)) must include(
+ "User is not a member of this project"
+ )
+
+ }
+
+ "accept if user is in project" in {
+ // giả lập user trong project → cần mock ProjectService
+ // Ở đây mình dùng application.conf test để override
+
+ val request = FakeRequest(GET, "/ws/projects/1")
+ .withCookies(
+ Cookie(cookieName, fakeToken)
+ )
+ val wsFuture = controller.joinProject(1).apply(request)
+
+ val either = await(wsFuture)
+
+ either.isRight mustBe true
+
+ val flow = either.toOption.get
+
+ flow must not be null
+ }
+ }
+}
diff --git a/frontend/src/hooks/useWebsocket.ts b/frontend/src/hooks/useWebsocket.ts
new file mode 100644
index 0000000..c2090e2
--- /dev/null
+++ b/frontend/src/hooks/useWebsocket.ts
@@ -0,0 +1,21 @@
+import { wsService } from "@/services/wsService";
+import type { InMsg, OutMsg } from "@/types/ws";
+import { useEffect, useState } from "react";
+
+export function useWebSocket() {
+ const [lastMessage, setLastMessage] = useState(null);
+
+ useEffect(() => {
+ wsService.connect();
+
+ const unsubscribe = wsService.onMessage((msg) => {
+ setLastMessage(msg);
+ });
+
+ return () => unsubscribe();
+ }, []);
+
+ const send = (msg: InMsg) => wsService.send(msg);
+
+ return { lastMessage, send };
+}
\ No newline at end of file
diff --git a/frontend/src/pages/WorkspaceBoard.tsx b/frontend/src/pages/WorkspaceBoard.tsx
index 9e692cd..da26eb7 100644
--- a/frontend/src/pages/WorkspaceBoard.tsx
+++ b/frontend/src/pages/WorkspaceBoard.tsx
@@ -2,10 +2,13 @@ import BoardNavbar from '@/components/board/BoardNavbar';
import DroppableColumn from '@/components/board/DroppableColumn';
import TaskDetailModal from '@/components/board/TaskDetailModal';
import LoadingContent from '@/components/ui/LoadingContent';
+import { useWebSocket } from '@/hooks/useWebsocket';
import { archiveColumn, createNewColumn, fetchBoardColumns, fetchBoardDetail, updateColumn, updateColumnPosititon } from '@/services/boardService';
import { notify } from '@/services/toastService';
import { reopenBoard } from '@/services/workspaceService';
import type { Board, Column } from '@/types';
+import type { ColumnOutMsg } from '@/types/ws/columns';
+import type { DomainOutMsg } from '@/types/ws/domains';
import {
DndContext,
DragOverlay,
@@ -33,6 +36,7 @@ import { useParams } from 'react-router-dom';
const WorkspaceBoard = () => {
const { boardId } = useParams();
+ const { lastMessage, send } = useWebSocket();
const [isBoardClosed, setIsBoardClosed] = useState(false);
const [boardDetail, setBoardDetail] = useState({ id: 0, name: '', status: undefined });
const [columns, setColumns] = useState([]);
@@ -43,18 +47,6 @@ const WorkspaceBoard = () => {
const [showDetailModal, setShowDetailModal] = useState(false);
const containerRef = useRef(null);
- const ws = new WebSocket("http://localhost:9000/ws");
-
- ws.onopen = () => {
- console.log("Connected to WS");
- ws.send(JSON.stringify({ type: "ping" }));
- };
-
- ws.onmessage = (event) => {
- const msg = JSON.parse(event.data);
- console.log("Received:", msg);
- };
-
// OPTIMIZATION: Track dragging state separately from active elements
const [isDragging, setIsDragging] = useState(false);
const [dragType, setDragType] = useState<'column' | 'item' | null>(null);
@@ -127,8 +119,31 @@ const WorkspaceBoard = () => {
}
useEffect(() => {
+ send({ type: "join", boardId: Number(boardId) });
fetchBoardData();
- }, []);
+ }, [boardId]);
+
+ useEffect(() => {
+ if (!lastMessage) return;
+
+ switch (lastMessage.type) {
+ case "pong":
+ console.log("Server alive");
+ break;
+
+ case "joined":
+ console.log("Joined board", lastMessage.boardId);
+ break;
+
+ case "columnMoved":
+ console.log("Column moved:", (lastMessage as ColumnOutMsg).columnId);
+ break;
+
+ case "error":
+ console.error("WS Error:", (lastMessage as DomainOutMsg).type);
+ break;
+ }
+ }, [lastMessage]);
useEffect(() => {
const handleClickOutside = (event: MouseEvent) => {
diff --git a/frontend/src/services/axiosClient.ts b/frontend/src/services/axiosClient.ts
index aa6043e..e0701ab 100644
--- a/frontend/src/services/axiosClient.ts
+++ b/frontend/src/services/axiosClient.ts
@@ -13,6 +13,7 @@ const axiosClients: AxiosInstance = axios.create({
axiosClients.interceptors.request.use(
function (config) {
// Add any custom logic before the request is sent
+
return config;
},
function (error) {
diff --git a/frontend/src/services/wsService.ts b/frontend/src/services/wsService.ts
new file mode 100644
index 0000000..8cf02e6
--- /dev/null
+++ b/frontend/src/services/wsService.ts
@@ -0,0 +1,60 @@
+import type { InMsg, OutMsg } from "@/types/ws";
+
+type Listener = (msg: OutMsg) => void;
+
+class WSService {
+ private ws?: WebSocket;
+ private listeners: Listener[] = [];
+ private pending: InMsg[] = [];
+
+ baseURL = `${import.meta.env.VITE_TRELLO_LIKE_API_URL}` || 'http://localhost:9000'
+
+ connect() {
+ if (this.ws) return;
+
+ const wsUrl = `${this.baseURL}/ws`;
+ this.ws = new WebSocket(wsUrl);
+
+ this.ws.onopen = () => {
+ console.log("[WS] Connected");
+ // flush pending
+ this.pending.forEach(msg => this.send(msg));
+ this.pending = [];
+ this.send({ type: "ping" });
+ };
+
+ this.ws.onmessage = (event) => {
+ try {
+ const msg: OutMsg = JSON.parse(event.data);
+ console.log("[WS] Received:", msg);
+ this.listeners.forEach((l) => l(msg));
+ } catch (e) {
+ console.error("[WS] Parse error:", e);
+ }
+ };
+
+ this.ws.onclose = () => {
+ console.warn("[WS] Closed, retrying...");
+ this.ws = undefined;
+ setTimeout(() => this.connect(), 3000);
+ };
+ }
+
+ send(msg: InMsg) {
+ if (this.ws?.readyState === WebSocket.OPEN) {
+ this.ws.send(JSON.stringify(msg));
+ } else {
+ console.log("[WS] Queued until open:", msg);
+ this.pending.push(msg);
+ }
+ }
+
+ onMessage(listener: Listener) {
+ this.listeners.push(listener);
+ return () => {
+ this.listeners = this.listeners.filter((l) => l !== listener);
+ };
+ }
+}
+
+export const wsService = new WSService();
diff --git a/frontend/src/types/ws/columns.ts b/frontend/src/types/ws/columns.ts
new file mode 100644
index 0000000..228ac27
--- /dev/null
+++ b/frontend/src/types/ws/columns.ts
@@ -0,0 +1,5 @@
+export type ColumnInMsg =
+ | { type: 'moveColumn'; boardId: number; columnId: number; newPosition: number };
+
+export type ColumnOutMsg =
+ | { type: 'columnMoved'; columnId: number, newPosition: number }
\ No newline at end of file
diff --git a/frontend/src/types/ws/domains.ts b/frontend/src/types/ws/domains.ts
new file mode 100644
index 0000000..6dbbbad
--- /dev/null
+++ b/frontend/src/types/ws/domains.ts
@@ -0,0 +1,8 @@
+export type DomainInMsg =
+ | { type: 'ping' }
+ | { type: 'join'; boardId: number; };
+
+export type DomainOutMsg =
+ | { type: 'pong' }
+ | { type: 'joined'; boardId: number }
+ | { type: 'error'; message: string };
\ No newline at end of file
diff --git a/frontend/src/types/ws/index.ts b/frontend/src/types/ws/index.ts
new file mode 100644
index 0000000..be6b785
--- /dev/null
+++ b/frontend/src/types/ws/index.ts
@@ -0,0 +1,5 @@
+import type { ColumnInMsg, ColumnOutMsg } from "./columns";
+import type { DomainInMsg, DomainOutMsg } from "./domains";
+
+export type InMsg = DomainInMsg | ColumnInMsg;
+export type OutMsg = DomainOutMsg | ColumnOutMsg;
\ No newline at end of file