Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion backend/Dockerfile
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
# Base image with Java 21
FROM openjdk:21-jdk-slim
FROM eclipse-temurin:21-jdk-jammy

# Install necessary tools: curl, unzip
RUN apt-get update && apt-get install -y curl unzip && rm -rf /var/lib/apt/lists/*
Expand Down
72 changes: 72 additions & 0 deletions backend/app/controllers/UserProfileController.scala
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
package controllers

import javax.inject.{Inject, Singleton}
import play.api.mvc._
import play.api.libs.json._
import services.UserProfileService
import models.entities.UserProfile

import java.time.LocalDateTime
import scala.concurrent.{ExecutionContext, Future}

@Singleton
class UserProfileController @Inject()(
val controllerComponents: ControllerComponents,
userProfileService: UserProfileService,
authenticatedAction: AuthenticatedActionWithUser
)(implicit ec: ExecutionContext) extends BaseController {

implicit val userProfileWrites: OWrites[UserProfile] = Json.writes[UserProfile]

// Request DTO for partial updates
private case class UpdateUserProfileRequest(userLanguage: Option[String], themeMode: Option[String])
private object UpdateUserProfileRequest {
implicit val reads: Reads[UpdateUserProfileRequest] = Json.reads[UpdateUserProfileRequest]
}

def getUserProfile: Action[AnyContent] = authenticatedAction.async { request =>
userProfileService.getUserProfile(request.userToken.userId).map {
case Some(profile) => Ok(Json.toJson(profile))
case None => NotFound
}
}

def createProfile: Action[JsValue] = authenticatedAction.async(parse.json) { request =>
request.body.validate[UpdateUserProfileRequest].fold(
errors => Future.successful(BadRequest(JsError.toJson(errors))),
dto => {
val newProfile = UserProfile(
userId = request.userToken.userId,
userLanguage = dto.userLanguage.get,
themeMode = dto.themeMode.get,
createdAt = LocalDateTime.now(),
updatedAt = LocalDateTime.now()
)
userProfileService.createProfile(newProfile).map { profile =>
Created(Json.toJson(profile))
}
}
)
}

def updateProfile: Action[JsValue] = authenticatedAction.async(parse.json) { request =>
request.body.validate[UpdateUserProfileRequest].fold(
errors => Future.successful(BadRequest(JsError.toJson(errors))),
dto => {
userProfileService.getUserProfile(request.userToken.userId).flatMap {
case None => Future.successful(NotFound)
case Some(existing) =>
val updated = existing.copy(
userLanguage = dto.userLanguage.getOrElse(existing.userLanguage),
themeMode = dto.themeMode.getOrElse(existing.themeMode),
updatedAt = java.time.LocalDateTime.now()
)
userProfileService.updateProfile(updated).map {
case Some(profile) => Ok(Json.toJson(profile))
case None => InternalServerError(Json.obj("error" -> "Failed to update profile"))
}
}
}
)
}
}
8 changes: 5 additions & 3 deletions backend/app/exception/AppException.scala
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@ package exception

import play.api.http.Status

case class AppException(message: String, statusCode: Int = Status.BAD_REQUEST) extends RuntimeException {

}
/**
* Base application exception with an associated HTTP status code.
* Specific exceptions extend this so handlers can map them to proper HTTP responses.
*/
case class AppException(message: String, val statusCode: Int = Status.BAD_REQUEST) extends RuntimeException(message)
6 changes: 6 additions & 0 deletions backend/app/exception/BadRequestException.scala
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
package exception

import play.api.http.Status

case class BadRequestException(message: String = "Bad Request", statusCode: Int = Status.BAD_REQUEST)
extends RuntimeException(message)
6 changes: 6 additions & 0 deletions backend/app/exception/InternalException.scala
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
package exception

import play.api.http.Status

case class InternalErrorException(message: String = "Internal Server Error", statusCode: Int = Status.INTERNAL_SERVER_ERROR)
extends RuntimeException(message)
6 changes: 6 additions & 0 deletions backend/app/exception/NotFoundException.scala
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
package exception

import play.api.http.Status

case class NotFoundException(message: String = "Not Found", statusCode: Int = Status.NOT_FOUND)
extends RuntimeException(message)
6 changes: 6 additions & 0 deletions backend/app/exception/ResourceInactiveException.scala
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
package exception

import play.api.http.Status

case class ResourceInactiveException(message: String = "Resource Inactive", statusCode: Int = Status.CONFLICT)
extends RuntimeException(message)
18 changes: 18 additions & 0 deletions backend/app/models/entities/UserProfile.scala
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
package models.entities

import play.api.libs.json.{Json, OFormat}

import java.time.LocalDateTime

case class UserProfile(
id: Option[Int] = None,
userId: Int,
userLanguage: String,
themeMode: String,
createdAt: LocalDateTime = LocalDateTime.now(),
updatedAt: LocalDateTime = LocalDateTime.now()
)

object UserProfile {
implicit val userProfileFormat: OFormat[Project] = Json.format[Project]
}
1 change: 1 addition & 0 deletions backend/app/models/tables/TableRegistry.scala
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ object TableRegistry {
lazy val notifications = TableQuery[NotificationTable]
lazy val activityLogs = TableQuery[ActivityLogTable]
lazy val userTasks = TableQuery[UserTaskTable]
lazy val userProfiles = TableQuery[UserProfileTable]

// All tables for schema creation/evolution
val allTables = Seq(
Expand Down
31 changes: 31 additions & 0 deletions backend/app/models/tables/UserProfileTable.scala
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
package models.tables

import models.entities.UserProfile
import java.time.LocalDateTime
import db.MyPostgresProfile.api._

class UserProfileTable(tag: Tag) extends Table[UserProfile](tag, "user_profiles") {
def id = column[Int]("id", O.PrimaryKey, O.AutoInc)
def userId = column[Int]("user_id")
def userLanguage = column[String]("user_language")
def themeMode = column[String]("theme_mode")
def createdAt = column[LocalDateTime]("created_at")
def updatedAt = column[LocalDateTime]("updated_at")

def * = (
id.?,
userId,
userLanguage,
themeMode,
createdAt,
updatedAt
) <> ((UserProfile.apply _).tupled, UserProfile.unapply)

private def userFk = foreignKey(
"user_profile_user_fk",
userId,
TableQuery[UserTable]
)(_.id, onDelete = ForeignKeyAction.Cascade)

private def userIdIdx = index("user_profile_user_id_idx", userId, unique = true)
}
42 changes: 42 additions & 0 deletions backend/app/repositories/UserProfileRepository.scala
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
package repositories

import javax.inject.{Inject, Singleton}
import models.entities.UserProfile
import play.api.db.slick.{DatabaseConfigProvider, HasDatabaseConfigProvider}
import models.tables.TableRegistry.userProfiles
import slick.jdbc.JdbcProfile

import scala.concurrent.{ExecutionContext, Future}

@Singleton
class UserProfileRepository @Inject()(
protected val dbConfigProvider: DatabaseConfigProvider
)(implicit ec: ExecutionContext) extends HasDatabaseConfigProvider[JdbcProfile] {

import profile.api._

def create(userProfile: UserProfile): Future[UserProfile] = {
val query = userProfiles returning userProfiles.map(_.id) into ((profile, id) => profile.copy(id = Some(id)))
db.run(query += userProfile)
}

def update(userProfile: UserProfile): Future[Int] = {
val query = userProfiles.filter(_.id === userProfile.id).update(userProfile)
db.run(query)
}

def findByUserId(userId: Int): Future[Option[UserProfile]] = {
val query = userProfiles.filter(_.userId === userId)
db.run(query.result.headOption)
}

def findById(id: Int): Future[Option[UserProfile]] = {
val query = userProfiles.filter(_.id === id)
db.run(query.result.headOption)
}

def delete(id: Int): Future[Int] = {
val query = userProfiles.filter(_.id === id).delete
db.run(query)
}
}
27 changes: 27 additions & 0 deletions backend/app/services/UserProfileService.scala
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
package services

import javax.inject.{Inject, Singleton}
import models.entities.UserProfile
import repositories.UserProfileRepository
import scala.concurrent.{ExecutionContext, Future}

@Singleton
class UserProfileService @Inject()(
userProfileRepository: UserProfileRepository
)(implicit ec: ExecutionContext) {

def createProfile(newProfile: UserProfile): Future[UserProfile] = {
userProfileRepository.create(newProfile)
}

def updateProfile(userProfile: UserProfile): Future[Option[UserProfile]] = {
userProfileRepository.update(userProfile).flatMap {
case 0 => Future.successful(None)
case _ => userProfileRepository.findById(userProfile.id.get)
}
}

def getUserProfile(userId: Int): Future[Option[UserProfile]] = {
userProfileRepository.findByUserId(userId)
}
}
1 change: 1 addition & 0 deletions backend/app/services/WorkspaceService.scala
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import slick.jdbc.JdbcProfile
import java.time.Instant
import javax.inject.{Inject, Singleton}
import scala.concurrent.{ExecutionContext, Future}
import exception.AppException

@Singleton
class WorkspaceService @Inject()(
Expand Down
2 changes: 1 addition & 1 deletion backend/conf/application.conf
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,7 @@ slick.dbs.default.db.url = "jdbc:postgresql://localhost:5432/smart_taskhub"
slick.dbs.default.db.url = ${?DB_URL}
slick.dbs.default.db.user = "postgres"
slick.dbs.default.db.user = ${?DB_USER}
slick.dbs.default.db.password = "postgres"
slick.dbs.default.db.password = "admin"
slick.dbs.default.db.password = ${?DB_PASSWORD}

# Play evolutions
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
CREATE TABLE user_profiles (
id SERIAL PRIMARY KEY,
user_id INT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
user_language VARCHAR(10) NOT NULL DEFAULT 'en',
theme_mode VARCHAR(10) NOT NULL DEFAULT 'Light',
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT user_profile_user_id_idx UNIQUE (user_id)
);
5 changes: 5 additions & 0 deletions backend/conf/routes
Original file line number Diff line number Diff line change
Expand Up @@ -66,5 +66,10 @@ GET /api/projects/:projectId/columns/tasks/active controllers.TaskControll
GET /api/projects/:projectId/columns/:columnsId/tasks controllers.TaskController.getActiveTasksInColumn(projectId: Int, columnsId: Int, limit: Int ?= 20, page: Int ?= 1)
GET /api/tasks controllers.TaskController.search(page: Int ?= 2, size: Int ?= 10, keyword: String ?= "", projectIds: Option[List[Int]])

# User Profile routes
GET /api/user/profile controllers.UserProfileController.getUserProfile()
POST /api/user/profile controllers.UserProfileController.createProfile()
PUT /api/user/profile controllers.UserProfileController.updateProfile()

#Web socket
GET /ws/project/:projectId controllers.WebSocketController.joinProject(projectId: Int)
Loading