Skip to content

Commit

Permalink
feat: ✨ Leave Chat Room API (#215)
Browse files Browse the repository at this point in the history
* feat: add chat-member-delete method in rdb-service

* feat: add read-chat-member-by-chat-member-id method in rdb-service and repository

* fix: when search chat-member entity by chat-member-id, exclude chat-room-id

* fix: delete sql-delete annotation in the chat-member entity

* fix: remove delete method and add update method in chat-member-rdb-service

* feat: impl chat-room-leave-service

* feat: add chatroom leave usecase

* feat: add is-exists method with chat-room-id, user-id, chat-member-id in repository

* feat: impl is-exists method in rdb service for adding chat-member-id condition

* feat: add authorization method in the chat-member-service

* feat: add chat leave controller

* feat: add is_admin method in chat-member entity

* feat: add check chat-member-size to leave admin in the chat-room entity

* fix: add admin leave validation rule

* feat: add chat room delete method in chat-room-rdb-service

* feat: add admin-cannot-leave error code

* fix: sperate business logic from service

* style: add log to alter normal member status is changed

* feat: add association mapping helper method chat-room with chat-member

* test: chat room leave collection unit test

* test: chat room leave integration test

* docs: chatroom leave controller swagger docs

* test: disable chat-room-detaill-integration-test temporarily

* test: resolve chat-room-detail-integration test error
  • Loading branch information
psychology50 authored Jan 8, 2025
1 parent 092e7aa commit 389509c
Show file tree
Hide file tree
Showing 18 changed files with 440 additions and 29 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -62,4 +62,15 @@ ResponseEntity<?> joinChatRoom(
})
@ApiResponse(responseCode = "200", description = "채팅방 멤버 조회 성공", content = @Content(schemaProperties = @SchemaProperty(name = "chatMembers", array = @ArraySchema(schema = @Schema(implementation = ChatMemberRes.MemberDetail.class)))))
ResponseEntity<?> readChatMembers(@PathVariable("chatRoomId") Long chatRoomId, @Validated @NotEmpty @RequestParam("ids") Set<Long> ids);

@Operation(summary = "채팅방 멤버 탈퇴", method = "DELETE", description = "채팅방에서 탈퇴한다. 채팅방장은 채팅 멤버가 한 명이라도 남아있으면 탈퇴할 수 없으며, 채팅방장이 탈퇴할 경우 채팅방이 삭제된다.")
@Parameters({
@Parameter(name = "chatRoomId", description = "채팅방 ID", required = true, in = ParameterIn.PATH),
@Parameter(name = "chatMemberId", description = "채팅방 멤버 ID (user id가 아님)", required = true, in = ParameterIn.PATH)
})
@ApiResponseExplanations(errors = {
@ApiExceptionExplanation(value = ChatRoomErrorCode.class, constant = "ADMIN_CANNOT_LEAVE", summary = "채팅방장은 탈퇴할 수 없음", description = "채팅방장은 채팅방 멤버 탈퇴에 실패했습니다.")}
)
@ApiResponse(responseCode = "200", description = "채팅방 멤버 탈퇴 성공")
ResponseEntity<?> leaveChatRoom(@PathVariable("chatRoomId") Long chatRoomId, @PathVariable("chatMemberId") Long chatMemberId);
}
Original file line number Diff line number Diff line change
Expand Up @@ -52,4 +52,13 @@ public ResponseEntity<?> readChatMembers(@PathVariable("chatRoomId") Long chatRo

return ResponseEntity.ok(SuccessResponse.from(CHAT_MEMBERS, chatMemberUseCase.readChatMembers(chatRoomId, chatMemberIds)));
}

@Override
@DeleteMapping("/{chatMemberId}")
@PreAuthorize("isAuthenticated() and @chatRoomManager.hasPermission(principal.userId, #chatRoomId, #chatMemberId)")
public ResponseEntity<?> leaveChatRoom(@PathVariable("chatRoomId") Long chatRoomId, @PathVariable("chatMemberId") Long chatMemberId) {
chatMemberUseCase.leaveChatRoom(chatMemberId);

return ResponseEntity.ok(SuccessResponse.noContent());
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
import kr.co.pennyway.api.apis.chat.service.ChatMemberJoinService;
import kr.co.pennyway.api.apis.chat.service.ChatMemberSearchService;
import kr.co.pennyway.common.annotation.UseCase;
import kr.co.pennyway.domain.context.chat.service.ChatRoomLeaveService;
import kr.co.pennyway.domain.domains.chatroom.domain.ChatRoom;
import kr.co.pennyway.domain.domains.member.dto.ChatMemberResult;
import lombok.RequiredArgsConstructor;
Expand All @@ -22,6 +23,7 @@
public class ChatMemberUseCase {
private final ChatMemberJoinService chatMemberJoinService;
private final ChatMemberSearchService chatMemberSearchService;
private final ChatRoomLeaveService chatRoomLeaveService;

public ChatRoomRes.Detail joinChatRoom(Long userId, Long chatRoomId, Integer password) {
Triple<ChatRoom, Integer, Long> chatRoom = chatMemberJoinService.execute(userId, chatRoomId, password);
Expand All @@ -34,4 +36,8 @@ public List<ChatMemberRes.MemberDetail> readChatMembers(Long chatRoomId, Set<Lon

return ChatMemberMapper.toChatMemberResDetail(chatMembers);
}

public void leaveChatRoom(Long chatMemberId) {
chatRoomLeaveService.execute(chatMemberId);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -19,4 +19,12 @@ public class ChatRoomManager {
public boolean hasPermission(Long userId, Long chatRoomId) {
return chatMemberService.isExists(chatRoomId, userId);
}

/**
* 사용자가 채팅방과 특정 멤버에 대한 접근 권한이 있는지 확인한다.
*/
@Transactional(readOnly = true)
public boolean hasPermission(Long userId, Long chatRoomId, Long chatMemberId) {
return chatMemberService.isExists(chatRoomId, userId, chatMemberId);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -9,10 +9,8 @@
import kr.co.pennyway.api.config.ExternalApiIntegrationTest;
import kr.co.pennyway.api.config.fixture.ChatRoomFixture;
import kr.co.pennyway.api.config.fixture.UserFixture;
import kr.co.pennyway.domain.context.account.service.UserService;
import kr.co.pennyway.domain.context.chat.service.ChatMemberService;
import kr.co.pennyway.domain.context.chat.service.ChatRoomService;
import kr.co.pennyway.domain.domains.chatroom.domain.ChatRoom;
import kr.co.pennyway.domain.domains.chatroom.repository.ChatRoomRepository;
import kr.co.pennyway.domain.domains.member.domain.ChatMember;
import kr.co.pennyway.domain.domains.member.repository.ChatMemberRepository;
import kr.co.pennyway.domain.domains.member.type.ChatMemberRole;
Expand All @@ -22,6 +20,7 @@
import kr.co.pennyway.domain.domains.message.type.MessageCategoryType;
import kr.co.pennyway.domain.domains.message.type.MessageContentType;
import kr.co.pennyway.domain.domains.user.domain.User;
import kr.co.pennyway.domain.domains.user.repository.UserRepository;
import kr.co.pennyway.infra.client.guid.IdGenerator;
import kr.co.pennyway.infra.common.jwt.JwtProvider;
import lombok.extern.slf4j.Slf4j;
Expand Down Expand Up @@ -60,16 +59,13 @@ public class ChatRoomDetailIntegrationTest extends ExternalApiDBTestConfig {
private RedisTemplate<String, String> redisTemplate;

@Autowired
private UserService userService;
private UserRepository userRepository;

@Autowired
private ChatMemberRepository chatMemberRepository;

@Autowired
private ChatRoomService chatRoomService;
private ChatRoomRepository chatRoomRepository;

@Autowired
private ChatMemberService chatMemberService;
private ChatMemberRepository chatMemberRepository;

@Autowired
private ChatMessageRepositoryImpl chatMessageRepository;
Expand All @@ -82,17 +78,9 @@ public class ChatRoomDetailIntegrationTest extends ExternalApiDBTestConfig {
@LocalServerPort
private int port;

private User owner;
private ChatRoom chatRoom;
private ChatMember ownerMember;

@BeforeEach
void setUp() {
apiTestHelper = new ApiTestHelper(restTemplate, objectMapper, accessTokenProvider);

owner = userService.createUser(UserFixture.GENERAL_USER.toUser());
chatRoom = chatRoomService.create(ChatRoomFixture.PUBLIC_CHAT_ROOM.toEntity(1L));
ownerMember = chatMemberService.createAdmin(owner, chatRoom);
}

@AfterEach
Expand All @@ -103,14 +91,20 @@ void tearDown() {
}

chatMemberRepository.deleteAll();
chatRoomRepository.deleteAll();
userRepository.deleteAll();
}

@Test
@DisplayName("Happy Path: 사용자는 채팅방 상세 정보를 조회할 수 있다.")
void successGetChatRoomDetail() {
// given
User member = userService.createUser(UserFixture.GENERAL_USER.toUser());
ChatMember participant = chatMemberService.createMember(member, chatRoom);
var owner = userRepository.save(UserFixture.GENERAL_USER.toUser());
var chatRoom = chatRoomRepository.save(ChatRoomFixture.PUBLIC_CHAT_ROOM.toEntity(1L));
var ownerMember = chatMemberRepository.save(ChatMember.of(owner, chatRoom, ChatMemberRole.ADMIN));

User member = userRepository.save(UserFixture.GENERAL_USER.toUser());
ChatMember participant = chatMemberRepository.save(ChatMember.of(member, chatRoom, ChatMemberRole.MEMBER));

int expectedRecentParticipantCount = 1; // 나 자신은 제외
int expectedMessageCount = 5;
Expand Down Expand Up @@ -142,8 +136,12 @@ void successGetChatRoomDetail() {
@Test
@DisplayName("채팅방 멤버가 아닌 사용자는 조회할 수 없다")
void failGetChatRoomDetailWhenNotMember() {
var owner = userRepository.save(UserFixture.GENERAL_USER.toUser());
var chatRoom = chatRoomRepository.save(ChatRoomFixture.PUBLIC_CHAT_ROOM.toEntity(2L));
var ownerMember = chatMemberRepository.save(ChatMember.of(owner, chatRoom, ChatMemberRole.ADMIN));

// given
User nonMember = userService.createUser(UserFixture.GENERAL_USER.toUser());
User nonMember = userRepository.save(UserFixture.GENERAL_USER.toUser());

// when
ResponseEntity<?> response = performApi(nonMember, chatRoom.getId());
Expand All @@ -156,8 +154,12 @@ void failGetChatRoomDetailWhenNotMember() {
@DisplayName("최근 메시지가 없는 채팅방도 정상적으로 조회된다")
void successGetChatRoomDetailWithoutMessages() {
// given
User member = userService.createUser(UserFixture.GENERAL_USER.toUser());
ChatMember participant = chatMemberService.createMember(member, chatRoom);
var owner = userRepository.save(UserFixture.GENERAL_USER.toUser());
var chatRoom = chatRoomRepository.save(ChatRoomFixture.PUBLIC_CHAT_ROOM.toEntity(3L));
var ownerMember = chatMemberRepository.save(ChatMember.of(owner, chatRoom, ChatMemberRole.ADMIN));

var member = userRepository.save(UserFixture.GENERAL_USER.toUser());
var participant = chatMemberRepository.save(ChatMember.of(member, chatRoom, ChatMemberRole.MEMBER));

// when
ResponseEntity<?> response = performApi(member, chatRoom.getId());
Expand All @@ -174,8 +176,12 @@ void successGetChatRoomDetailWithoutMessages() {
@DisplayName("채팅방에 다수의 참여자가 있는 경우 정상적으로 조회된다")
void successGetChatRoomDetailWithManyParticipants() {
// given
var owner = userRepository.save(UserFixture.GENERAL_USER.toUser());
var chatRoom = chatRoomRepository.save(ChatRoomFixture.PUBLIC_CHAT_ROOM.toEntity(4L));
var ownerMember = chatMemberRepository.save(ChatMember.of(owner, chatRoom, ChatMemberRole.ADMIN));

int expectedParticipantCount = 10;
List<User> participants = createMultipleParticipants(expectedParticipantCount);
List<User> participants = createMultipleParticipants(expectedParticipantCount, chatRoom);

chatMessageRepository.save(createTestMessage(chatRoom.getId(), 1L, owner.getId()));
chatMessageRepository.save(createTestMessage(chatRoom.getId(), 2L, participants.get(0).getId()));
Expand Down Expand Up @@ -206,12 +212,12 @@ private ChatMessage createTestMessage(Long chatRoomId, Long idx, Long senderId)
.build();
}

private List<User> createMultipleParticipants(int count) {
private List<User> createMultipleParticipants(int count, ChatRoom chatRoom) {
List<User> participants = new ArrayList<>();

for (int i = 0; i < count; ++i) {
User user = userService.createUser(UserFixture.GENERAL_USER.toUser());
chatMemberService.createMember(user, chatRoom);
var user = userRepository.save(UserFixture.GENERAL_USER.toUser());
chatMemberRepository.save(ChatMember.of(user, chatRoom, ChatMemberRole.MEMBER));
participants.add(user);
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,19 +2,24 @@

import jakarta.persistence.Entity;
import jakarta.persistence.Id;
import jakarta.persistence.OneToMany;
import jakarta.persistence.Table;
import kr.co.pennyway.domain.common.model.DateAuditable;
import kr.co.pennyway.domain.domains.member.domain.ChatMember;
import lombok.AccessLevel;
import lombok.Builder;
import lombok.Getter;
import lombok.NoArgsConstructor;
import org.hibernate.Hibernate;
import org.hibernate.annotations.ColumnDefault;
import org.hibernate.annotations.DynamicInsert;
import org.hibernate.annotations.SQLDelete;
import org.hibernate.annotations.SQLRestriction;
import org.springframework.util.StringUtils;

import java.time.LocalDateTime;
import java.util.ArrayList;
import java.util.List;
import java.util.Objects;

@Entity
Expand All @@ -36,6 +41,9 @@ public class ChatRoom extends DateAuditable {
@ColumnDefault("NULL")
private LocalDateTime deletedAt;

@OneToMany(mappedBy = "chatRoom")
private List<ChatMember> chatMembers = new ArrayList<>();

@Builder
public ChatRoom(Long id, String title, String description, String backgroundImageUrl, Integer password) {
validate(id, title, description, password);
Expand Down Expand Up @@ -84,6 +92,10 @@ public boolean matchPassword(Integer password) {
return this.password.equals(password);
}

public boolean hasOnlyAdmin() {
return Hibernate.size(chatMembers) == 1 && chatMembers.get(0).isAdmin();
}

@Override
public String toString() {
return "ChatRoom{" +
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -38,4 +38,9 @@ public List<ChatRoomDetail> readChatRoomsByUserId(Long userId) {
public Slice<ChatRoomDetail> readChatRooms(Long userId, String target, Pageable pageable) {
return chatRoomRepository.findChatRooms(userId, target, pageable);
}

@Transactional
public void delete(ChatRoom chatRoom) {
chatRoomRepository.delete(chatRoom);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,6 @@
import lombok.NoArgsConstructor;
import org.hibernate.annotations.ColumnDefault;
import org.hibernate.annotations.DynamicInsert;
import org.hibernate.annotations.SQLDelete;

import java.time.LocalDateTime;
import java.util.Objects;
Expand All @@ -22,7 +21,6 @@
@Table(name = "chat_member")
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@DynamicInsert
@SQLDelete(sql = "UPDATE chat_member SET deleted_at = NOW() WHERE id = ?")
public class ChatMember extends DateAuditable {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
Expand Down Expand Up @@ -52,7 +50,7 @@ protected ChatMember(User user, ChatRoom chatRoom, ChatMemberRole role) {
validate(user, chatRoom, role);

this.user = user;
this.chatRoom = chatRoom;
this.participate(chatRoom);
this.role = role;
this.notifyEnabled = true;
}
Expand All @@ -71,6 +69,15 @@ private void validate(User user, ChatRoom chatRoom, ChatMemberRole role) {
Objects.requireNonNull(role, "role은 null이 될 수 없습니다.");
}

private void participate(ChatRoom chatRoom) {
if (this.chatRoom != null) {
throw new IllegalStateException("ChatMember는 이미 ChatRoom에 속해있습니다.");
}

chatRoom.getChatMembers().add(this);
this.chatRoom = chatRoom;
}

/**
* 사용자 데이터가 삭제되었는지 확인한다.
*
Expand All @@ -80,6 +87,10 @@ public boolean isActive() {
return deletedAt == null;
}

public boolean isAdmin() {
return role.equals(ChatMemberRole.ADMIN);
}

/**
* 사용자 추방된 이력이 있는 지 확인한다.
*
Expand All @@ -97,6 +108,10 @@ public void disableNotify() {
this.notifyEnabled = false;
}

public void leave() {
this.deletedAt = LocalDateTime.now();
}

public void ban() {
this.banned = true;
this.deletedAt = LocalDateTime.now();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ public enum ChatMemberErrorCode implements BaseErrorCode {
NOT_FOUND(StatusCode.NOT_FOUND, ReasonCode.REQUESTED_RESOURCE_NOT_FOUND, "회원을 찾을 수 없습니다."),

/* 409 Conflict */
ADMIN_CANNOT_LEAVE(StatusCode.CONFLICT, ReasonCode.REQUEST_CONFLICTS_WITH_CURRENT_STATE_OF_RESOURCE, "채팅방에 사용자가 남아 있다면, 채팅방 방장은 채팅방을 탈퇴할 수 없습니다."),
ALREADY_JOINED(StatusCode.CONFLICT, ReasonCode.RESOURCE_ALREADY_EXISTS, "이미 가입한 회원입니다."),
;

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,10 @@ public interface ChatMemberRepository extends ExtendedRepository<ChatMember, Lon
@Query("SELECT cm FROM ChatMember cm WHERE cm.chatRoom.id = :chatRoomId AND cm.id IN :chatMemberIds")
List<ChatMember> findByChatRoom_IdAndIdIn(Long chatRoomId, Set<Long> chatMemberIds);

@Transactional(readOnly = true)
@Query("SELECT cm FROM ChatMember cm WHERE cm.id = :chatMemberId")
Optional<ChatMember> findByChatRoom_Id(Long chatMemberId);

@Transactional(readOnly = true)
@Query("SELECT cm FROM ChatMember cm WHERE cm.chatRoom.id = :chatRoomId AND cm.user.id = :userId AND cm.deletedAt IS NULL")
Optional<ChatMember> findActiveChatMember(Long chatRoomId, Long userId);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,8 @@ public interface CustomChatMemberRepository {
*/
boolean existsOwnershipChatRoomByUserId(Long userId);

boolean existsByChatRoomIdAndUserIdAndId(Long chatRoomId, Long userId, Long chatMemberId);

/**
* 채팅방의 관리자 정보를 조회한다.
*/
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,17 @@ public boolean existsOwnershipChatRoomByUserId(Long userId) {
.fetchFirst() != null;
}

@Override
public boolean existsByChatRoomIdAndUserIdAndId(Long chatRoomId, Long userId, Long chatMemberId) {
return queryFactory.select(ConstantImpl.create(1))
.from(chatMember)
.where(chatMember.chatRoom.id.eq(chatRoomId)
.and(chatMember.user.id.eq(userId))
.and(chatMember.id.eq(chatMemberId))
.and(chatMember.deletedAt.isNull()))
.fetchFirst() != null;
}

@Override
public Optional<ChatMemberResult.Detail> findAdminByChatRoomId(Long chatRoomId) {
ChatMemberResult.Detail result =
Expand Down
Loading

0 comments on commit 389509c

Please sign in to comment.