diff --git a/api/src/main/kotlin/com/backgu/amaker/api/chat/service/ChatRoomFacadeService.kt b/api/src/main/kotlin/com/backgu/amaker/api/chat/service/ChatRoomFacadeService.kt index 7b87e516..aa2df298 100644 --- a/api/src/main/kotlin/com/backgu/amaker/api/chat/service/ChatRoomFacadeService.kt +++ b/api/src/main/kotlin/com/backgu/amaker/api/chat/service/ChatRoomFacadeService.kt @@ -37,7 +37,7 @@ class ChatRoomFacadeService( val user: User = userService.getById(userId) val workspace: Workspace = workspaceService.getById(workspaceId) - workspaceUserService.validUserInWorkspace(user, workspace) + workspaceUserService.validateUserWorkspaceInActive(user, workspace) // 유저의 채팅방 목록 조회 val chatRoomMys: List = chatRoomUserService.findAllByUser(user) @@ -86,7 +86,7 @@ class ChatRoomFacadeService( ): BriefChatRoomViewDto { val user: User = userService.getById(userId) val workspace: Workspace = workspaceService.getById(workspaceId) - workspaceUserService.validUserInWorkspace(user, workspace) + workspaceUserService.validateUserWorkspaceInActive(user, workspace) val chatRooms: List = chatRoomService.findChatRoomsByWorkspaceId(workspaceId) val chatRoomUserMap: Map> = @@ -111,7 +111,7 @@ class ChatRoomFacadeService( ): BriefChatRoomViewDto { val user: User = userService.getById(userId) val workspace: Workspace = workspaceService.getById(workspaceId) - workspaceUserService.validUserInWorkspace(user, workspace) + workspaceUserService.validateUserWorkspaceInActive(user, workspace) val chatRooms: List = chatRoomService.findNotRegisteredChatRoomsByWorkspaceId(workspaceId, userId) val chatRoomUserMap: Map> = @@ -154,7 +154,7 @@ class ChatRoomFacadeService( ): ChatRoomUser { val user: User = userService.getById(userId) val workspace: Workspace = workspaceService.getById(workspaceId) - workspaceUserService.validUserInWorkspace(user, workspace) + workspaceUserService.validateUserWorkspaceInActive(user, workspace) val chatRoom = chatRoomService.getChatRoomByWorkspaceIdAndChatRoomId(workspaceId, chatRoomId) chatRoomUserService.validateUserNotInChatRoom(user, chatRoom) diff --git a/api/src/main/kotlin/com/backgu/amaker/api/event/config/EventServiceConfig.kt b/api/src/main/kotlin/com/backgu/amaker/api/event/config/EventServiceConfig.kt index 4cd5c69f..87af8dad 100644 --- a/api/src/main/kotlin/com/backgu/amaker/api/event/config/EventServiceConfig.kt +++ b/api/src/main/kotlin/com/backgu/amaker/api/event/config/EventServiceConfig.kt @@ -2,10 +2,14 @@ package com.backgu.amaker.api.event.config import com.backgu.amaker.application.event.service.EventAssignedUserService import com.backgu.amaker.application.event.service.EventService +import com.backgu.amaker.application.event.service.ReactionEventService +import com.backgu.amaker.application.event.service.ReactionOptionService import com.backgu.amaker.application.event.service.ReplyCommentService import com.backgu.amaker.application.event.service.ReplyEventService import com.backgu.amaker.infra.jpa.event.repository.EventAssignedUserRepository import com.backgu.amaker.infra.jpa.event.repository.EventRepository +import com.backgu.amaker.infra.jpa.event.repository.ReactionEventRepository +import com.backgu.amaker.infra.jpa.event.repository.ReactionOptionRepository import com.backgu.amaker.infra.jpa.event.repository.ReplyCommentRepository import com.backgu.amaker.infra.jpa.event.repository.ReplyEventRepository import org.springframework.context.annotation.Bean @@ -26,4 +30,12 @@ class EventServiceConfig { @Bean fun eventService(eventRepository: EventRepository): EventService = EventService(eventRepository) + + @Bean + fun reactionEventService(reactionEventRepository: ReactionEventRepository): ReactionEventService = + ReactionEventService(reactionEventRepository) + + @Bean + fun reactionOptionService(reactionOptionRepository: ReactionOptionRepository): ReactionOptionService = + ReactionOptionService(reactionOptionRepository) } diff --git a/api/src/main/kotlin/com/backgu/amaker/api/event/controller/EventController.kt b/api/src/main/kotlin/com/backgu/amaker/api/event/controller/EventController.kt index 05c6eeff..8bc5809b 100644 --- a/api/src/main/kotlin/com/backgu/amaker/api/event/controller/EventController.kt +++ b/api/src/main/kotlin/com/backgu/amaker/api/event/controller/EventController.kt @@ -1,6 +1,8 @@ package com.backgu.amaker.api.event.controller +import com.backgu.amaker.api.event.dto.request.ReactionEventCreateRequest import com.backgu.amaker.api.event.dto.request.ReplyEventCreateRequest +import com.backgu.amaker.api.event.dto.response.ReactionEventDetailResponse import com.backgu.amaker.api.event.dto.response.ReplyEventDetailResponse import com.backgu.amaker.api.event.service.EventFacadeService import com.backgu.amaker.common.http.ApiHandler @@ -23,6 +25,46 @@ class EventController( private val eventFacadeService: EventFacadeService, private val apiHandler: ApiHandler, ) : EventSwagger { + @GetMapping("/events/{event-id}/reply") + override fun getReplyEvent( + @AuthenticationPrincipal token: JwtAuthentication, + @PathVariable("chat-room-id") chatRoomId: Long, + @PathVariable("event-id") eventId: Long, + ): ResponseEntity> = + ResponseEntity + .ok() + .body( + apiHandler.onSuccess( + ReplyEventDetailResponse.of( + eventFacadeService.getReplyEvent( + token.id, + chatRoomId, + eventId, + ), + ), + ), + ) + + @GetMapping("/events/{event-id}/reaction") + override fun getReactionEvent( + @AuthenticationPrincipal token: JwtAuthentication, + @PathVariable("chat-room-id") chatRoomId: Long, + @PathVariable("event-id") eventId: Long, + ): ResponseEntity> = + ResponseEntity + .ok() + .body( + apiHandler.onSuccess( + ReactionEventDetailResponse.of( + eventFacadeService.getReactionEvent( + token.id, + chatRoomId, + eventId, + ), + ), + ), + ) + @PostMapping("/events/reply") override fun createReplyEvent( @AuthenticationPrincipal token: JwtAuthentication, @@ -44,23 +86,24 @@ class EventController( ).toUri(), ).build() - @GetMapping("/events/{event-id}/reply") - override fun getReplyEvent( + @PostMapping("/events/reaction") + override fun createReactionEvent( @AuthenticationPrincipal token: JwtAuthentication, @PathVariable("chat-room-id") chatRoomId: Long, - @PathVariable("event-id") eventId: Long, - ): ResponseEntity> = + @RequestBody @Valid request: ReactionEventCreateRequest, + ): ResponseEntity = ResponseEntity - .ok() - .body( - apiHandler.onSuccess( - ReplyEventDetailResponse.of( - eventFacadeService.getReplyEvent( - token.id, - chatRoomId, - eventId, - ), - ), - ), - ) + .created( + ServletUriComponentsBuilder + .fromCurrentContextPath() + .path("/api/v1/events/{id}/reply") + .buildAndExpand( + eventFacadeService + .createReactionEvent( + token.id, + chatRoomId, + request.toDto(), + ).id, + ).toUri(), + ).build() } diff --git a/api/src/main/kotlin/com/backgu/amaker/api/event/controller/EventSwagger.kt b/api/src/main/kotlin/com/backgu/amaker/api/event/controller/EventSwagger.kt index 9c31a10d..49bc77a8 100644 --- a/api/src/main/kotlin/com/backgu/amaker/api/event/controller/EventSwagger.kt +++ b/api/src/main/kotlin/com/backgu/amaker/api/event/controller/EventSwagger.kt @@ -1,6 +1,8 @@ package com.backgu.amaker.api.event.controller +import com.backgu.amaker.api.event.dto.request.ReactionEventCreateRequest import com.backgu.amaker.api.event.dto.request.ReplyEventCreateRequest +import com.backgu.amaker.api.event.dto.response.ReactionEventDetailResponse import com.backgu.amaker.api.event.dto.response.ReplyEventDetailResponse import com.backgu.amaker.common.http.response.ApiResult import com.backgu.amaker.common.security.jwt.authentication.JwtAuthentication @@ -12,10 +14,41 @@ import jakarta.validation.Valid import org.springframework.http.ResponseEntity import org.springframework.security.core.annotation.AuthenticationPrincipal import org.springframework.web.bind.annotation.PathVariable +import org.springframework.web.bind.annotation.PostMapping import org.springframework.web.bind.annotation.RequestBody @Tag(name = "event", description = "이벤트 API") interface EventSwagger { + @Operation(summary = "reply 이벤트 상세조회", description = "reply 이벤트 상세조회합니다.") + @ApiResponses( + value = [ + ApiResponse( + responseCode = "200", + description = "reply 이벤트 상세조회 성공", + ), + ], + ) + fun getReplyEvent( + @AuthenticationPrincipal token: JwtAuthentication, + @PathVariable("chat-room-id") chatRoomId: Long, + @PathVariable("event-id") eventId: Long, + ): ResponseEntity> + + @Operation(summary = "reaction 이벤트 상세조회", description = "reaction 이벤트 상세조회합니다.") + @ApiResponses( + value = [ + ApiResponse( + responseCode = "200", + description = "reaction 이벤트 상세조회 성공", + ), + ], + ) + fun getReactionEvent( + token: JwtAuthentication, + chatRoomId: Long, + eventId: Long, + ): ResponseEntity> + @Operation(summary = "reply 이벤트 생성", description = "reply 이벤트 생성합니다.") @ApiResponses( value = [ @@ -31,18 +64,19 @@ interface EventSwagger { @RequestBody @Valid request: ReplyEventCreateRequest, ): ResponseEntity - @Operation(summary = "reply 이벤트 상세조회", description = "reply 이벤트 상세조회합니다.") + @Operation(summary = "reaction 이벤트 생성", description = "reaction 이벤트 생성합니다.") @ApiResponses( value = [ ApiResponse( - responseCode = "200", - description = "reply 이벤트 상세조회 성공", + responseCode = "201", + description = "reaction 이벤트 생성 성공", ), ], ) - fun getReplyEvent( - @AuthenticationPrincipal token: JwtAuthentication, - @PathVariable("chat-room-id") chatRoomId: Long, - @PathVariable("event-id") eventId: Long, - ): ResponseEntity> + @PostMapping("/events/reaction") + fun createReactionEvent( + token: JwtAuthentication, + chatRoomId: Long, + request: ReactionEventCreateRequest, + ): ResponseEntity } diff --git a/api/src/main/kotlin/com/backgu/amaker/api/event/dto/ReactionEventCreateDto.kt b/api/src/main/kotlin/com/backgu/amaker/api/event/dto/ReactionEventCreateDto.kt new file mode 100644 index 00000000..be932a6c --- /dev/null +++ b/api/src/main/kotlin/com/backgu/amaker/api/event/dto/ReactionEventCreateDto.kt @@ -0,0 +1,13 @@ +package com.backgu.amaker.api.event.dto + +import java.time.LocalDateTime + +data class ReactionEventCreateDto( + val eventTitle: String, + val options: List, + val assignees: List, + val deadLine: LocalDateTime, + val notificationStartHour: Int, + val notificationStartMinute: Int, + val interval: Int, +) diff --git a/api/src/main/kotlin/com/backgu/amaker/api/event/dto/ReactionEventDetailDto.kt b/api/src/main/kotlin/com/backgu/amaker/api/event/dto/ReactionEventDetailDto.kt new file mode 100644 index 00000000..0d17fbbf --- /dev/null +++ b/api/src/main/kotlin/com/backgu/amaker/api/event/dto/ReactionEventDetailDto.kt @@ -0,0 +1,38 @@ +package com.backgu.amaker.api.event.dto + +import com.backgu.amaker.api.user.dto.UserDto +import com.backgu.amaker.domain.event.ReactionEvent +import com.backgu.amaker.domain.event.ReactionOption +import java.time.LocalDateTime + +data class ReactionEventDetailDto( + val id: Long, + val eventTitle: String, + val options: List, + val deadLine: LocalDateTime, + val notificationStartTime: LocalDateTime, + val notificationInterval: Int, + val eventCreator: UserDto, + val finishUser: List, + val waitingUser: List, +) { + companion object { + fun of( + reactionEvent: ReactionEvent, + reactionOptions: List, + eventCreator: UserDto, + finishUser: List, + waitingUser: List, + ) = ReactionEventDetailDto( + id = reactionEvent.id, + eventTitle = reactionEvent.eventTitle, + options = reactionOptions.map { ReactionOptionDto.of(it) }, + deadLine = reactionEvent.deadLine, + notificationStartTime = reactionEvent.notificationStartTime, + notificationInterval = reactionEvent.notificationInterval, + eventCreator = eventCreator, + finishUser = finishUser, + waitingUser = waitingUser, + ) + } +} diff --git a/api/src/main/kotlin/com/backgu/amaker/api/event/dto/ReactionEventDto.kt b/api/src/main/kotlin/com/backgu/amaker/api/event/dto/ReactionEventDto.kt new file mode 100644 index 00000000..2cf1fb96 --- /dev/null +++ b/api/src/main/kotlin/com/backgu/amaker/api/event/dto/ReactionEventDto.kt @@ -0,0 +1,28 @@ +package com.backgu.amaker.api.event.dto + +import com.backgu.amaker.domain.event.ReactionEvent +import com.backgu.amaker.domain.event.ReactionOption +import java.time.LocalDateTime + +data class ReactionEventDto( + val id: Long, + val eventTitle: String, + val options: List, + val deadLine: LocalDateTime, + val notificationStartTime: LocalDateTime, + val notificationInterval: Int, +) { + companion object { + fun of( + reactionEvent: ReactionEvent, + options: List, + ) = ReactionEventDto( + id = reactionEvent.id, + eventTitle = reactionEvent.eventTitle, + options = options.map { it.content }, + deadLine = reactionEvent.deadLine, + notificationStartTime = reactionEvent.notificationStartTime, + notificationInterval = reactionEvent.notificationInterval, + ) + } +} diff --git a/api/src/main/kotlin/com/backgu/amaker/api/event/dto/ReactionOptionDto.kt b/api/src/main/kotlin/com/backgu/amaker/api/event/dto/ReactionOptionDto.kt new file mode 100644 index 00000000..6cea3022 --- /dev/null +++ b/api/src/main/kotlin/com/backgu/amaker/api/event/dto/ReactionOptionDto.kt @@ -0,0 +1,18 @@ +package com.backgu.amaker.api.event.dto + +import com.backgu.amaker.domain.event.ReactionOption + +data class ReactionOptionDto( + val id: Long, + val eventId: Long, + val content: String, +) { + companion object { + fun of(reactionOption: ReactionOption) = + ReactionOptionDto( + id = reactionOption.id, + eventId = reactionOption.eventId, + content = reactionOption.content, + ) + } +} diff --git a/api/src/main/kotlin/com/backgu/amaker/api/event/dto/request/ReactionEventCreateRequest.kt b/api/src/main/kotlin/com/backgu/amaker/api/event/dto/request/ReactionEventCreateRequest.kt new file mode 100644 index 00000000..9fd759cf --- /dev/null +++ b/api/src/main/kotlin/com/backgu/amaker/api/event/dto/request/ReactionEventCreateRequest.kt @@ -0,0 +1,39 @@ +package com.backgu.amaker.api.event.dto.request + +import com.backgu.amaker.api.event.dto.ReactionEventCreateDto +import io.swagger.v3.oas.annotations.media.Schema +import jakarta.validation.constraints.NotBlank +import jakarta.validation.constraints.Size +import org.springframework.format.annotation.DateTimeFormat +import java.time.LocalDateTime + +data class ReactionEventCreateRequest( + @Schema(description = "제목", example = "제목 어때요?") + @field:NotBlank(message = "이벤트 제목을 입력해주세요.") + val eventTitle: String, + @Schema(description = "선택지", example = "[\"예\", \"아니오\"]") + val options: List, + @Schema(description = "답변을 요청할 인원", example = "[\"user1\", \"user2\"]") + @field:Size(min = 1, message = "최소 한 명 이상의 인원을 지정해야 합니다.") + val assignees: List, + @Schema(description = "마감 기한", example = "2021-08-01T00:00:00") + @DateTimeFormat(pattern = "yyyy-MM-dd'T'HH:mm:ss") + val deadLine: LocalDateTime, + @Schema(description = "알림 시작 시간", example = "1") + val notificationStartHour: Int, + @Schema(description = "알림 시작 분", example = "30") + val notificationStartMinute: Int, + @Schema(description = "알림 주기", example = "15") + val interval: Int, +) { + fun toDto() = + ReactionEventCreateDto( + eventTitle = eventTitle, + options = options, + assignees = assignees, + deadLine = deadLine, + notificationStartMinute = notificationStartMinute, + notificationStartHour = notificationStartHour, + interval = interval, + ) +} diff --git a/api/src/main/kotlin/com/backgu/amaker/api/event/dto/response/ReactionEventDetailResponse.kt b/api/src/main/kotlin/com/backgu/amaker/api/event/dto/response/ReactionEventDetailResponse.kt new file mode 100644 index 00000000..87ef1b90 --- /dev/null +++ b/api/src/main/kotlin/com/backgu/amaker/api/event/dto/response/ReactionEventDetailResponse.kt @@ -0,0 +1,42 @@ +package com.backgu.amaker.api.event.dto.response + +import com.backgu.amaker.api.event.dto.ReactionEventDetailDto +import com.backgu.amaker.api.user.dto.response.UserResponse +import io.swagger.v3.oas.annotations.media.Schema +import java.time.LocalDateTime + +data class ReactionEventDetailResponse( + @Schema(description = "이벤트 id", example = "1") + val id: Long, + @Schema(description = "이벤트 제목", example = "우리 어디서 만날지") + val eventTitle: String, + @Schema(description = "선택지") + val options: List, + @Schema(description = "데드라인", example = "2024-07-24T07:39:37.598") + val deadLine: LocalDateTime, + @Schema(description = "알림 보낼 시작 시간", example = "2024-07-24T06:09:37.598") + val notificationStartTime: LocalDateTime, + @Schema(description = "알림 주기", example = "15") + val notificationInterval: Int, + @Schema(description = "이벤트 생성자") + val eventCreator: UserResponse, + @Schema(description = "이벤트를 수행한 유저") + val finishUser: List, + @Schema(description = "이벤트 수행 대기중인 유저") + val waitingUser: List, +) { + companion object { + fun of(reactionEventDetailDto: ReactionEventDetailDto) = + ReactionEventDetailResponse( + id = reactionEventDetailDto.id, + eventTitle = reactionEventDetailDto.eventTitle, + options = reactionEventDetailDto.options.map { ReactionOptionResponse.of(it) }, + deadLine = reactionEventDetailDto.deadLine, + notificationStartTime = reactionEventDetailDto.notificationStartTime, + notificationInterval = reactionEventDetailDto.notificationInterval, + eventCreator = UserResponse.of(reactionEventDetailDto.eventCreator), + finishUser = reactionEventDetailDto.finishUser.map { UserResponse.of(it) }, + waitingUser = reactionEventDetailDto.waitingUser.map { UserResponse.of(it) }, + ) + } +} diff --git a/api/src/main/kotlin/com/backgu/amaker/api/event/dto/response/ReactionOptionResponse.kt b/api/src/main/kotlin/com/backgu/amaker/api/event/dto/response/ReactionOptionResponse.kt new file mode 100644 index 00000000..d15e1a5b --- /dev/null +++ b/api/src/main/kotlin/com/backgu/amaker/api/event/dto/response/ReactionOptionResponse.kt @@ -0,0 +1,22 @@ +package com.backgu.amaker.api.event.dto.response + +import com.backgu.amaker.api.event.dto.ReactionOptionDto +import io.swagger.v3.oas.annotations.media.Schema + +data class ReactionOptionResponse( + @Schema(description = "선택지 id", example = "1") + val id: Long, + @Schema(description = "이벤트 id", example = "1") + val eventId: Long, + @Schema(description = "내용", example = "옵션 1") + val content: String, +) { + companion object { + fun of(reactionOptionDto: ReactionOptionDto) = + ReactionOptionResponse( + id = reactionOptionDto.id, + eventId = reactionOptionDto.eventId, + content = reactionOptionDto.content, + ) + } +} diff --git a/api/src/main/kotlin/com/backgu/amaker/api/event/service/EventFacadeService.kt b/api/src/main/kotlin/com/backgu/amaker/api/event/service/EventFacadeService.kt index de5f99e4..35230131 100644 --- a/api/src/main/kotlin/com/backgu/amaker/api/event/service/EventFacadeService.kt +++ b/api/src/main/kotlin/com/backgu/amaker/api/event/service/EventFacadeService.kt @@ -1,5 +1,8 @@ package com.backgu.amaker.api.event.service +import com.backgu.amaker.api.event.dto.ReactionEventCreateDto +import com.backgu.amaker.api.event.dto.ReactionEventDetailDto +import com.backgu.amaker.api.event.dto.ReactionEventDto import com.backgu.amaker.api.event.dto.ReplyEventCreateDto import com.backgu.amaker.api.event.dto.ReplyEventDetailDto import com.backgu.amaker.api.event.dto.ReplyEventDto @@ -9,6 +12,8 @@ import com.backgu.amaker.application.chat.service.ChatRoomService import com.backgu.amaker.application.chat.service.ChatRoomUserService import com.backgu.amaker.application.chat.service.ChatService import com.backgu.amaker.application.event.service.EventAssignedUserService +import com.backgu.amaker.application.event.service.ReactionEventService +import com.backgu.amaker.application.event.service.ReactionOptionService import com.backgu.amaker.application.event.service.ReplyEventService import com.backgu.amaker.application.user.service.UserService import com.backgu.amaker.common.exception.BusinessException @@ -27,9 +32,90 @@ class EventFacadeService( private val chatRoomUserService: ChatRoomUserService, private val chatService: ChatService, private val replyEventService: ReplyEventService, + private val reactionEventService: ReactionEventService, + private val reactionOptionService: ReactionOptionService, private val eventAssignedUserService: EventAssignedUserService, private val eventPublisher: ApplicationEventPublisher, ) { + @Transactional + fun getReplyEvent( + userId: String, + chatRoomId: Long, + eventId: Long, + ): ReplyEventDetailDto { + val user = userService.getById(userId) + val chatRoom = chatRoomService.getById(chatRoomId) + chatRoomUserService.validateUserInChatRoom(user, chatRoom) + + val chat = chatService.getById(eventId) + val eventAssignedUsers = eventAssignedUserService.findAllByEventId(eventId) + val eventAssignedUserIds = eventAssignedUsers.map { it.userId } + + val users = userService.findAllByUserIdsToMap(eventAssignedUserIds.union(listOf(chat.userId)).toList()) + + val replyEvent = replyEventService.getById(eventId) + + val (finishedUsers, waitingUsers) = eventAssignedUsers.partition { it.isFinished } + + return ReplyEventDetailDto.of( + replyEvent = replyEvent, + eventCreator = UserDto.of(users[chat.userId] ?: throw BusinessException(StatusCode.USER_NOT_FOUND)), + finishUser = + finishedUsers.map { + UserDto.of( + users[it.userId] ?: throw BusinessException(StatusCode.USER_NOT_FOUND), + ) + }, + waitingUser = + waitingUsers.map { + UserDto.of( + users[it.userId] ?: throw BusinessException(StatusCode.USER_NOT_FOUND), + ) + }, + ) + } + + @Transactional + fun getReactionEvent( + userId: String, + chatRoomId: Long, + eventId: Long, + ): ReactionEventDetailDto { + val user = userService.getById(userId) + val chatRoom = chatRoomService.getById(chatRoomId) + chatRoomUserService.validateUserInChatRoom(user, chatRoom) + + val chat = chatService.getById(eventId) + val eventAssignedUsers = eventAssignedUserService.findAllByEventId(eventId) + val eventAssignedUserIds = eventAssignedUsers.map { it.userId } + + val users = userService.findAllByUserIdsToMap(eventAssignedUserIds.union(listOf(chat.userId)).toList()) + + val reactionEvent = reactionEventService.getById(eventId) + + val reactionOptions = reactionOptionService.getAllByEventId(eventId) + + val (finishedUsers, waitingUsers) = eventAssignedUsers.partition { it.isFinished } + + return ReactionEventDetailDto.of( + reactionEvent = reactionEvent, + reactionOptions = reactionOptions, + eventCreator = UserDto.of(users[chat.userId] ?: throw BusinessException(StatusCode.USER_NOT_FOUND)), + finishUser = + finishedUsers.map { + UserDto.of( + users[it.userId] ?: throw BusinessException(StatusCode.USER_NOT_FOUND), + ) + }, + waitingUser = + waitingUsers.map { + UserDto.of( + users[it.userId] ?: throw BusinessException(StatusCode.USER_NOT_FOUND), + ) + }, + ) + } + @Transactional fun createReplyEvent( userId: String, @@ -70,40 +156,44 @@ class EventFacadeService( } @Transactional - fun getReplyEvent( + fun createReactionEvent( userId: String, chatRoomId: Long, - eventId: Long, - ): ReplyEventDetailDto { + reactionEventCreateDto: ReactionEventCreateDto, + ): ReactionEventDto { val user = userService.getById(userId) val chatRoom = chatRoomService.getById(chatRoomId) chatRoomUserService.validateUserInChatRoom(user, chatRoom) - val chat = chatService.getById(eventId) - val eventAssignedUsers = eventAssignedUserService.findAllByEventId(eventId) - val eventAssignedUserIds = eventAssignedUsers.map { it.userId } + val chat: Chat = + chatService.save(chatRoom.createChat(user, reactionEventCreateDto.eventTitle, ChatType.REACTION)) + chatRoomService.save(chatRoom.updateLastChatId(chat)) - val users = userService.findAllByUserIdsToMap(eventAssignedUserIds.union(listOf(chat.userId)).toList()) + val reactionEvent = + reactionEventService.save( + chat.createReactionEvent( + reactionEventCreateDto.deadLine, + reactionEventCreateDto.notificationStartHour, + reactionEventCreateDto.notificationStartMinute, + reactionEventCreateDto.interval, + ), + ) - val replyEvent = replyEventService.getById(eventId) + val reactionOptions = + reactionOptionService.saveAll(reactionEvent.createReactionOption(reactionEventCreateDto.options)) - val (finishedUsers, waitingUsers) = eventAssignedUsers.partition { it.isFinished } + val users = userService.getAllByUserEmails(reactionEventCreateDto.assignees) + chatRoomUserService.validateUsersInChatRoom(users, chatRoom) - return ReplyEventDetailDto.of( - replyEvent = replyEvent, - eventCreator = UserDto.of(users[chat.userId] ?: throw BusinessException(StatusCode.USER_NOT_FOUND)), - finishUser = - finishedUsers.map { - UserDto.of( - users[it.userId] ?: throw BusinessException(StatusCode.USER_NOT_FOUND), - ) - }, - waitingUser = - waitingUsers.map { - UserDto.of( - users[it.userId] ?: throw BusinessException(StatusCode.USER_NOT_FOUND), - ) - }, + eventAssignedUserService.saveAll(reactionEvent.createAssignedUsers(users)) + + eventPublisher.publishEvent( + EventChatSaveEvent.of( + chatRoomId, + chat.createEventChatWithUser(reactionEvent, user, users), + ), ) + + return ReactionEventDto.of(reactionEvent, reactionOptions) } } diff --git a/api/src/main/kotlin/com/backgu/amaker/api/workspace/service/WorkspaceFacadeService.kt b/api/src/main/kotlin/com/backgu/amaker/api/workspace/service/WorkspaceFacadeService.kt index a7ab81a0..28c25136 100644 --- a/api/src/main/kotlin/com/backgu/amaker/api/workspace/service/WorkspaceFacadeService.kt +++ b/api/src/main/kotlin/com/backgu/amaker/api/workspace/service/WorkspaceFacadeService.kt @@ -62,11 +62,9 @@ class WorkspaceFacadeService( userId: String, workspaceId: Long, ): WorkspaceDto { - val user: User = userService.getById(userId) + workspaceUserService.validateUserInWorkspace(userId, workspaceId) val workspace: Workspace = workspaceService.getWorkspaceById(workspaceId) - workspaceUserService.validUserInWorkspace(user, workspace) - return WorkspaceDto.of(workspace) } @@ -94,7 +92,7 @@ class WorkspaceFacadeService( val user: User = userService.getById(userId) val workspace: Workspace = workspaceService.getWorkspaceById(workspaceId) - workspaceUserService.validUserInWorkspace(user, workspace) + workspaceUserService.validateUserWorkspaceInActive(user, workspace) val workspaceUsers = workspaceUserService.findWorkSpaceUserByWorkspaceId(workspaceId) @@ -120,7 +118,7 @@ class WorkspaceFacadeService( val user: User = userService.getById(userId) val workspace: Workspace = workspaceService.getWorkspaceById(workspaceId) - workspaceUserService.validUserInWorkspace(user, workspace) + workspaceUserService.validateUserWorkspaceInActive(user, workspace) return ChatRoomDto .of(chatRoomService.getDefaultChatRoomByWorkspace(workspace)) diff --git a/api/src/test/kotlin/com/backgu/amaker/api/event/service/EventFacadeServiceTest.kt b/api/src/test/kotlin/com/backgu/amaker/api/event/service/EventFacadeServiceTest.kt index 588e3a99..41a511f1 100644 --- a/api/src/test/kotlin/com/backgu/amaker/api/event/service/EventFacadeServiceTest.kt +++ b/api/src/test/kotlin/com/backgu/amaker/api/event/service/EventFacadeServiceTest.kt @@ -1,6 +1,7 @@ package com.backgu.amaker.api.event.service import com.backgu.amaker.api.common.container.IntegrationTest +import com.backgu.amaker.api.event.dto.ReactionEventCreateDto import com.backgu.amaker.api.event.dto.ReplyEventCreateDto import com.backgu.amaker.api.fixture.EventFixtureFacade import com.backgu.amaker.domain.chat.ChatRoom @@ -38,7 +39,6 @@ class EventFacadeServiceTest : IntegrationTest() { @DisplayName("reply 이벤트 생성 테스트") fun createReplyEvent() { // given - val replyEventCreateDto = ReplyEventCreateDto( eventTitle = "eventTitle", @@ -63,6 +63,34 @@ class EventFacadeServiceTest : IntegrationTest() { assertThat(replyEvent.deadLine).isEqualTo(replyEventCreateDto.deadLine) } + @Test + @DisplayName("reaction 이벤트 생성 테스트") + fun createReactionEvent() { + // given + val reactionEventCreateDto = + ReactionEventCreateDto( + eventTitle = "eventTitle", + deadLine = LocalDateTime.now().plusDays(1), + notificationStartHour = 1, + notificationStartMinute = 30, + interval = 10, + options = listOf("option1", "option2"), + assignees = listOf("$DEFAULT_USER_ID@email.com"), + ) + + // when + val replyEvent = + eventFacadeService.createReactionEvent( + userId = DEFAULT_USER_ID, + chatRoomId = chatRoom.id, + reactionEventCreateDto = reactionEventCreateDto, + ) + + // then + assertThat(replyEvent).isNotNull() + assertThat(replyEvent.deadLine).isEqualTo(reactionEventCreateDto.deadLine) + } + @Test @DisplayName("reply 이벤트 조회 테스트") fun getReplyEvent() { @@ -114,4 +142,59 @@ class EventFacadeServiceTest : IntegrationTest() { assertThat(result.waitingUser.size).isEqualTo(2) assertThat(result.eventCreator.id).isEqualTo(anotherUser) } + + @Test + @DisplayName("reaction 이벤트 조회 테스트") + fun getReactionEvent() { + // given + val anotherUser = "another-user" + val chat = + fixtures.chatFixtureFacade.chatFixture.createPersistedChat( + chatRoomId = chatRoom.id, + userId = DEFAULT_USER_ID, + chatType = ChatType.REACTION, + ) + val reactionEvent = fixtures.reactionEventFixture.createPersistedReactionEvent(chat.id) + fixtures.reactionOptionFixture.createPersistedReactionOptions(reactionEvent.id) + fixtures.eventAssignedUserFixture.createPersistedEventAssignedUser(DEFAULT_USER_ID, reactionEvent.id) + fixtures.chatFixtureFacade.userFixture.createPersistedUser(anotherUser) + fixtures.eventAssignedUserFixture.createPersistedEventAssignedUser(anotherUser, reactionEvent.id) + + // when + val result = eventFacadeService.getReactionEvent(DEFAULT_USER_ID, chatRoom.id, reactionEvent.id) + + // then + assertThat(result).isNotNull() + assertThat(result.id).isEqualTo(chat.id) + assertThat(result.waitingUser.size).isEqualTo(2) + assertThat(result.eventCreator.id).isEqualTo(DEFAULT_USER_ID) + } + + @Test + @DisplayName("reaction 이벤트 조회 테스트 - 다른 유저가 생성") + fun getReactionEventCreateAnotherUser() { + // given + val anotherUser = "another-user" + fixtures.chatFixtureFacade.userFixture.createPersistedUser(anotherUser) + val chat = + fixtures.chatFixtureFacade.chatFixture.createPersistedChat( + chatRoomId = chatRoom.id, + userId = anotherUser, + chatType = ChatType.REACTION, + ) + val reactionEvent = fixtures.reactionEventFixture.createPersistedReactionEvent(chat.id) + fixtures.reactionOptionFixture.createPersistedReactionOptions(reactionEvent.id) + fixtures.eventAssignedUserFixture.createPersistedEventAssignedUser(DEFAULT_USER_ID, reactionEvent.id) + fixtures.eventAssignedUserFixture.createPersistedEventAssignedUser(anotherUser, reactionEvent.id) + + // when + val result = eventFacadeService.getReactionEvent(DEFAULT_USER_ID, chatRoom.id, reactionEvent.id) + + // then + assertThat(result).isNotNull() + assertThat(result.id).isEqualTo(chat.id) + assertThat(result.waitingUser.size).isEqualTo(2) + assertThat(result.eventCreator.id).isEqualTo(anotherUser) + assertThat(result.options.size).isEqualTo(3) + } } diff --git a/api/src/test/kotlin/com/backgu/amaker/api/fixture/EventFixtureFacade.kt b/api/src/test/kotlin/com/backgu/amaker/api/fixture/EventFixtureFacade.kt index 5fcbc47c..0329d1b8 100644 --- a/api/src/test/kotlin/com/backgu/amaker/api/fixture/EventFixtureFacade.kt +++ b/api/src/test/kotlin/com/backgu/amaker/api/fixture/EventFixtureFacade.kt @@ -7,6 +7,8 @@ import org.springframework.stereotype.Component class EventFixtureFacade( val chatFixtureFacade: ChatFixtureFacade, val replyEventFixture: ReplyEventFixture, + val reactionEventFixture: ReactionEventFixture, + val reactionOptionFixture: ReactionOptionFixture, val eventAssignedUserFixture: EventAssignedUserFixture, ) { fun setUp( diff --git a/api/src/test/kotlin/com/backgu/amaker/api/fixture/ReactionEventFixture.kt b/api/src/test/kotlin/com/backgu/amaker/api/fixture/ReactionEventFixture.kt new file mode 100644 index 00000000..a9bda278 --- /dev/null +++ b/api/src/test/kotlin/com/backgu/amaker/api/fixture/ReactionEventFixture.kt @@ -0,0 +1,30 @@ +package com.backgu.amaker.api.fixture + +import com.backgu.amaker.domain.event.ReactionEvent +import com.backgu.amaker.infra.jpa.event.entity.ReactionEventEntity +import com.backgu.amaker.infra.jpa.event.repository.ReactionEventRepository +import org.springframework.stereotype.Component +import java.time.LocalDateTime + +@Component +class ReactionEventFixture( + val reactionEventRepository: ReactionEventRepository, +) { + fun createPersistedReactionEvent( + id: Long, + eventTitle: String = "$id 번째 이벤트", + deadLine: LocalDateTime = LocalDateTime.now(), + notificationStartTime: LocalDateTime = LocalDateTime.now(), + notificationInterval: Int = id.toInt(), + ): ReactionEvent = + reactionEventRepository + .save( + ReactionEventEntity( + id = id, + eventTitle = eventTitle, + deadLine = deadLine, + notificationStartTime = notificationStartTime, + notificationInterval = notificationInterval, + ), + ).toDomain() +} diff --git a/api/src/test/kotlin/com/backgu/amaker/api/fixture/ReactionOptionFixture.kt b/api/src/test/kotlin/com/backgu/amaker/api/fixture/ReactionOptionFixture.kt new file mode 100644 index 00000000..d9b098d3 --- /dev/null +++ b/api/src/test/kotlin/com/backgu/amaker/api/fixture/ReactionOptionFixture.kt @@ -0,0 +1,25 @@ +package com.backgu.amaker.api.fixture + +import com.backgu.amaker.domain.event.ReactionOption +import com.backgu.amaker.infra.jpa.event.entity.ReactionOptionEntity +import com.backgu.amaker.infra.jpa.event.repository.ReactionOptionRepository +import org.springframework.stereotype.Component + +@Component +class ReactionOptionFixture( + val reactionOptionRepository: ReactionOptionRepository, +) { + fun createPersistedReactionOptions( + eventId: Long, + options: List = listOf("옵션1", "옵션2", "옵션3"), + ): List = + reactionOptionRepository + .saveAll( + options.map { + ReactionOptionEntity( + eventId = eventId, + content = it, + ) + }, + ).map { it.toDomain() } +} diff --git a/api/src/test/kotlin/com/backgu/amaker/api/workspace/service/WorkspaceUserServiceTest.kt b/api/src/test/kotlin/com/backgu/amaker/api/workspace/service/WorkspaceUserServiceTest.kt index 99fa2dce..c3bc4f0e 100644 --- a/api/src/test/kotlin/com/backgu/amaker/api/workspace/service/WorkspaceUserServiceTest.kt +++ b/api/src/test/kotlin/com/backgu/amaker/api/workspace/service/WorkspaceUserServiceTest.kt @@ -33,7 +33,7 @@ class WorkspaceUserServiceTest : IntegrationTest() { // when & then assertThatCode { - workspaceUserService.validUserInWorkspace(user, workspace) + workspaceUserService.validateUserWorkspaceInActive(user, workspace) }.doesNotThrowAnyException() } @@ -50,7 +50,7 @@ class WorkspaceUserServiceTest : IntegrationTest() { fixtures.workspaceUser.createPersistedWorkspaceUser(workspaceId = workspace.id, leaderId = diffUser) // when & then - assertThatThrownBy { workspaceUserService.validUserInWorkspace(user, workspace) } + assertThatThrownBy { workspaceUserService.validateUserWorkspaceInActive(user, workspace) } .isInstanceOf(BusinessException::class.java) .hasMessage("워크스페이스에 접근할 수 없습니다.") .extracting("statusCode") diff --git a/docker-compose-kafka.yaml b/docker-compose-kafka.yaml index e6a6150b..eccf36ae 100644 --- a/docker-compose-kafka.yaml +++ b/docker-compose-kafka.yaml @@ -5,8 +5,6 @@ services: ports: - "9092:9092" - "9093:9093" - networks: - - backgu_dev_net environment: KAFKA_PROCESS_ROLES: broker,controller KAFKA_NODE_ID: 1 @@ -24,6 +22,3 @@ services: volumes: - ./kafka_data:/var/lib/kafka/data -networks: - backgu_dev_net: - external: true diff --git a/domain/src/main/kotlin/com/backgu/amaker/domain/chat/Chat.kt b/domain/src/main/kotlin/com/backgu/amaker/domain/chat/Chat.kt index ffd5badb..f618c506 100644 --- a/domain/src/main/kotlin/com/backgu/amaker/domain/chat/Chat.kt +++ b/domain/src/main/kotlin/com/backgu/amaker/domain/chat/Chat.kt @@ -1,5 +1,7 @@ package com.backgu.amaker.domain.chat +import com.backgu.amaker.domain.event.Event +import com.backgu.amaker.domain.event.ReactionEvent import com.backgu.amaker.domain.event.ReplyEvent import com.backgu.amaker.domain.user.User import java.time.LocalDateTime @@ -31,6 +33,22 @@ class Chat( eventDetails = eventDetails, ) + fun createReactionEvent( + deadLine: LocalDateTime, + notificationStartHour: Int, + notificationStartMinute: Int, + notificationInterval: Int, + ) = ReactionEvent( + id = id, + eventTitle = content, + deadLine = deadLine, + notificationStartTime = + deadLine + .minusHours(notificationStartHour.toLong()) + .minusMinutes(notificationStartMinute.toLong()), + notificationInterval = notificationInterval, + ) + fun createDefaultChatWithUser(user: User) = DefaultChatWithUser( id = id, @@ -43,7 +61,7 @@ class Chat( ) fun createEventChatWithUser( - event: ReplyEvent, + event: Event, user: User, assignedUsers: List, ) = EventChatWithUser( diff --git a/domain/src/main/kotlin/com/backgu/amaker/domain/event/ReactionEvent.kt b/domain/src/main/kotlin/com/backgu/amaker/domain/event/ReactionEvent.kt new file mode 100644 index 00000000..478bb265 --- /dev/null +++ b/domain/src/main/kotlin/com/backgu/amaker/domain/event/ReactionEvent.kt @@ -0,0 +1,21 @@ +package com.backgu.amaker.domain.event + +import java.time.LocalDateTime + +class ReactionEvent( + id: Long, + eventTitle: String, + deadLine: LocalDateTime, + notificationStartTime: LocalDateTime, + notificationInterval: Int, + createdAt: LocalDateTime = LocalDateTime.now(), + updatedAt: LocalDateTime = LocalDateTime.now(), +) : Event(id, eventTitle, deadLine, notificationStartTime, notificationInterval, createdAt, updatedAt) { + fun createReactionOption(contents: List) = + contents.map { + ReactionOption( + eventId = id, + content = it, + ) + } +} diff --git a/domain/src/main/kotlin/com/backgu/amaker/domain/event/ReactionOption.kt b/domain/src/main/kotlin/com/backgu/amaker/domain/event/ReactionOption.kt new file mode 100644 index 00000000..24a194b1 --- /dev/null +++ b/domain/src/main/kotlin/com/backgu/amaker/domain/event/ReactionOption.kt @@ -0,0 +1,11 @@ +package com.backgu.amaker.domain.event + +import java.time.LocalDateTime + +class ReactionOption( + val id: Long = 0L, + val eventId: Long, + val content: String, + val createdAt: LocalDateTime = LocalDateTime.now(), + val updatedAt: LocalDateTime = LocalDateTime.now(), +) diff --git a/domain/src/test/kotlin/com/backgu/amaker/domain/chat/ChatTest.kt b/domain/src/test/kotlin/com/backgu/amaker/domain/chat/ChatTest.kt index a22c55ee..abfa352d 100644 --- a/domain/src/test/kotlin/com/backgu/amaker/domain/chat/ChatTest.kt +++ b/domain/src/test/kotlin/com/backgu/amaker/domain/chat/ChatTest.kt @@ -24,7 +24,6 @@ class ChatTest { val notificationStartMinute = 30 // when - val replyEvent = chat.createReplyEvent( deadLine = deadLine, @@ -41,4 +40,37 @@ class ChatTest { deadLine.minusHours(notificationStartHour.toLong()).minusMinutes(notificationStartMinute.toLong()), ) } + + @Test + @DisplayName("reaction 이벤트 생성 테스트") + fun creatReactionEvent() { + // given + val chat = + Chat( + id = 1, + userId = "user1", + chatRoomId = 1, + content = "안녕하세요", + chatType = ChatType.GENERAL, + ) + val deadLine = LocalDateTime.now().plusDays(1) + val notificationStartHour = 1 + val notificationStartMinute = 30 + + // when + val replyEvent = + chat.createReactionEvent( + deadLine = deadLine, + notificationStartHour = notificationStartHour, + notificationStartMinute = notificationStartMinute, + notificationInterval = 10, + ) + + // then + assertThat(replyEvent.eventTitle).isEqualTo(chat.content) + assertThat(replyEvent.deadLine).isEqualTo(deadLine) + assertThat(replyEvent.notificationStartTime).isEqualTo( + deadLine.minusHours(notificationStartHour.toLong()).minusMinutes(notificationStartMinute.toLong()), + ) + } } diff --git a/domain/src/test/kotlin/com/backgu/amaker/domain/event/ReactionTest.kt b/domain/src/test/kotlin/com/backgu/amaker/domain/event/ReactionTest.kt new file mode 100644 index 00000000..66246c81 --- /dev/null +++ b/domain/src/test/kotlin/com/backgu/amaker/domain/event/ReactionTest.kt @@ -0,0 +1,33 @@ +package com.backgu.amaker.domain.event + +import org.assertj.core.api.Assertions.assertThat +import org.junit.jupiter.api.DisplayName +import java.time.LocalDateTime +import kotlin.test.Test + +@DisplayName("Reation 테스트") +class ReactionTest { + @Test + @DisplayName("reaction option 생성 테스트") + fun createReactionOption() { + // given + val reactionEvent = + ReactionEvent( + id = 1, + eventTitle = "eventTitle", + deadLine = LocalDateTime.now().plusDays(1), + notificationStartTime = LocalDateTime.now().plusDays(1), + notificationInterval = 10, + ) + val contents = listOf("content1", "content2") + + // when + val reactionOptions = reactionEvent.createReactionOption(contents) + + // then + reactionOptions.forEachIndexed { index, reactionOption -> + assertThat(reactionOption.eventId).isEqualTo(reactionEvent.id) + assertThat(reactionOption.content).isEqualTo(contents[index]) + } + } +} diff --git a/infra/src/main/kotlin/com/backgu/amaker/application/event/service/ReactionEventService.kt b/infra/src/main/kotlin/com/backgu/amaker/application/event/service/ReactionEventService.kt new file mode 100644 index 00000000..96cbd625 --- /dev/null +++ b/infra/src/main/kotlin/com/backgu/amaker/application/event/service/ReactionEventService.kt @@ -0,0 +1,23 @@ +package com.backgu.amaker.application.event.service + +import com.backgu.amaker.common.exception.BusinessException +import com.backgu.amaker.common.status.StatusCode +import com.backgu.amaker.domain.event.ReactionEvent +import com.backgu.amaker.infra.jpa.event.entity.ReactionEventEntity +import com.backgu.amaker.infra.jpa.event.repository.ReactionEventRepository +import org.springframework.data.repository.findByIdOrNull +import org.springframework.stereotype.Service +import org.springframework.transaction.annotation.Transactional + +@Service +@Transactional(readOnly = true) +class ReactionEventService( + private val reactionEventRepository: ReactionEventRepository, +) { + @Transactional + fun save(reactionEvent: ReactionEvent): ReactionEvent = reactionEventRepository.save(ReactionEventEntity.of(reactionEvent)).toDomain() + + fun getById(id: Long): ReactionEvent = + reactionEventRepository.findByIdOrNull(id)?.toDomain() + ?: throw BusinessException(StatusCode.EVENT_NOT_FOUND) +} diff --git a/infra/src/main/kotlin/com/backgu/amaker/application/event/service/ReactionOptionService.kt b/infra/src/main/kotlin/com/backgu/amaker/application/event/service/ReactionOptionService.kt new file mode 100644 index 00000000..00e99ce2 --- /dev/null +++ b/infra/src/main/kotlin/com/backgu/amaker/application/event/service/ReactionOptionService.kt @@ -0,0 +1,21 @@ +package com.backgu.amaker.application.event.service + +import com.backgu.amaker.domain.event.ReactionOption +import com.backgu.amaker.infra.jpa.event.entity.ReactionOptionEntity +import com.backgu.amaker.infra.jpa.event.repository.ReactionOptionRepository +import org.springframework.stereotype.Service +import org.springframework.transaction.annotation.Transactional + +@Service +@Transactional(readOnly = true) +class ReactionOptionService( + private val reactionOptionRepository: ReactionOptionRepository, +) { + @Transactional + fun saveAll(options: List): List = + reactionOptionRepository + .saveAll(options.map { ReactionOptionEntity.of(it) }) + .map { it.toDomain() } + + fun getAllByEventId(eventId: Long): List = reactionOptionRepository.findAllByEventId(eventId).map { it.toDomain() } +} diff --git a/infra/src/main/kotlin/com/backgu/amaker/application/workspace/WorkspaceUserService.kt b/infra/src/main/kotlin/com/backgu/amaker/application/workspace/WorkspaceUserService.kt index d016d263..075c7faf 100644 --- a/infra/src/main/kotlin/com/backgu/amaker/application/workspace/WorkspaceUserService.kt +++ b/infra/src/main/kotlin/com/backgu/amaker/application/workspace/WorkspaceUserService.kt @@ -33,7 +33,16 @@ class WorkspaceUserService( fun findWorkSpaceUserByWorkspaceId(workspaceId: Long): List = workspaceUserRepository.findByWorkspaceId(workspaceId).map { it.toDomain() } - fun validUserInWorkspace( + fun validateUserInWorkspace( + userId: String, + workspaceId: Long, + ) { + if (!workspaceUserRepository.existsByUserIdAndWorkspaceId(userId, workspaceId)) { + throw BusinessException(StatusCode.WORKSPACE_UNREACHABLE) + } + } + + fun validateUserWorkspaceInActive( userId: String, workspaceId: Long, ) { @@ -47,11 +56,11 @@ class WorkspaceUserService( } } - fun validUserInWorkspace( + fun validateUserWorkspaceInActive( user: User, workspace: Workspace, ) { - validUserInWorkspace(user.id, workspace.id) + validateUserWorkspaceInActive(user.id, workspace.id) } fun validateUserNotRelatedInWorkspace( diff --git a/infra/src/main/kotlin/com/backgu/amaker/infra/jpa/event/entity/ReactionEventEntity.kt b/infra/src/main/kotlin/com/backgu/amaker/infra/jpa/event/entity/ReactionEventEntity.kt new file mode 100644 index 00000000..e5430a8c --- /dev/null +++ b/infra/src/main/kotlin/com/backgu/amaker/infra/jpa/event/entity/ReactionEventEntity.kt @@ -0,0 +1,46 @@ +package com.backgu.amaker.infra.jpa.event.entity + +import com.backgu.amaker.domain.event.ReactionEvent +import jakarta.persistence.DiscriminatorValue +import jakarta.persistence.Entity +import jakarta.persistence.Table +import java.time.LocalDateTime + +@Entity(name = "ReactionEvent") +@Table(name = "reaction_event") +@DiscriminatorValue(value = "REACTION") +class ReactionEventEntity( + id: Long, + eventTitle: String, + deadLine: LocalDateTime, + notificationStartTime: LocalDateTime, + notificationInterval: Int, +) : EventEntity( + id = id, + eventTitle = eventTitle, + deadLine = deadLine, + notificationStartTime = notificationStartTime, + notificationInterval = notificationInterval, + ) { + override fun toDomain(): ReactionEvent = + ReactionEvent( + id = id, + eventTitle = eventTitle, + deadLine = deadLine, + notificationStartTime = notificationStartTime, + notificationInterval = notificationInterval, + createdAt = createdAt, + updatedAt = updatedAt, + ) + + companion object { + fun of(reactionEvent: ReactionEvent) = + ReactionEventEntity( + id = reactionEvent.id, + eventTitle = reactionEvent.eventTitle, + deadLine = reactionEvent.deadLine, + notificationStartTime = reactionEvent.notificationStartTime, + notificationInterval = reactionEvent.notificationInterval, + ) + } +} diff --git a/infra/src/main/kotlin/com/backgu/amaker/infra/jpa/event/entity/ReactionOptionEntity.kt b/infra/src/main/kotlin/com/backgu/amaker/infra/jpa/event/entity/ReactionOptionEntity.kt new file mode 100644 index 00000000..0e6056ef --- /dev/null +++ b/infra/src/main/kotlin/com/backgu/amaker/infra/jpa/event/entity/ReactionOptionEntity.kt @@ -0,0 +1,27 @@ +package com.backgu.amaker.infra.jpa.event.entity + +import com.backgu.amaker.domain.event.ReactionOption +import jakarta.persistence.Column +import jakarta.persistence.Entity +import jakarta.persistence.GeneratedValue +import jakarta.persistence.GenerationType +import jakarta.persistence.Id +import jakarta.persistence.Table + +@Entity(name = "ReactionOption") +@Table(name = "reaction_option") +class ReactionOptionEntity( + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + val id: Long = 0L, + @Column(nullable = false) + val eventId: Long, + val content: String, +) { + fun toDomain(): ReactionOption = ReactionOption(id = id, eventId = eventId, content = content) + + companion object { + fun of(reactionOption: ReactionOption): ReactionOptionEntity = + ReactionOptionEntity(eventId = reactionOption.eventId, content = reactionOption.content) + } +} diff --git a/infra/src/main/kotlin/com/backgu/amaker/infra/jpa/event/repository/ReactionEventRepository.kt b/infra/src/main/kotlin/com/backgu/amaker/infra/jpa/event/repository/ReactionEventRepository.kt new file mode 100644 index 00000000..d15386f4 --- /dev/null +++ b/infra/src/main/kotlin/com/backgu/amaker/infra/jpa/event/repository/ReactionEventRepository.kt @@ -0,0 +1,6 @@ +package com.backgu.amaker.infra.jpa.event.repository + +import com.backgu.amaker.infra.jpa.event.entity.ReactionEventEntity +import org.springframework.data.jpa.repository.JpaRepository + +interface ReactionEventRepository : JpaRepository diff --git a/infra/src/main/kotlin/com/backgu/amaker/infra/jpa/event/repository/ReactionOptionRepository.kt b/infra/src/main/kotlin/com/backgu/amaker/infra/jpa/event/repository/ReactionOptionRepository.kt new file mode 100644 index 00000000..109bc444 --- /dev/null +++ b/infra/src/main/kotlin/com/backgu/amaker/infra/jpa/event/repository/ReactionOptionRepository.kt @@ -0,0 +1,8 @@ +package com.backgu.amaker.infra.jpa.event.repository + +import com.backgu.amaker.infra.jpa.event.entity.ReactionOptionEntity +import org.springframework.data.jpa.repository.JpaRepository + +interface ReactionOptionRepository : JpaRepository { + fun findAllByEventId(eventId: Long): List +} diff --git a/realtime/src/main/kotlin/com/backgu/amaker/realtime/common/excpetion/RealTimeException.kt b/realtime/src/main/kotlin/com/backgu/amaker/realtime/common/excpetion/RealTimeException.kt index f17aed0e..5dc649db 100644 --- a/realtime/src/main/kotlin/com/backgu/amaker/realtime/common/excpetion/RealTimeException.kt +++ b/realtime/src/main/kotlin/com/backgu/amaker/realtime/common/excpetion/RealTimeException.kt @@ -3,5 +3,5 @@ package com.backgu.amaker.realtime.common.excpetion import com.backgu.amaker.common.status.StatusCode class RealTimeException( - val statusCode: StatusCode, + statusCode: StatusCode, ) : RuntimeException(statusCode.message) diff --git a/realtime/src/main/kotlin/com/backgu/amaker/realtime/config/ProdRedisConfig.kt b/realtime/src/main/kotlin/com/backgu/amaker/realtime/config/ProdRedisConfig.kt index 618822bd..0a4daf2c 100644 --- a/realtime/src/main/kotlin/com/backgu/amaker/realtime/config/ProdRedisConfig.kt +++ b/realtime/src/main/kotlin/com/backgu/amaker/realtime/config/ProdRedisConfig.kt @@ -1,6 +1,8 @@ package com.backgu.amaker.realtime.config import com.backgu.amaker.infra.redis.session.SessionRedisData +import com.backgu.amaker.realtime.server.config.ServerConfig +import com.backgu.amaker.realtime.session.service.SessionDeleteSubscriber import io.lettuce.core.SocketOptions import io.lettuce.core.cluster.ClusterClientOptions import io.lettuce.core.cluster.ClusterTopologyRefreshOptions @@ -14,6 +16,9 @@ import org.springframework.data.redis.connection.RedisPassword import org.springframework.data.redis.connection.lettuce.LettuceClientConfiguration import org.springframework.data.redis.connection.lettuce.LettuceConnectionFactory import org.springframework.data.redis.core.RedisTemplate +import org.springframework.data.redis.listener.ChannelTopic +import org.springframework.data.redis.listener.RedisMessageListenerContainer +import org.springframework.data.redis.listener.adapter.MessageListenerAdapter import org.springframework.data.redis.repository.configuration.EnableRedisRepositories import org.springframework.data.redis.serializer.GenericJackson2JsonRedisSerializer import org.springframework.data.redis.serializer.StringRedisSerializer @@ -74,4 +79,22 @@ class ProdRedisConfig( template.valueSerializer = GenericJackson2JsonRedisSerializer() return template } + + @Bean + fun messageListenerAdapter(sessionDeleteSubscriber: SessionDeleteSubscriber): MessageListenerAdapter = + MessageListenerAdapter(sessionDeleteSubscriber, "dropOutSessions") + + @Bean + fun redisContainer( + messageListenerAdapter: MessageListenerAdapter, + topic: ChannelTopic, + ): RedisMessageListenerContainer { + val container = RedisMessageListenerContainer() + container.setConnectionFactory(redisConnectionFactory()) + container.addMessageListener(messageListenerAdapter, topic) + return container + } + + @Bean + fun topic(serverConfig: ServerConfig): ChannelTopic = ChannelTopic(serverConfig.id) } diff --git a/realtime/src/main/kotlin/com/backgu/amaker/realtime/config/RedisConfig.kt b/realtime/src/main/kotlin/com/backgu/amaker/realtime/config/RedisConfig.kt index cba8fc8d..0a9d33d1 100644 --- a/realtime/src/main/kotlin/com/backgu/amaker/realtime/config/RedisConfig.kt +++ b/realtime/src/main/kotlin/com/backgu/amaker/realtime/config/RedisConfig.kt @@ -1,6 +1,8 @@ package com.backgu.amaker.realtime.config import com.backgu.amaker.infra.redis.session.SessionRedisData +import com.backgu.amaker.realtime.server.config.ServerConfig +import com.backgu.amaker.realtime.session.service.SessionDeleteSubscriber import org.springframework.boot.context.properties.ConfigurationProperties import org.springframework.context.annotation.Bean import org.springframework.context.annotation.Configuration @@ -8,6 +10,9 @@ import org.springframework.context.annotation.Profile import org.springframework.data.redis.connection.RedisConnectionFactory import org.springframework.data.redis.connection.lettuce.LettuceConnectionFactory import org.springframework.data.redis.core.RedisTemplate +import org.springframework.data.redis.listener.ChannelTopic +import org.springframework.data.redis.listener.RedisMessageListenerContainer +import org.springframework.data.redis.listener.adapter.MessageListenerAdapter import org.springframework.data.redis.serializer.GenericJackson2JsonRedisSerializer import org.springframework.data.redis.serializer.StringRedisSerializer @@ -33,4 +38,22 @@ class RedisConfig { template.valueSerializer = GenericJackson2JsonRedisSerializer() return template } + + @Bean + fun messageListenerAdapter(sessionDeleteSubscriber: SessionDeleteSubscriber): MessageListenerAdapter = + MessageListenerAdapter(sessionDeleteSubscriber, "dropOutSessions") + + @Bean + fun redisContainer( + messageListenerAdapter: MessageListenerAdapter, + topic: ChannelTopic, + ): RedisMessageListenerContainer { + val container = RedisMessageListenerContainer() + container.setConnectionFactory(redisConnectionFactory()) + container.addMessageListener(messageListenerAdapter, topic) + return container + } + + @Bean + fun topic(serverConfig: ServerConfig): ChannelTopic = ChannelTopic(serverConfig.id) } diff --git a/realtime/src/main/kotlin/com/backgu/amaker/realtime/session/service/SessionDeletePublisher.kt b/realtime/src/main/kotlin/com/backgu/amaker/realtime/session/service/SessionDeletePublisher.kt new file mode 100644 index 00000000..bc69077c --- /dev/null +++ b/realtime/src/main/kotlin/com/backgu/amaker/realtime/session/service/SessionDeletePublisher.kt @@ -0,0 +1,17 @@ +package com.backgu.amaker.realtime.session.service + +import com.backgu.amaker.infra.redis.session.SessionRedisData +import org.springframework.data.redis.core.RedisTemplate +import org.springframework.stereotype.Service + +@Service +class SessionDeletePublisher( + private val redisTemplate: RedisTemplate, +) { + fun publish( + serverId: String, + sessionRedisData: SessionRedisData, + ) { + redisTemplate.convertAndSend(serverId, sessionRedisData) + } +} diff --git a/realtime/src/main/kotlin/com/backgu/amaker/realtime/session/service/SessionDeleteSubscriber.kt b/realtime/src/main/kotlin/com/backgu/amaker/realtime/session/service/SessionDeleteSubscriber.kt new file mode 100644 index 00000000..2d30dd16 --- /dev/null +++ b/realtime/src/main/kotlin/com/backgu/amaker/realtime/session/service/SessionDeleteSubscriber.kt @@ -0,0 +1,20 @@ +package com.backgu.amaker.realtime.session.service + +import com.backgu.amaker.infra.redis.session.SessionRedisData +import com.backgu.amaker.realtime.user.service.UserSessionService +import com.backgu.amaker.realtime.workspace.service.WorkspaceSessionService +import com.fasterxml.jackson.databind.ObjectMapper +import org.springframework.stereotype.Service + +@Service +class SessionDeleteSubscriber( + private val workspaceSessionService: WorkspaceSessionService, + private val userSessionService: UserSessionService, + private val objectMapper: ObjectMapper, +) { + fun dropOutSessions(message: String) { + val sessionRedisData = objectMapper.readValue(message, SessionRedisData::class.java) + workspaceSessionService.dropOut(sessionRedisData.workspaceId, sessionRedisData.toDomain()) + userSessionService.dropOut(sessionRedisData.userId, sessionRedisData.toDomain()) + } +} diff --git a/realtime/src/main/kotlin/com/backgu/amaker/realtime/session/service/SessionFacadeService.kt b/realtime/src/main/kotlin/com/backgu/amaker/realtime/session/service/SessionFacadeService.kt index cbf0ca20..20c62e64 100644 --- a/realtime/src/main/kotlin/com/backgu/amaker/realtime/session/service/SessionFacadeService.kt +++ b/realtime/src/main/kotlin/com/backgu/amaker/realtime/session/service/SessionFacadeService.kt @@ -1,6 +1,8 @@ package com.backgu.amaker.realtime.session.service import com.backgu.amaker.application.workspace.WorkspaceUserService +import com.backgu.amaker.infra.redis.session.SessionRedisData +import com.backgu.amaker.realtime.server.config.ServerConfig import com.backgu.amaker.realtime.session.session.RealTimeSession import com.backgu.amaker.realtime.user.service.UserSessionService import com.backgu.amaker.realtime.workspace.service.WorkspaceSessionService @@ -12,13 +14,25 @@ class SessionFacadeService( private val workspaceUserService: WorkspaceUserService, private val workspaceSessionService: WorkspaceSessionService, private val userSessionService: UserSessionService, + private val sessionDeletePublisher: SessionDeletePublisher, + private val serverConfig: ServerConfig, ) { fun enrollUserToWorkspaceSession( userId: String, workspaceId: Long, workspaceRealTimeSession: RealTimeSession, ) { - workspaceUserService.validUserInWorkspace(userId, workspaceId) + workspaceUserService.validateUserWorkspaceInActive(userId, workspaceId) + + workspaceSessionService.findDropOutSessionIfLimit(workspaceId, userId).forEach { + if (it.realtimeId != serverConfig.id) { + sessionDeletePublisher.publish(it.realtimeId, SessionRedisData.of(it)) + } else { + workspaceSessionService.dropOut(workspaceId, it) + userSessionService.dropOut(userId, it) + } + } + workspaceSessionService.enrollUserToWorkspaceSession(workspaceId, workspaceRealTimeSession) userSessionService.enrollUserToUserSession(userId, workspaceRealTimeSession) } @@ -28,7 +42,7 @@ class SessionFacadeService( workspaceId: Long, workspaceRealTimeSession: RealTimeSession, ) { - userSessionService.dropOut(userId, workspaceRealTimeSession) workspaceSessionService.dropOut(workspaceId, workspaceRealTimeSession) + userSessionService.dropOut(userId, workspaceRealTimeSession) } } diff --git a/realtime/src/main/kotlin/com/backgu/amaker/realtime/session/session/RealTimeSession.kt b/realtime/src/main/kotlin/com/backgu/amaker/realtime/session/session/RealTimeSession.kt index 1dfb8f2b..f221a187 100644 --- a/realtime/src/main/kotlin/com/backgu/amaker/realtime/session/session/RealTimeSession.kt +++ b/realtime/src/main/kotlin/com/backgu/amaker/realtime/session/session/RealTimeSession.kt @@ -9,5 +9,9 @@ class RealTimeSession( val realTimeId: String, val session: T, ) { + companion object { + const val WORKSPACE_USER_SESSION_LIMIT = 20 + } + fun toDomain() = Session(id, userId, workspaceId, realTimeId) } diff --git a/realtime/src/main/kotlin/com/backgu/amaker/realtime/user/service/UserSessionService.kt b/realtime/src/main/kotlin/com/backgu/amaker/realtime/user/service/UserSessionService.kt index 032ce7fb..286ecaf9 100644 --- a/realtime/src/main/kotlin/com/backgu/amaker/realtime/user/service/UserSessionService.kt +++ b/realtime/src/main/kotlin/com/backgu/amaker/realtime/user/service/UserSessionService.kt @@ -1,5 +1,6 @@ package com.backgu.amaker.realtime.user.service +import com.backgu.amaker.domain.session.Session import com.backgu.amaker.infra.redis.session.user.repository.UserSessionRepository import com.backgu.amaker.realtime.server.config.ServerConfig import com.backgu.amaker.realtime.session.session.RealTimeSession @@ -49,4 +50,15 @@ class UserSessionService( ) sessionStorage.removeSession(realTimeSession.id) } + + fun dropOut( + userId: String, + session: Session, + ) { + userSessionRepository.removeUserSession( + userId, + session, + ) + sessionStorage.removeSession(session.id) + } } diff --git a/realtime/src/main/kotlin/com/backgu/amaker/realtime/workspace/service/WorkspaceSessionService.kt b/realtime/src/main/kotlin/com/backgu/amaker/realtime/workspace/service/WorkspaceSessionService.kt index e93975c3..aaf2090c 100644 --- a/realtime/src/main/kotlin/com/backgu/amaker/realtime/workspace/service/WorkspaceSessionService.kt +++ b/realtime/src/main/kotlin/com/backgu/amaker/realtime/workspace/service/WorkspaceSessionService.kt @@ -1,5 +1,6 @@ package com.backgu.amaker.realtime.workspace.service +import com.backgu.amaker.domain.session.Session import com.backgu.amaker.infra.redis.session.workspace.repository.WorkspaceSessionRepository import com.backgu.amaker.realtime.server.config.ServerConfig import com.backgu.amaker.realtime.session.session.RealTimeSession @@ -32,6 +33,14 @@ class WorkspaceSessionService( sessionStorage.removeSession(session.id) } + fun dropOut( + workspaceId: Long, + session: Session, + ) { + workspaceSessionRepository.removeWorkspaceSession(workspaceId, session) + sessionStorage.removeSession(session.id) + } + fun getWorkspaceSession(workspaceId: Long): List> { val workspaceSessions = workspaceSessionRepository @@ -41,4 +50,21 @@ class WorkspaceSessionService( return sessionStorage.getSessions(workspaceSessions.map { it.id }) } + + fun findDropOutSessionIfLimit( + workspaceId: Long, + userId: String, + ): List { + val workspaceSessions = + workspaceSessionRepository + .findWorkspaceSessionByWorkspaceId(workspaceId) + .map { it.toDomain() } + .filter { it.userId == userId } + + if (workspaceSessions.size >= RealTimeSession.WORKSPACE_USER_SESSION_LIMIT) { + return workspaceSessions.drop(RealTimeSession.WORKSPACE_USER_SESSION_LIMIT - 1) + } + + return emptyList() + } } diff --git a/realtime/src/main/kotlin/com/backgu/amaker/realtime/workspace/storage/SessionStorage.kt b/realtime/src/main/kotlin/com/backgu/amaker/realtime/workspace/storage/SessionStorage.kt index f9e73f2d..3178c889 100644 --- a/realtime/src/main/kotlin/com/backgu/amaker/realtime/workspace/storage/SessionStorage.kt +++ b/realtime/src/main/kotlin/com/backgu/amaker/realtime/workspace/storage/SessionStorage.kt @@ -4,6 +4,7 @@ import com.backgu.amaker.common.status.StatusCode import com.backgu.amaker.realtime.common.excpetion.RealTimeException import com.backgu.amaker.realtime.session.session.RealTimeSession import org.springframework.stereotype.Component +import org.springframework.web.socket.CloseStatus import org.springframework.web.socket.WebSocketSession import java.util.concurrent.ConcurrentHashMap @@ -16,7 +17,10 @@ class SessionStorage { } fun removeSession(id: String) { - sessionsMap.remove(id) + sessionsMap[id]?.let { + it.session.close(CloseStatus.NORMAL) + sessionsMap.remove(id) + } } fun getSessions(ids: Collection): List> = ids.mapNotNull { sessionsMap[it] }