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
16 changes: 16 additions & 0 deletions src/main/java/one/colla/chat/application/ChatChannelService.java
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,13 +7,16 @@

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;

import jakarta.annotation.Nullable;
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;
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -161,14 +165,36 @@ public void participate(CustomUserDetails userDetails, Long teamspaceId, Partici
}

final UserTeamspace participatedUserTeamspace = user.participate(teamspace, TeamspaceRole.MEMBER);

final List<UserChatChannel> 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<ChatChannelMessage> 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);
Expand Down
Original file line number Diff line number Diff line change
@@ -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);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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()")
Expand Down Expand Up @@ -151,4 +154,16 @@ public ResponseEntity<ApiResponse<Object>> deleteTeamspaceProfileImageUrl(
teamspaceService.deleteProfileImageUrl(userDetails, teamspaceId);
return ResponseEntity.ok().body(ApiResponse.createSuccessResponse(Map.of()));
}

@GetMapping("/{teamspaceId}/unread-count")
@PreAuthorize("isAuthenticated()")
public ResponseEntity<ApiResponse<UnreadMessageCountResponse>> getTeamspaceUnreadMessageCount(
@AuthenticationPrincipal final CustomUserDetails userDetails,
@PathVariable final Long teamspaceId
) {
return ResponseEntity.ok()
.body(ApiResponse.createSuccessResponse(
chatChannelService.getTeamspaceUnreadMessageCount(userDetails, teamspaceId))
);
}
}
128 changes: 128 additions & 0 deletions src/test/java/one/colla/chat/application/ChatChannelServiceTest.java
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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());
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -44,13 +45,17 @@
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 {

@MockBean
private TeamspaceService teamspaceService;

@MockBean
private ChatChannelService chatChannelService;

@Nested
@DisplayName("팀스페이스 생성 문서화")
class CreateTeamspaceDocs {
Expand Down Expand Up @@ -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<UnreadMessageCountResponse>"
);
}

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());
}
}
}