diff --git a/src/main/java/one/colla/chat/application/ChatChannelService.java b/src/main/java/one/colla/chat/application/ChatChannelService.java index eb09d34..c7610fd 100644 --- a/src/main/java/one/colla/chat/application/ChatChannelService.java +++ b/src/main/java/one/colla/chat/application/ChatChannelService.java @@ -29,6 +29,7 @@ import one.colla.global.exception.CommonException; import one.colla.global.exception.ExceptionCode; import one.colla.teamspace.application.TeamspaceService; +import one.colla.teamspace.application.dto.response.UnreadMessageCountResponse; import one.colla.teamspace.domain.Teamspace; import one.colla.teamspace.domain.TeamspaceRole; import one.colla.teamspace.domain.UserTeamspace; @@ -160,6 +161,21 @@ public void deleteChatChannel(CustomUserDetails userDetails, Long teamspaceId, L chatChannelRepository.delete(chatChannel); } + @Transactional(readOnly = true) + public UnreadMessageCountResponse getTeamspaceUnreadMessageCount( + final CustomUserDetails userDetails, + final Long teamspaceId + ) { + final Teamspace teamspace = teamspaceService.getUserTeamspace(userDetails, teamspaceId).getTeamspace(); + + int totalUnreadCount = teamspace.getChatChannels().stream() + .mapToInt(chatChannel -> calculateUnreadMessageCount(userDetails.getUserId(), chatChannel)) + .sum(); + + log.info("팀스페이스 안읽은 메시지 개수 조회 - 팀스페이스 Id: {}, 조회한 사용자 Id: {}", teamspaceId, userDetails.getUserId()); + return UnreadMessageCountResponse.of(totalUnreadCount); + } + private ChatChannel getChatChannel(Teamspace teamspace, Long chatChannelId) { return teamspace.getChatChannels() .stream() diff --git a/src/main/java/one/colla/teamspace/application/TeamspaceService.java b/src/main/java/one/colla/teamspace/application/TeamspaceService.java index df7c9ba..5bc6633 100644 --- a/src/main/java/one/colla/teamspace/application/TeamspaceService.java +++ b/src/main/java/one/colla/teamspace/application/TeamspaceService.java @@ -7,6 +7,7 @@ import org.apache.commons.lang3.tuple.Pair; import org.springframework.context.ApplicationEventPublisher; +import org.springframework.data.domain.Pageable; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @@ -14,6 +15,8 @@ import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import one.colla.chat.domain.ChatChannel; +import one.colla.chat.domain.ChatChannelMessage; +import one.colla.chat.domain.ChatChannelMessageRepository; import one.colla.chat.domain.ChatChannelRepository; import one.colla.chat.domain.UserChatChannel; import one.colla.chat.domain.UserChatChannelRepository; @@ -69,6 +72,7 @@ public class TeamspaceService { private final RandomCodeGenerator randomCodeGenerator; private final UserChatChannelRepository userChatChannelRepository; private final ChatChannelRepository chatChannelRepository; + private final ChatChannelMessageRepository chatChannelMessageRepository; @Transactional public CreateTeamspaceResponse create(final CustomUserDetails userDetails, final CreateTeamspaceRequest request) { @@ -161,14 +165,36 @@ public void participate(CustomUserDetails userDetails, Long teamspaceId, Partici } final UserTeamspace participatedUserTeamspace = user.participate(teamspace, TeamspaceRole.MEMBER); + final List participatedUserChatChannels = teamspace.getChatChannels().stream() - .map(ch -> ch.participateTeamspaceUser(participatedUserTeamspace)) + .map(chatChannel -> { + UserChatChannel userChatChannel = chatChannel.participateTeamspaceUser(participatedUserTeamspace); + + Long latestMessageId = getLatestMessageIdForChannel(chatChannel); + + if (latestMessageId != null) { + userChatChannel.updateLastReadMessageId(latestMessageId); + } + return userChatChannel; + }) .toList(); + userTeamspaceRepository.save(participatedUserTeamspace); userChatChannelRepository.saveAll(participatedUserChatChannels); log.info("팀스페이스 참가 - 팀스페이스 Id: {}, 사용자 Id: {}", teamspaceId, user.getId()); } + private Long getLatestMessageIdForChannel(ChatChannel chatChannel) { + List latestMessages = chatChannelMessageRepository + .findChatChannelMessageByChatChannelAndCriteria( + chatChannel, + null, + Pageable.ofSize(1) + ); + + return latestMessages.isEmpty() ? null : latestMessages.get(0).getId(); + } + @Transactional(readOnly = true) public TeamspaceParticipantsResponse getParticipants(CustomUserDetails userDetails, Long teamspaceId) { UserTeamspace userTeamspace = getUserTeamspace(userDetails, teamspaceId); diff --git a/src/main/java/one/colla/teamspace/application/dto/response/UnreadMessageCountResponse.java b/src/main/java/one/colla/teamspace/application/dto/response/UnreadMessageCountResponse.java new file mode 100644 index 0000000..4819e9d --- /dev/null +++ b/src/main/java/one/colla/teamspace/application/dto/response/UnreadMessageCountResponse.java @@ -0,0 +1,9 @@ +package one.colla.teamspace.application.dto.response; + +public record UnreadMessageCountResponse( + int unreadMessageCount +) { + public static UnreadMessageCountResponse of(int unreadMessageCount) { + return new UnreadMessageCountResponse(unreadMessageCount); + } +} diff --git a/src/main/java/one/colla/teamspace/presentation/TeamspaceController.java b/src/main/java/one/colla/teamspace/presentation/TeamspaceController.java index f8bf68c..01fc21d 100644 --- a/src/main/java/one/colla/teamspace/presentation/TeamspaceController.java +++ b/src/main/java/one/colla/teamspace/presentation/TeamspaceController.java @@ -17,6 +17,7 @@ import jakarta.validation.Valid; import lombok.RequiredArgsConstructor; +import one.colla.chat.application.ChatChannelService; import one.colla.common.presentation.ApiResponse; import one.colla.common.security.authentication.CustomUserDetails; import one.colla.teamspace.application.TeamspaceService; @@ -31,12 +32,14 @@ import one.colla.teamspace.application.dto.response.TeamspaceInfoResponse; import one.colla.teamspace.application.dto.response.TeamspaceParticipantsResponse; import one.colla.teamspace.application.dto.response.TeamspaceSettingsResponse; +import one.colla.teamspace.application.dto.response.UnreadMessageCountResponse; @RestController @RequiredArgsConstructor @RequestMapping("/api/v1/teamspaces") public class TeamspaceController { private final TeamspaceService teamspaceService; + private final ChatChannelService chatChannelService; @PostMapping @PreAuthorize("isAuthenticated()") @@ -151,4 +154,16 @@ public ResponseEntity> deleteTeamspaceProfileImageUrl( teamspaceService.deleteProfileImageUrl(userDetails, teamspaceId); return ResponseEntity.ok().body(ApiResponse.createSuccessResponse(Map.of())); } + + @GetMapping("/{teamspaceId}/unread-count") + @PreAuthorize("isAuthenticated()") + public ResponseEntity> getTeamspaceUnreadMessageCount( + @AuthenticationPrincipal final CustomUserDetails userDetails, + @PathVariable final Long teamspaceId + ) { + return ResponseEntity.ok() + .body(ApiResponse.createSuccessResponse( + chatChannelService.getTeamspaceUnreadMessageCount(userDetails, teamspaceId)) + ); + } } diff --git a/src/test/java/one/colla/chat/application/ChatChannelServiceTest.java b/src/test/java/one/colla/chat/application/ChatChannelServiceTest.java index 52b8fcd..e975df2 100644 --- a/src/test/java/one/colla/chat/application/ChatChannelServiceTest.java +++ b/src/test/java/one/colla/chat/application/ChatChannelServiceTest.java @@ -28,12 +28,14 @@ import one.colla.chat.domain.ChatChannelMessage; import one.colla.chat.domain.ChatChannelMessageRepository; import one.colla.chat.domain.ChatChannelRepository; +import one.colla.chat.domain.UserChatChannel; import one.colla.chat.domain.UserChatChannelRepository; import one.colla.common.ServiceTest; import one.colla.common.security.authentication.CustomUserDetails; import one.colla.global.exception.CommonException; import one.colla.global.exception.ExceptionCode; import one.colla.teamspace.application.TeamspaceService; +import one.colla.teamspace.application.dto.response.UnreadMessageCountResponse; import one.colla.teamspace.domain.Teamspace; import one.colla.teamspace.domain.UserTeamspace; import one.colla.user.domain.User; @@ -482,4 +484,130 @@ void deleteChatChannel_Fail_ChannelNotFound() { .hasMessageContaining(ExceptionCode.NOT_FOUND_CHAT_CHANNEL.getMessage()); } } + + @Nested + @DisplayName("팀스페이스 안읽은 메시지 개수 조회시") + class GetTeamspaceUnreadMessageCountTest { + + ChatChannel FRONTEND_CHAT_CHANNEL; + ChatChannel BACKEND_CHAT_CHANNEL; + + @BeforeEach + void setUp() { + /* 채팅 채널 생성 */ + FRONTEND_CHAT_CHANNEL = testFixtureBuilder.buildChatChannel(FRONTEND_CHAT_CHANNEL(OS_TEAMSPACE)); + BACKEND_CHAT_CHANNEL = testFixtureBuilder.buildChatChannel(BACKEND_CHAT_CHANNEL(OS_TEAMSPACE)); + + /* 팀스페이스에 채팅 채널 추가 */ + OS_TEAMSPACE.addChatChannel(FRONTEND_CHAT_CHANNEL); + OS_TEAMSPACE.addChatChannel(BACKEND_CHAT_CHANNEL); + + /* 채팅 채널 유저 참가 */ + testFixtureBuilder.buildUserChatChannel( + FRONTEND_CHAT_CHANNEL.participateAllTeamspaceUser(OS_TEAMSPACE.getUserTeamspaces())); + testFixtureBuilder.buildUserChatChannel( + BACKEND_CHAT_CHANNEL.participateAllTeamspaceUser(OS_TEAMSPACE.getUserTeamspaces())); + } + + @Test + @DisplayName("팀스페이스의 모든 채팅 채널에서 안읽은 메시지 개수의 합계를 반환한다") + void getTeamspaceUnreadCount_Success() { + // given + // 첫 번째 채널에 메시지 5개 생성 + for (int i = 0; i < 5; i++) { + ChatChannelMessage msg = testFixtureBuilder.buildChatChannelMessage( + CHAT_MESSAGE1(USER2, OS_TEAMSPACE, FRONTEND_CHAT_CHANNEL)); + FRONTEND_CHAT_CHANNEL.updateLastChatMessage(msg.getId()); + } + + // 두 번째 채널에 메시지 3개 생성 + for (int i = 0; i < 3; i++) { + ChatChannelMessage msg = testFixtureBuilder.buildChatChannelMessage( + CHAT_MESSAGE1(USER2, OS_TEAMSPACE, BACKEND_CHAT_CHANNEL)); + BACKEND_CHAT_CHANNEL.updateLastChatMessage(msg.getId()); + } + + // USER1의 첫 번째 채널에서 3개 메시지를 읽음 표시 + UserChatChannel userChatChannel = userChatChannelRepository + .findByUserIdAndChatChannelId(USER1.getId(), FRONTEND_CHAT_CHANNEL.getId()) + .orElseThrow(); + + // 첫 번째 채널의 세 번째 메시지까지 읽음 처리 (2개 안읽음 상태로 설정) + ChatChannelMessage thirdMessage = chatChannelMessageRepository + .findChatChannelMessageByChatChannelAndCriteria(FRONTEND_CHAT_CHANNEL, null, + PageRequest.of(0, 5)) + .get(2); + + userChatChannel.updateLastReadMessageId(thirdMessage.getId()); + + // when + UnreadMessageCountResponse response = chatChannelService + .getTeamspaceUnreadMessageCount(USER1_DETAILS, OS_TEAMSPACE.getId()); + + // then + // 예상 결과: 첫 번째 채널에서 2개(5개 중 3개 읽음) + 두 번째 채널에서 3개 = 총 5개 안읽음 + SoftAssertions.assertSoftly(softly -> { + softly.assertThat(response).isNotNull(); + softly.assertThat(response.unreadMessageCount()).isEqualTo(5); + }); + } + + @Test + @DisplayName("모든 메시지를 읽은 경우 0을 반환한다") + void getTeamspaceUnreadCount_AllRead() { + // given + // 첫 번째 채널에 메시지 5개 생성 + ChatChannelMessage lastMsg = null; + for (int i = 0; i < 5; i++) { + lastMsg = testFixtureBuilder.buildChatChannelMessage( + CHAT_MESSAGE1(USER2, OS_TEAMSPACE, FRONTEND_CHAT_CHANNEL)); + FRONTEND_CHAT_CHANNEL.updateLastChatMessage(lastMsg.getId()); + } + + // USER1의 모든 메시지를 읽음 표시 + UserChatChannel userChatChannel = userChatChannelRepository + .findByUserIdAndChatChannelId(USER1.getId(), FRONTEND_CHAT_CHANNEL.getId()) + .orElseThrow(); + + userChatChannel.updateLastReadMessageId(lastMsg.getId()); + + // when + UnreadMessageCountResponse response = chatChannelService + .getTeamspaceUnreadMessageCount(USER1_DETAILS, OS_TEAMSPACE.getId()); + + // then + SoftAssertions.assertSoftly(softly -> { + softly.assertThat(response).isNotNull(); + softly.assertThat(response.unreadMessageCount()).isEqualTo(0); + }); + } + + @Test + @DisplayName("메시지가 없는 경우 0을 반환한다") + void getTeamspaceUnreadCount_NoMessages() { + // when + UnreadMessageCountResponse response = chatChannelService + .getTeamspaceUnreadMessageCount(USER1_DETAILS, OS_TEAMSPACE.getId()); + + // then + SoftAssertions.assertSoftly(softly -> { + softly.assertThat(response).isNotNull(); + softly.assertThat(response.unreadMessageCount()).isEqualTo(0); + }); + } + + @Test + @DisplayName("팀스페이스 접근 권한이 없으면 예외가 발생한다") + void getTeamspaceUnreadCount_Fail_NoAccess() { + // given + User OTHER_USER = testFixtureBuilder.buildUser(RANDOMUSER()); + CustomUserDetails OTHER_USER_DETAILS = createCustomUserDetailsByUser(OTHER_USER); + + // when & then + assertThatThrownBy(() -> + chatChannelService.getTeamspaceUnreadMessageCount(OTHER_USER_DETAILS, OS_TEAMSPACE.getId())) + .isExactlyInstanceOf(CommonException.class) + .hasMessageContaining(ExceptionCode.FORBIDDEN_TEAMSPACE.getMessage()); + } + } } diff --git a/src/test/java/one/colla/teamspace/presentation/TeamspaceControllerTest.java b/src/test/java/one/colla/teamspace/presentation/TeamspaceControllerTest.java index 774d27b..4ddbf04 100644 --- a/src/test/java/one/colla/teamspace/presentation/TeamspaceControllerTest.java +++ b/src/test/java/one/colla/teamspace/presentation/TeamspaceControllerTest.java @@ -24,6 +24,7 @@ import com.epages.restdocs.apispec.ResourceSnippetParameters; import com.epages.restdocs.apispec.Schema; +import one.colla.chat.application.ChatChannelService; import one.colla.common.ControllerTest; import one.colla.common.presentation.ApiResponse; import one.colla.common.security.authentication.CustomUserDetails; @@ -44,6 +45,7 @@ import one.colla.teamspace.application.dto.response.TeamspaceInfoResponse; import one.colla.teamspace.application.dto.response.TeamspaceParticipantsResponse; import one.colla.teamspace.application.dto.response.TeamspaceSettingsResponse; +import one.colla.teamspace.application.dto.response.UnreadMessageCountResponse; @WebMvcTest(TeamspaceController.class) class TeamspaceControllerTest extends ControllerTest { @@ -51,6 +53,9 @@ class TeamspaceControllerTest extends ControllerTest { @MockBean private TeamspaceService teamspaceService; + @MockBean + private ChatChannelService chatChannelService; + @Nested @DisplayName("팀스페이스 생성 문서화") class CreateTeamspaceDocs { @@ -695,4 +700,54 @@ private void doTest( .andDo(print()); } } + + @Nested + @DisplayName("팀스페이스 안읽은 메시지 개수 조회 문서화") + class GetTeamspaceUnreadCountDocs { + Long teamspaceId = 1L; + UnreadMessageCountResponse response = new UnreadMessageCountResponse(5); + + @Test + @WithMockCustomUser + @DisplayName("팀스페이스 안읽은 메시지 개수 조회 성공") + void getTeamspaceUnreadMessageCountSuccessfully() throws Exception { + given(chatChannelService.getTeamspaceUnreadMessageCount(any(CustomUserDetails.class), eq(teamspaceId))) + .willReturn(response); + + doTest( + ApiResponse.createSuccessResponse(response), + status().isOk(), + apiDocHelper.createSuccessResponseFields( + fieldWithPath("unreadMessageCount").description("안읽은 메시지 개수").type(JsonFieldType.NUMBER) + ), + "ApiResponse" + ); + } + + private void doTest( + ApiResponse response, + ResultMatcher statusMatcher, + FieldDescriptor[] responseFields, + String responseSchemaTitle + ) throws Exception { + mockMvc.perform(get("/api/v1/teamspaces/{teamspaceId}/unread-count", teamspaceId) + .with(csrf()) + .accept(MediaType.APPLICATION_JSON)) + .andExpect(statusMatcher) + .andExpect(content().json( + objectMapper.writeValueAsString(response))) + .andDo(restDocs.document( + resource(ResourceSnippetParameters.builder() + .tag("teamspace-controller") + .description("특정 팀스페이스 내의 안읽은 메시지 개수를 조회합니다.") + .pathParameters( + parameterWithName("teamspaceId").description("팀스페이스 ID") + ) + .responseFields(responseFields) + .responseSchema(Schema.schema(responseSchemaTitle)) + .build() + ))) + .andDo(print()); + } + } }