diff --git a/src/main/java/makeus/cmc/malmo/adaptor/in/exception/ErrorCode.java b/src/main/java/makeus/cmc/malmo/adaptor/in/exception/ErrorCode.java index 0dff9355..50cd2382 100644 --- a/src/main/java/makeus/cmc/malmo/adaptor/in/exception/ErrorCode.java +++ b/src/main/java/makeus/cmc/malmo/adaptor/in/exception/ErrorCode.java @@ -23,6 +23,9 @@ public enum ErrorCode { NOT_VALID_COUPLE_CODE(HttpStatus.BAD_REQUEST, 40011, "유효하지 않은 커플 코드입니다."), NO_SUCH_COUPLE_QUESTION(HttpStatus.BAD_REQUEST, 40012, "커플 질문이 존재하지 않습니다."), NO_SUCH_TEMP_LOVE_TYPE(HttpStatus.BAD_REQUEST, 40013, "애착 유형 결과가 존재하지 않습니다."), + NO_SUCH_BOOKMARK(HttpStatus.BAD_REQUEST, 40014, "북마크가 존재하지 않습니다."), + BOOKMARK_ALREADY_EXISTS(HttpStatus.BAD_REQUEST, 40015, "이미 북마크된 메시지입니다."), + NO_SUCH_MESSAGE(HttpStatus.BAD_REQUEST, 40016, "메시지가 존재하지 않습니다."), // 401 Unauthorized UNAUTHORIZED(HttpStatus.UNAUTHORIZED, 40100, "인증되지 않은 사용자입니다."), diff --git a/src/main/java/makeus/cmc/malmo/adaptor/in/exception/GlobalExceptionHandler.java b/src/main/java/makeus/cmc/malmo/adaptor/in/exception/GlobalExceptionHandler.java index 46f81fc9..149dc51d 100644 --- a/src/main/java/makeus/cmc/malmo/adaptor/in/exception/GlobalExceptionHandler.java +++ b/src/main/java/makeus/cmc/malmo/adaptor/in/exception/GlobalExceptionHandler.java @@ -150,6 +150,24 @@ public ResponseEntity handleTempLoveTypeNotFoundException(TempLov return ErrorResponse.of(ErrorCode.NO_SUCH_TEMP_LOVE_TYPE); } + @ExceptionHandler({BookmarkNotFoundException.class}) + public ResponseEntity handleBookmarkNotFoundException(BookmarkNotFoundException e) { + log.warn("[GlobalExceptionHandler: handleBookmarkNotFoundException 호출] {}", e.getMessage()); + return ErrorResponse.of(ErrorCode.NO_SUCH_BOOKMARK); + } + + @ExceptionHandler({BookmarkAlreadyExistsException.class}) + public ResponseEntity handleBookmarkAlreadyExistsException(BookmarkAlreadyExistsException e) { + log.info("[GlobalExceptionHandler: handleBookmarkAlreadyExistsException 호출] {}", e.getMessage()); + return ErrorResponse.of(ErrorCode.BOOKMARK_ALREADY_EXISTS); + } + + @ExceptionHandler({MessageNotFoundException.class}) + public ResponseEntity handleMessageNotFoundException(MessageNotFoundException e) { + log.warn("[GlobalExceptionHandler: handleMessageNotFoundException 호출] {}", e.getMessage()); + return ErrorResponse.of(ErrorCode.NO_SUCH_MESSAGE); + } + /** diff --git a/src/main/java/makeus/cmc/malmo/adaptor/in/web/controller/BookmarkController.java b/src/main/java/makeus/cmc/malmo/adaptor/in/web/controller/BookmarkController.java new file mode 100644 index 00000000..594dc00a --- /dev/null +++ b/src/main/java/makeus/cmc/malmo/adaptor/in/web/controller/BookmarkController.java @@ -0,0 +1,163 @@ +package makeus.cmc.malmo.adaptor.in.web.controller; + +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.media.Content; +import io.swagger.v3.oas.annotations.media.Schema; +import io.swagger.v3.oas.annotations.responses.ApiResponse; +import io.swagger.v3.oas.annotations.security.SecurityRequirement; +import io.swagger.v3.oas.annotations.tags.Tag; +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; +import lombok.RequiredArgsConstructor; +import makeus.cmc.malmo.adaptor.in.web.docs.ApiCommonResponses; +import makeus.cmc.malmo.adaptor.in.web.dto.BaseListResponse; +import makeus.cmc.malmo.adaptor.in.web.dto.BaseResponse; +import makeus.cmc.malmo.application.port.in.chat.CreateBookmarkUseCase; +import makeus.cmc.malmo.application.port.in.chat.DeleteBookmarksUseCase; +import makeus.cmc.malmo.application.port.in.chat.GetBookmarkListUseCase; +import makeus.cmc.malmo.application.port.in.chat.GetMessagesByBookmarkUseCase; +import org.springframework.data.domain.Pageable; +import org.springframework.data.web.PageableDefault; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.security.core.userdetails.User; +import org.springframework.web.bind.annotation.*; + +import java.util.List; + +@Tag(name = "북마크 API", description = "채팅 메시지 북마크 관리를 위한 API") +@RestController +@RequestMapping("/chatrooms/{chatRoomId}/bookmarks") +@RequiredArgsConstructor +public class BookmarkController { + + private final CreateBookmarkUseCase createBookmarkUseCase; + private final DeleteBookmarksUseCase deleteBookmarksUseCase; + private final GetBookmarkListUseCase getBookmarkListUseCase; + private final GetMessagesByBookmarkUseCase getMessagesByBookmarkUseCase; + + @Operation( + summary = "북마크 생성", + description = "채팅 메시지를 북마크합니다. JWT 토큰이 필요합니다.", + security = @SecurityRequirement(name = "Bearer Authentication") + ) + @ApiResponse( + responseCode = "200", + description = "북마크 생성 성공" + ) + @ApiCommonResponses.RequireAuth + @PostMapping + public BaseResponse createBookmark( + @AuthenticationPrincipal User user, + @PathVariable Long chatRoomId, + @RequestBody CreateBookmarkRequestDto requestDto) { + + CreateBookmarkUseCase.CreateBookmarkCommand command = + CreateBookmarkUseCase.CreateBookmarkCommand.builder() + .userId(Long.valueOf(user.getUsername())) + .chatRoomId(chatRoomId) + .messageId(requestDto.getMessageId()) + .build(); + + return BaseResponse.success(createBookmarkUseCase.createBookmark(command)); + } + + @Operation( + summary = "북마크 삭제 (다건)", + description = "북마크를 삭제합니다 (소프트 삭제). JWT 토큰이 필요합니다.", + security = @SecurityRequirement(name = "Bearer Authentication") + ) + @ApiResponse( + responseCode = "200", + description = "북마크 삭제 성공" + ) + @ApiCommonResponses.RequireAuth + @DeleteMapping + public BaseResponse deleteBookmarks( + @AuthenticationPrincipal User user, + @PathVariable Long chatRoomId, + @RequestBody DeleteBookmarksRequestDto requestDto) { + + DeleteBookmarksUseCase.DeleteBookmarksCommand command = + DeleteBookmarksUseCase.DeleteBookmarksCommand.builder() + .userId(Long.valueOf(user.getUsername())) + .chatRoomId(chatRoomId) + .bookmarkIdList(requestDto.getBookmarkIdList()) + .build(); + + deleteBookmarksUseCase.deleteBookmarks(command); + return BaseResponse.success(null); + } + + @Operation( + summary = "북마크 리스트 조회", + description = "채팅방의 북마크 목록을 조회합니다. JWT 토큰이 필요합니다.", + security = @SecurityRequirement(name = "Bearer Authentication") + ) + @ApiResponse( + responseCode = "200", + description = "북마크 리스트 조회 성공" + ) + @ApiCommonResponses.RequireAuth + @GetMapping + public BaseResponse> getBookmarkList( + @AuthenticationPrincipal User user, + @PathVariable Long chatRoomId, + @PageableDefault(page = 0, size = 10) Pageable pageable) { + + GetBookmarkListUseCase.GetBookmarkListCommand command = + GetBookmarkListUseCase.GetBookmarkListCommand.builder() + .userId(Long.valueOf(user.getUsername())) + .chatRoomId(chatRoomId) + .pageable(pageable) + .build(); + + GetBookmarkListUseCase.GetBookmarkListResponse response = + getBookmarkListUseCase.getBookmarkList(command); + + return BaseListResponse.success(response.getBookmarkList(), response.getTotalCount()); + } + + @Operation( + summary = "북마크 기반 메시지 조회", + description = "북마크된 메시지 위치로 이동하기 위한 메시지 목록을 조회합니다. JWT 토큰이 필요합니다.", + security = @SecurityRequirement(name = "Bearer Authentication") + ) + @ApiResponse( + responseCode = "200", + description = "북마크 기반 메시지 조회 성공" + ) + @ApiCommonResponses.RequireAuth + @GetMapping("/{bookmarkId}/messages") + public BaseResponse getMessagesByBookmark( + @AuthenticationPrincipal User user, + @PathVariable Long chatRoomId, + @PathVariable Long bookmarkId, + @RequestParam(defaultValue = "10") int size, + @RequestParam(defaultValue = "ASC") String sort) { + + GetMessagesByBookmarkUseCase.GetMessagesByBookmarkCommand command = + GetMessagesByBookmarkUseCase.GetMessagesByBookmarkCommand.builder() + .userId(Long.valueOf(user.getUsername())) + .bookmarkId(bookmarkId) + .size(size) + .sort(sort) + .build(); + + return BaseResponse.success(getMessagesByBookmarkUseCase.getMessagesByBookmark(command)); + } + + @Data + @NoArgsConstructor + @AllArgsConstructor + public static class CreateBookmarkRequestDto { + private Long messageId; + } + + @Data + @NoArgsConstructor + @AllArgsConstructor + public static class DeleteBookmarksRequestDto { + private List bookmarkIdList; + } +} diff --git a/src/main/java/makeus/cmc/malmo/adaptor/out/persistence/adapter/BookmarkPersistenceAdapter.java b/src/main/java/makeus/cmc/malmo/adaptor/out/persistence/adapter/BookmarkPersistenceAdapter.java new file mode 100644 index 00000000..d1980aca --- /dev/null +++ b/src/main/java/makeus/cmc/malmo/adaptor/out/persistence/adapter/BookmarkPersistenceAdapter.java @@ -0,0 +1,79 @@ +package makeus.cmc.malmo.adaptor.out.persistence.adapter; + +import lombok.RequiredArgsConstructor; +import makeus.cmc.malmo.adaptor.out.persistence.entity.chat.BookmarkEntity; +import makeus.cmc.malmo.adaptor.out.persistence.mapper.BookmarkMapper; +import makeus.cmc.malmo.adaptor.out.persistence.repository.chat.BookmarkRepository; +import makeus.cmc.malmo.application.port.out.chat.DeleteBookmarkPort; +import makeus.cmc.malmo.application.port.out.chat.LoadBookmarkPort; +import makeus.cmc.malmo.application.port.out.chat.SaveBookmarkPort; +import makeus.cmc.malmo.domain.model.chat.Bookmark; +import makeus.cmc.malmo.domain.value.id.BookmarkId; +import makeus.cmc.malmo.domain.value.id.ChatRoomId; +import makeus.cmc.malmo.domain.value.id.MemberId; +import makeus.cmc.malmo.domain.value.state.BookmarkState; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.stereotype.Component; + +import java.util.List; +import java.util.Optional; + +@Component +@RequiredArgsConstructor +public class BookmarkPersistenceAdapter implements LoadBookmarkPort, SaveBookmarkPort, DeleteBookmarkPort { + + private final BookmarkRepository bookmarkRepository; + private final BookmarkMapper bookmarkMapper; + + @Override + public Optional loadBookmarkById(BookmarkId bookmarkId) { + return bookmarkRepository.findByIdAndBookmarkState(bookmarkId.getValue(), BookmarkState.ALIVE) + .map(bookmarkMapper::toDomain); + } + + @Override + public Optional loadBookmarkByMemberAndMessage(MemberId memberId, Long chatMessageId) { + return bookmarkRepository.findByMemberEntityIdValueAndChatMessageEntityIdValueAndBookmarkState( + memberId.getValue(), chatMessageId, BookmarkState.ALIVE) + .map(bookmarkMapper::toDomain); + } + + @Override + public Page loadBookmarksByMemberAndChatRoom( + MemberId memberId, ChatRoomId chatRoomId, Pageable pageable) { + return bookmarkRepository.loadBookmarksByMemberAndChatRoom( + memberId.getValue(), chatRoomId.getValue(), pageable); + } + + @Override + public boolean existsByMemberAndMessage(MemberId memberId, Long chatMessageId) { + return bookmarkRepository.existsByMemberEntityIdValueAndChatMessageEntityIdValueAndBookmarkState( + memberId.getValue(), chatMessageId, BookmarkState.ALIVE); + } + + @Override + public boolean isMemberOwnerOfBookmarks(MemberId memberId, List bookmarkIds) { + return bookmarkRepository.isMemberOwnerOfBookmarks( + memberId.getValue(), + bookmarkIds.stream().map(BookmarkId::getValue).toList()); + } + + @Override + public long countMessagesBeforeId(ChatRoomId chatRoomId, Long messageId, String sort) { + return bookmarkRepository.countMessagesBeforeId(chatRoomId.getValue(), messageId, sort); + } + + @Override + public Bookmark saveBookmark(Bookmark bookmark) { + BookmarkEntity entity = bookmarkMapper.toEntity(bookmark); + BookmarkEntity saved = bookmarkRepository.save(entity); + return bookmarkMapper.toDomain(saved); + } + + @Override + public void softDeleteBookmarks(List bookmarkIds) { + bookmarkRepository.softDeleteBookmarks( + bookmarkIds.stream().map(BookmarkId::getValue).toList()); + } +} diff --git a/src/main/java/makeus/cmc/malmo/adaptor/out/persistence/adapter/ChatRoomPersistenceAdapter.java b/src/main/java/makeus/cmc/malmo/adaptor/out/persistence/adapter/ChatRoomPersistenceAdapter.java index 3ba16e01..4a732e54 100644 --- a/src/main/java/makeus/cmc/malmo/adaptor/out/persistence/adapter/ChatRoomPersistenceAdapter.java +++ b/src/main/java/makeus/cmc/malmo/adaptor/out/persistence/adapter/ChatRoomPersistenceAdapter.java @@ -31,13 +31,19 @@ public class ChatRoomPersistenceAdapter private final ChatMessageMapper chatMessageMapper; @Override - public Page loadMessagesDto(ChatRoomId chatRoomId, Pageable pageable) { - return chatMessageRepository.loadCurrentMessagesDto(chatRoomId.getValue(), pageable); + public Optional loadMessageById(Long messageId) { + return chatMessageRepository.findById(messageId) + .map(chatMessageMapper::toDomain); } @Override - public Page loadMessagesDtoAsc(ChatRoomId chatRoomId, Pageable pageable) { - return chatMessageRepository.loadCurrentMessagesDtoAsc(chatRoomId.getValue(), pageable); + public Page loadMessagesDto(ChatRoomId chatRoomId, MemberId memberId, Pageable pageable) { + return chatMessageRepository.loadCurrentMessagesDto(chatRoomId.getValue(), memberId.getValue(), pageable); + } + + @Override + public Page loadMessagesDtoAsc(ChatRoomId chatRoomId, MemberId memberId, Pageable pageable) { + return chatMessageRepository.loadCurrentMessagesDtoAsc(chatRoomId.getValue(), memberId.getValue(), pageable); } @Override diff --git a/src/main/java/makeus/cmc/malmo/adaptor/out/persistence/entity/chat/BookmarkEntity.java b/src/main/java/makeus/cmc/malmo/adaptor/out/persistence/entity/chat/BookmarkEntity.java new file mode 100644 index 00000000..534897cc --- /dev/null +++ b/src/main/java/makeus/cmc/malmo/adaptor/out/persistence/entity/chat/BookmarkEntity.java @@ -0,0 +1,45 @@ +package makeus.cmc.malmo.adaptor.out.persistence.entity.chat; + +import jakarta.persistence.*; +import lombok.AccessLevel; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.experimental.SuperBuilder; +import makeus.cmc.malmo.adaptor.out.persistence.entity.BaseTimeEntity; +import makeus.cmc.malmo.adaptor.out.persistence.entity.value.ChatMessageEntityId; +import makeus.cmc.malmo.adaptor.out.persistence.entity.value.ChatRoomEntityId; +import makeus.cmc.malmo.adaptor.out.persistence.entity.value.MemberEntityId; +import makeus.cmc.malmo.domain.value.state.BookmarkState; + +@Getter +@SuperBuilder +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@Entity +@Table(name = "bookmark_entity", + indexes = { + @Index(name = "idx_bookmark_member_chatroom", columnList = "member_id, chat_room_id"), + @Index(name = "idx_bookmark_member_message", columnList = "member_id, chat_message_id") + }) +public class BookmarkEntity extends BaseTimeEntity { + + @Column(name = "bookmarkId") + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Embedded + private ChatRoomEntityId chatRoomEntityId; + + @Embedded + private ChatMessageEntityId chatMessageEntityId; + + @Embedded + private MemberEntityId memberEntityId; + + @Enumerated(EnumType.STRING) + private BookmarkState bookmarkState; + + public void softDelete() { + this.bookmarkState = BookmarkState.DELETED; + } +} diff --git a/src/main/java/makeus/cmc/malmo/adaptor/out/persistence/mapper/BookmarkMapper.java b/src/main/java/makeus/cmc/malmo/adaptor/out/persistence/mapper/BookmarkMapper.java new file mode 100644 index 00000000..bc242f13 --- /dev/null +++ b/src/main/java/makeus/cmc/malmo/adaptor/out/persistence/mapper/BookmarkMapper.java @@ -0,0 +1,46 @@ +package makeus.cmc.malmo.adaptor.out.persistence.mapper; + +import makeus.cmc.malmo.adaptor.out.persistence.entity.chat.BookmarkEntity; +import makeus.cmc.malmo.adaptor.out.persistence.entity.value.ChatMessageEntityId; +import makeus.cmc.malmo.adaptor.out.persistence.entity.value.ChatRoomEntityId; +import makeus.cmc.malmo.adaptor.out.persistence.entity.value.MemberEntityId; +import makeus.cmc.malmo.domain.model.chat.Bookmark; +import makeus.cmc.malmo.domain.value.id.ChatRoomId; +import makeus.cmc.malmo.domain.value.id.MemberId; +import org.springframework.stereotype.Component; + +@Component +public class BookmarkMapper { + + public Bookmark toDomain(BookmarkEntity entity) { + return Bookmark.from( + entity.getId(), + entity.getChatRoomEntityId() != null + ? ChatRoomId.of(entity.getChatRoomEntityId().getValue()) : null, + entity.getChatMessageEntityId() != null + ? entity.getChatMessageEntityId().getValue() : null, + entity.getMemberEntityId() != null + ? MemberId.of(entity.getMemberEntityId().getValue()) : null, + entity.getBookmarkState(), + entity.getCreatedAt(), + entity.getModifiedAt(), + entity.getDeletedAt() + ); + } + + public BookmarkEntity toEntity(Bookmark domain) { + return BookmarkEntity.builder() + .id(domain.getId()) + .chatRoomEntityId(domain.getChatRoomId() != null + ? ChatRoomEntityId.of(domain.getChatRoomId().getValue()) : null) + .chatMessageEntityId(domain.getChatMessageId() != null + ? ChatMessageEntityId.of(domain.getChatMessageId()) : null) + .memberEntityId(domain.getMemberId() != null + ? MemberEntityId.of(domain.getMemberId().getValue()) : null) + .bookmarkState(domain.getBookmarkState()) + .createdAt(domain.getCreatedAt()) + .modifiedAt(domain.getModifiedAt()) + .deletedAt(domain.getDeletedAt()) + .build(); + } +} diff --git a/src/main/java/makeus/cmc/malmo/adaptor/out/persistence/repository/chat/BookmarkRepository.java b/src/main/java/makeus/cmc/malmo/adaptor/out/persistence/repository/chat/BookmarkRepository.java new file mode 100644 index 00000000..c02b54d1 --- /dev/null +++ b/src/main/java/makeus/cmc/malmo/adaptor/out/persistence/repository/chat/BookmarkRepository.java @@ -0,0 +1,18 @@ +package makeus.cmc.malmo.adaptor.out.persistence.repository.chat; + +import makeus.cmc.malmo.adaptor.out.persistence.entity.chat.BookmarkEntity; +import makeus.cmc.malmo.domain.value.state.BookmarkState; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.Optional; + +public interface BookmarkRepository extends JpaRepository, BookmarkRepositoryCustom { + + Optional findByIdAndBookmarkState(Long id, BookmarkState state); + + Optional findByMemberEntityIdValueAndChatMessageEntityIdValueAndBookmarkState( + Long memberId, Long chatMessageId, BookmarkState state); + + boolean existsByMemberEntityIdValueAndChatMessageEntityIdValueAndBookmarkState( + Long memberId, Long chatMessageId, BookmarkState state); +} diff --git a/src/main/java/makeus/cmc/malmo/adaptor/out/persistence/repository/chat/BookmarkRepositoryCustom.java b/src/main/java/makeus/cmc/malmo/adaptor/out/persistence/repository/chat/BookmarkRepositoryCustom.java new file mode 100644 index 00000000..6899a2dc --- /dev/null +++ b/src/main/java/makeus/cmc/malmo/adaptor/out/persistence/repository/chat/BookmarkRepositoryCustom.java @@ -0,0 +1,19 @@ +package makeus.cmc.malmo.adaptor.out.persistence.repository.chat; + +import makeus.cmc.malmo.application.port.out.chat.LoadBookmarkPort; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; + +import java.util.List; + +public interface BookmarkRepositoryCustom { + + Page loadBookmarksByMemberAndChatRoom( + Long memberId, Long chatRoomId, Pageable pageable); + + boolean isMemberOwnerOfBookmarks(Long memberId, List bookmarkIds); + + void softDeleteBookmarks(List bookmarkIds); + + long countMessagesBeforeId(Long chatRoomId, Long messageId, String sort); +} diff --git a/src/main/java/makeus/cmc/malmo/adaptor/out/persistence/repository/chat/BookmarkRepositoryCustomImpl.java b/src/main/java/makeus/cmc/malmo/adaptor/out/persistence/repository/chat/BookmarkRepositoryCustomImpl.java new file mode 100644 index 00000000..f30ce521 --- /dev/null +++ b/src/main/java/makeus/cmc/malmo/adaptor/out/persistence/repository/chat/BookmarkRepositoryCustomImpl.java @@ -0,0 +1,98 @@ +package makeus.cmc.malmo.adaptor.out.persistence.repository.chat; + +import com.querydsl.core.types.Projections; +import com.querydsl.jpa.impl.JPAQueryFactory; +import lombok.RequiredArgsConstructor; +import makeus.cmc.malmo.application.port.out.chat.LoadBookmarkPort; +import makeus.cmc.malmo.domain.value.state.BookmarkState; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageImpl; +import org.springframework.data.domain.Pageable; + +import java.time.LocalDateTime; +import java.util.List; + +import static makeus.cmc.malmo.adaptor.out.persistence.entity.chat.QBookmarkEntity.bookmarkEntity; +import static makeus.cmc.malmo.adaptor.out.persistence.entity.chat.QChatMessageEntity.chatMessageEntity; + +@RequiredArgsConstructor +public class BookmarkRepositoryCustomImpl implements BookmarkRepositoryCustom { + + private final JPAQueryFactory queryFactory; + + @Override + public Page loadBookmarksByMemberAndChatRoom( + Long memberId, Long chatRoomId, Pageable pageable) { + + List content = queryFactory + .select(Projections.constructor(LoadBookmarkPort.BookmarkDto.class, + bookmarkEntity.id, + chatMessageEntity.id, + chatMessageEntity.content, + chatMessageEntity.senderType, + chatMessageEntity.createdAt)) + .from(bookmarkEntity) + .join(chatMessageEntity).on(bookmarkEntity.chatMessageEntityId.value.eq(chatMessageEntity.id)) + .where(bookmarkEntity.memberEntityId.value.eq(memberId) + .and(bookmarkEntity.chatRoomEntityId.value.eq(chatRoomId)) + .and(bookmarkEntity.bookmarkState.eq(BookmarkState.ALIVE))) + .orderBy(bookmarkEntity.createdAt.desc()) + .offset(pageable.getOffset()) + .limit(pageable.getPageSize()) + .fetch(); + + Long total = queryFactory + .select(bookmarkEntity.count()) + .from(bookmarkEntity) + .where(bookmarkEntity.memberEntityId.value.eq(memberId) + .and(bookmarkEntity.chatRoomEntityId.value.eq(chatRoomId)) + .and(bookmarkEntity.bookmarkState.eq(BookmarkState.ALIVE))) + .fetchOne(); + + return new PageImpl<>(content, pageable, total != null ? total : 0L); + } + + @Override + public boolean isMemberOwnerOfBookmarks(Long memberId, List bookmarkIds) { + Long count = queryFactory + .select(bookmarkEntity.count()) + .from(bookmarkEntity) + .where(bookmarkEntity.id.in(bookmarkIds) + .and(bookmarkEntity.memberEntityId.value.eq(memberId)) + .and(bookmarkEntity.bookmarkState.eq(BookmarkState.ALIVE))) + .fetchOne(); + + return count != null && count == bookmarkIds.size(); + } + + @Override + public void softDeleteBookmarks(List bookmarkIds) { + queryFactory + .update(bookmarkEntity) + .set(bookmarkEntity.bookmarkState, BookmarkState.DELETED) + .set(bookmarkEntity.deletedAt, LocalDateTime.now()) + .where(bookmarkEntity.id.in(bookmarkIds)) + .execute(); + } + + @Override + public long countMessagesBeforeId(Long chatRoomId, Long messageId, String sort) { + Long count; + if ("ASC".equalsIgnoreCase(sort)) { + count = queryFactory + .select(chatMessageEntity.count()) + .from(chatMessageEntity) + .where(chatMessageEntity.chatRoomEntityId.value.eq(chatRoomId) + .and(chatMessageEntity.id.lt(messageId))) + .fetchOne(); + } else { + count = queryFactory + .select(chatMessageEntity.count()) + .from(chatMessageEntity) + .where(chatMessageEntity.chatRoomEntityId.value.eq(chatRoomId) + .and(chatMessageEntity.id.gt(messageId))) + .fetchOne(); + } + return count != null ? count : 0L; + } +} diff --git a/src/main/java/makeus/cmc/malmo/adaptor/out/persistence/repository/chat/ChatMessageRepositoryCustom.java b/src/main/java/makeus/cmc/malmo/adaptor/out/persistence/repository/chat/ChatMessageRepositoryCustom.java index c9477b50..b4bf0327 100644 --- a/src/main/java/makeus/cmc/malmo/adaptor/out/persistence/repository/chat/ChatMessageRepositoryCustom.java +++ b/src/main/java/makeus/cmc/malmo/adaptor/out/persistence/repository/chat/ChatMessageRepositoryCustom.java @@ -5,6 +5,6 @@ import org.springframework.data.domain.Pageable; public interface ChatMessageRepositoryCustom { - Page loadCurrentMessagesDto(Long chatRoomId, Pageable pageable); - Page loadCurrentMessagesDtoAsc(Long chatRoomId, Pageable pageable); + Page loadCurrentMessagesDto(Long chatRoomId, Long memberId, Pageable pageable); + Page loadCurrentMessagesDtoAsc(Long chatRoomId, Long memberId, Pageable pageable); } diff --git a/src/main/java/makeus/cmc/malmo/adaptor/out/persistence/repository/chat/ChatMessageRepositoryCustomImpl.java b/src/main/java/makeus/cmc/malmo/adaptor/out/persistence/repository/chat/ChatMessageRepositoryCustomImpl.java index f76702bf..04b682c3 100644 --- a/src/main/java/makeus/cmc/malmo/adaptor/out/persistence/repository/chat/ChatMessageRepositoryCustomImpl.java +++ b/src/main/java/makeus/cmc/malmo/adaptor/out/persistence/repository/chat/ChatMessageRepositoryCustomImpl.java @@ -4,15 +4,15 @@ import com.querydsl.jpa.impl.JPAQueryFactory; import lombok.RequiredArgsConstructor; import makeus.cmc.malmo.application.port.out.chat.LoadMessagesPort; -import makeus.cmc.malmo.domain.value.state.SavedChatMessageState; +import makeus.cmc.malmo.domain.value.state.BookmarkState; import org.springframework.data.domain.Page; import org.springframework.data.domain.PageImpl; import org.springframework.data.domain.Pageable; import java.util.List; +import static makeus.cmc.malmo.adaptor.out.persistence.entity.chat.QBookmarkEntity.bookmarkEntity; import static makeus.cmc.malmo.adaptor.out.persistence.entity.chat.QChatMessageEntity.chatMessageEntity; -import static makeus.cmc.malmo.adaptor.out.persistence.entity.chat.QSavedChatMessageEntity.savedChatMessageEntity; @RequiredArgsConstructor public class ChatMessageRepositoryCustomImpl implements ChatMessageRepositoryCustom { @@ -20,18 +20,19 @@ public class ChatMessageRepositoryCustomImpl implements ChatMessageRepositoryCus private final JPAQueryFactory queryFactory; @Override - public Page loadCurrentMessagesDto(Long chatRoomId, Pageable pageable) { + public Page loadCurrentMessagesDto(Long chatRoomId, Long memberId, Pageable pageable) { List content = queryFactory.select(Projections.constructor(LoadMessagesPort.ChatRoomMessageRepositoryDto.class, chatMessageEntity.id, chatMessageEntity.senderType, chatMessageEntity.content, chatMessageEntity.createdAt, - savedChatMessageEntity.isNotNull() + bookmarkEntity.isNotNull() )) .from(chatMessageEntity) - .leftJoin(savedChatMessageEntity) - .on(savedChatMessageEntity.chatMessageEntityId.value.eq(chatMessageEntity.id) - .and(savedChatMessageEntity.savedChatMessageState.eq(SavedChatMessageState.ALIVE))) + .leftJoin(bookmarkEntity) + .on(bookmarkEntity.chatMessageEntityId.value.eq(chatMessageEntity.id) + .and(bookmarkEntity.memberEntityId.value.eq(memberId)) + .and(bookmarkEntity.bookmarkState.eq(BookmarkState.ALIVE))) .where(chatMessageEntity.chatRoomEntityId.value.eq(chatRoomId)) .orderBy(chatMessageEntity.createdAt.desc()) .offset(pageable.getOffset()) @@ -40,9 +41,6 @@ public Page loadCurrentMessagesDt long total = queryFactory.select(chatMessageEntity.count()) .from(chatMessageEntity) - .leftJoin(savedChatMessageEntity) - .on(savedChatMessageEntity.chatMessageEntityId.value.eq(chatMessageEntity.id) - .and(savedChatMessageEntity.savedChatMessageState.eq(SavedChatMessageState.ALIVE))) .where(chatMessageEntity.chatRoomEntityId.value.eq(chatRoomId)) .fetchOne(); @@ -50,18 +48,19 @@ public Page loadCurrentMessagesDt } @Override - public Page loadCurrentMessagesDtoAsc(Long chatRoomId, Pageable pageable) { + public Page loadCurrentMessagesDtoAsc(Long chatRoomId, Long memberId, Pageable pageable) { List content = queryFactory.select(Projections.constructor(LoadMessagesPort.ChatRoomMessageRepositoryDto.class, chatMessageEntity.id, chatMessageEntity.senderType, chatMessageEntity.content, chatMessageEntity.createdAt, - savedChatMessageEntity.isNotNull() + bookmarkEntity.isNotNull() )) .from(chatMessageEntity) - .leftJoin(savedChatMessageEntity) - .on(savedChatMessageEntity.chatMessageEntityId.value.eq(chatMessageEntity.id) - .and(savedChatMessageEntity.savedChatMessageState.eq(SavedChatMessageState.ALIVE))) + .leftJoin(bookmarkEntity) + .on(bookmarkEntity.chatMessageEntityId.value.eq(chatMessageEntity.id) + .and(bookmarkEntity.memberEntityId.value.eq(memberId)) + .and(bookmarkEntity.bookmarkState.eq(BookmarkState.ALIVE))) .where(chatMessageEntity.chatRoomEntityId.value.eq(chatRoomId)) .orderBy(chatMessageEntity.createdAt.asc()) .offset(pageable.getOffset()) @@ -70,9 +69,6 @@ public Page loadCurrentMessagesDt long total = queryFactory.select(chatMessageEntity.count()) .from(chatMessageEntity) - .leftJoin(savedChatMessageEntity) - .on(savedChatMessageEntity.chatMessageEntityId.value.eq(chatMessageEntity.id) - .and(savedChatMessageEntity.savedChatMessageState.eq(SavedChatMessageState.ALIVE))) .where(chatMessageEntity.chatRoomEntityId.value.eq(chatRoomId)) .fetchOne(); diff --git a/src/main/java/makeus/cmc/malmo/application/exception/BookmarkAlreadyExistsException.java b/src/main/java/makeus/cmc/malmo/application/exception/BookmarkAlreadyExistsException.java new file mode 100644 index 00000000..2e3a7ae3 --- /dev/null +++ b/src/main/java/makeus/cmc/malmo/application/exception/BookmarkAlreadyExistsException.java @@ -0,0 +1,12 @@ +package makeus.cmc.malmo.application.exception; + +public class BookmarkAlreadyExistsException extends RuntimeException { + + public BookmarkAlreadyExistsException() { + super("이미 북마크된 메시지입니다."); + } + + public BookmarkAlreadyExistsException(String message) { + super(message); + } +} diff --git a/src/main/java/makeus/cmc/malmo/application/exception/BookmarkNotFoundException.java b/src/main/java/makeus/cmc/malmo/application/exception/BookmarkNotFoundException.java new file mode 100644 index 00000000..00a42494 --- /dev/null +++ b/src/main/java/makeus/cmc/malmo/application/exception/BookmarkNotFoundException.java @@ -0,0 +1,12 @@ +package makeus.cmc.malmo.application.exception; + +public class BookmarkNotFoundException extends RuntimeException { + + public BookmarkNotFoundException() { + super("북마크가 존재하지 않습니다."); + } + + public BookmarkNotFoundException(String message) { + super(message); + } +} diff --git a/src/main/java/makeus/cmc/malmo/application/exception/MessageNotFoundException.java b/src/main/java/makeus/cmc/malmo/application/exception/MessageNotFoundException.java new file mode 100644 index 00000000..2b2dbc04 --- /dev/null +++ b/src/main/java/makeus/cmc/malmo/application/exception/MessageNotFoundException.java @@ -0,0 +1,12 @@ +package makeus.cmc.malmo.application.exception; + +public class MessageNotFoundException extends RuntimeException { + + public MessageNotFoundException() { + super("메시지가 존재하지 않습니다."); + } + + public MessageNotFoundException(String message) { + super(message); + } +} diff --git a/src/main/java/makeus/cmc/malmo/application/helper/chat_room/BookmarkCommandHelper.java b/src/main/java/makeus/cmc/malmo/application/helper/chat_room/BookmarkCommandHelper.java new file mode 100644 index 00000000..de90c484 --- /dev/null +++ b/src/main/java/makeus/cmc/malmo/application/helper/chat_room/BookmarkCommandHelper.java @@ -0,0 +1,26 @@ +package makeus.cmc.malmo.application.helper.chat_room; + +import lombok.RequiredArgsConstructor; +import makeus.cmc.malmo.application.port.out.chat.DeleteBookmarkPort; +import makeus.cmc.malmo.application.port.out.chat.SaveBookmarkPort; +import makeus.cmc.malmo.domain.model.chat.Bookmark; +import makeus.cmc.malmo.domain.value.id.BookmarkId; +import org.springframework.stereotype.Component; + +import java.util.List; + +@Component +@RequiredArgsConstructor +public class BookmarkCommandHelper { + + private final SaveBookmarkPort saveBookmarkPort; + private final DeleteBookmarkPort deleteBookmarkPort; + + public Bookmark saveBookmark(Bookmark bookmark) { + return saveBookmarkPort.saveBookmark(bookmark); + } + + public void softDeleteBookmarks(List bookmarkIds) { + deleteBookmarkPort.softDeleteBookmarks(bookmarkIds); + } +} diff --git a/src/main/java/makeus/cmc/malmo/application/helper/chat_room/BookmarkQueryHelper.java b/src/main/java/makeus/cmc/malmo/application/helper/chat_room/BookmarkQueryHelper.java new file mode 100644 index 00000000..e1c650c0 --- /dev/null +++ b/src/main/java/makeus/cmc/malmo/application/helper/chat_room/BookmarkQueryHelper.java @@ -0,0 +1,68 @@ +package makeus.cmc.malmo.application.helper.chat_room; + +import lombok.RequiredArgsConstructor; +import makeus.cmc.malmo.application.exception.BookmarkNotFoundException; +import makeus.cmc.malmo.application.exception.MemberAccessDeniedException; +import makeus.cmc.malmo.application.exception.MessageNotFoundException; +import makeus.cmc.malmo.application.port.out.chat.LoadBookmarkPort; +import makeus.cmc.malmo.application.port.out.chat.LoadMessagesPort; +import makeus.cmc.malmo.domain.model.chat.Bookmark; +import makeus.cmc.malmo.domain.model.chat.ChatMessage; +import makeus.cmc.malmo.domain.value.id.BookmarkId; +import makeus.cmc.malmo.domain.value.id.ChatRoomId; +import makeus.cmc.malmo.domain.value.id.MemberId; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.stereotype.Component; + +import java.util.List; + +@Component +@RequiredArgsConstructor +public class BookmarkQueryHelper { + + private final LoadBookmarkPort loadBookmarkPort; + private final LoadMessagesPort loadMessagesPort; + + public Bookmark getBookmarkByIdOrThrow(BookmarkId bookmarkId) { + return loadBookmarkPort.loadBookmarkById(bookmarkId) + .orElseThrow(BookmarkNotFoundException::new); + } + + public boolean existsByMemberAndMessage(MemberId memberId, Long chatMessageId) { + return loadBookmarkPort.existsByMemberAndMessage(memberId, chatMessageId); + } + + public Page getBookmarksByMemberAndChatRoom( + MemberId memberId, ChatRoomId chatRoomId, Pageable pageable) { + return loadBookmarkPort.loadBookmarksByMemberAndChatRoom(memberId, chatRoomId, pageable); + } + + public void validateBookmarksOwnership(MemberId memberId, List bookmarkIds) { + boolean valid = loadBookmarkPort.isMemberOwnerOfBookmarks(memberId, bookmarkIds); + if (!valid) { + throw new MemberAccessDeniedException("북마크에 접근할 권한이 없습니다."); + } + } + + public int calculatePageForMessage(ChatRoomId chatRoomId, Long messageId, int pageSize, String sort) { + long position = loadBookmarkPort.countMessagesBeforeId(chatRoomId, messageId, sort); + return (int) (position / pageSize); + } + + public ChatMessage getMessageByIdOrThrow(Long messageId) { + return loadMessagesPort.loadMessageById(messageId) + .orElseThrow(MessageNotFoundException::new); + } + + public ChatMessage validateMessageInChatRoom(Long messageId, ChatRoomId chatRoomId) { + ChatMessage message = loadMessagesPort.loadMessageById(messageId) + .orElseThrow(MessageNotFoundException::new); + + if (!message.getChatRoomId().getValue().equals(chatRoomId.getValue())) { + throw new MessageNotFoundException("해당 채팅방에 존재하지 않는 메시지입니다."); + } + + return message; + } +} diff --git a/src/main/java/makeus/cmc/malmo/application/helper/chat_room/ChatRoomQueryHelper.java b/src/main/java/makeus/cmc/malmo/application/helper/chat_room/ChatRoomQueryHelper.java index b4967c00..6106a126 100644 --- a/src/main/java/makeus/cmc/malmo/application/helper/chat_room/ChatRoomQueryHelper.java +++ b/src/main/java/makeus/cmc/malmo/application/helper/chat_room/ChatRoomQueryHelper.java @@ -94,12 +94,12 @@ public void validateChatRoomAlive(MemberId memberId) { 채팅방 메시지 Query Methods */ - public Page getChatMessagesDtoDesc(ChatRoomId chatRoomId, Pageable pageable) { - return loadMessagesPort.loadMessagesDto(chatRoomId, pageable); + public Page getChatMessagesDtoDesc(ChatRoomId chatRoomId, MemberId memberId, Pageable pageable) { + return loadMessagesPort.loadMessagesDto(chatRoomId, memberId, pageable); } - public Page getChatMessagesDtoAsc(ChatRoomId chatRoomId, Pageable pageable) { - return loadMessagesPort.loadMessagesDtoAsc(chatRoomId, pageable); + public Page getChatMessagesDtoAsc(ChatRoomId chatRoomId, MemberId memberId, Pageable pageable) { + return loadMessagesPort.loadMessagesDtoAsc(chatRoomId, memberId, pageable); } public List getSummarizedMessages(ChatRoomId chatRoomId) { diff --git a/src/main/java/makeus/cmc/malmo/application/port/in/chat/CreateBookmarkUseCase.java b/src/main/java/makeus/cmc/malmo/application/port/in/chat/CreateBookmarkUseCase.java new file mode 100644 index 00000000..edf0089f --- /dev/null +++ b/src/main/java/makeus/cmc/malmo/application/port/in/chat/CreateBookmarkUseCase.java @@ -0,0 +1,29 @@ +package makeus.cmc.malmo.application.port.in.chat; + +import lombok.Builder; +import lombok.Data; +import makeus.cmc.malmo.domain.value.type.SenderType; + +import java.time.LocalDateTime; + +public interface CreateBookmarkUseCase { + + CreateBookmarkResponse createBookmark(CreateBookmarkCommand command); + + @Data + @Builder + class CreateBookmarkCommand { + private Long userId; + private Long chatRoomId; + private Long messageId; + } + + @Data + @Builder + class CreateBookmarkResponse { + private Long bookmarkId; + private String content; + private SenderType type; + private LocalDateTime timestamp; + } +} diff --git a/src/main/java/makeus/cmc/malmo/application/port/in/chat/DeleteBookmarksUseCase.java b/src/main/java/makeus/cmc/malmo/application/port/in/chat/DeleteBookmarksUseCase.java new file mode 100644 index 00000000..bb757adc --- /dev/null +++ b/src/main/java/makeus/cmc/malmo/application/port/in/chat/DeleteBookmarksUseCase.java @@ -0,0 +1,19 @@ +package makeus.cmc.malmo.application.port.in.chat; + +import lombok.Builder; +import lombok.Data; + +import java.util.List; + +public interface DeleteBookmarksUseCase { + + void deleteBookmarks(DeleteBookmarksCommand command); + + @Data + @Builder + class DeleteBookmarksCommand { + private Long userId; + private Long chatRoomId; + private List bookmarkIdList; + } +} diff --git a/src/main/java/makeus/cmc/malmo/application/port/in/chat/GetBookmarkListUseCase.java b/src/main/java/makeus/cmc/malmo/application/port/in/chat/GetBookmarkListUseCase.java new file mode 100644 index 00000000..026b78cc --- /dev/null +++ b/src/main/java/makeus/cmc/malmo/application/port/in/chat/GetBookmarkListUseCase.java @@ -0,0 +1,38 @@ +package makeus.cmc.malmo.application.port.in.chat; + +import lombok.Builder; +import lombok.Data; +import makeus.cmc.malmo.domain.value.type.SenderType; +import org.springframework.data.domain.Pageable; + +import java.time.LocalDateTime; +import java.util.List; + +public interface GetBookmarkListUseCase { + + GetBookmarkListResponse getBookmarkList(GetBookmarkListCommand command); + + @Data + @Builder + class GetBookmarkListCommand { + private Long userId; + private Long chatRoomId; + private Pageable pageable; + } + + @Data + @Builder + class GetBookmarkListResponse { + private List bookmarkList; + private Long totalCount; + } + + @Data + @Builder + class BookmarkDto { + private Long bookmarkId; + private String content; + private SenderType type; + private LocalDateTime timestamp; + } +} diff --git a/src/main/java/makeus/cmc/malmo/application/port/in/chat/GetMessagesByBookmarkUseCase.java b/src/main/java/makeus/cmc/malmo/application/port/in/chat/GetMessagesByBookmarkUseCase.java new file mode 100644 index 00000000..a650ded2 --- /dev/null +++ b/src/main/java/makeus/cmc/malmo/application/port/in/chat/GetMessagesByBookmarkUseCase.java @@ -0,0 +1,43 @@ +package makeus.cmc.malmo.application.port.in.chat; + +import com.fasterxml.jackson.annotation.JsonProperty; +import lombok.Builder; +import lombok.Data; +import makeus.cmc.malmo.domain.value.type.SenderType; + +import java.time.LocalDateTime; +import java.util.List; + +public interface GetMessagesByBookmarkUseCase { + + GetMessagesByBookmarkResponse getMessagesByBookmark(GetMessagesByBookmarkCommand command); + + @Data + @Builder + class GetMessagesByBookmarkCommand { + private Long userId; + private Long bookmarkId; + private int size; + private String sort; + } + + @Data + @Builder + class GetMessagesByBookmarkResponse { + private Long targetMessageId; + private int size; + private int page; + private List messages; + } + + @Data + @Builder + class MessageDto { + private Long messageId; + private String content; + private SenderType senderType; + private LocalDateTime createdAt; + @JsonProperty("isSaved") + private boolean isSaved; + } +} diff --git a/src/main/java/makeus/cmc/malmo/application/port/out/chat/DeleteBookmarkPort.java b/src/main/java/makeus/cmc/malmo/application/port/out/chat/DeleteBookmarkPort.java new file mode 100644 index 00000000..3605dbf3 --- /dev/null +++ b/src/main/java/makeus/cmc/malmo/application/port/out/chat/DeleteBookmarkPort.java @@ -0,0 +1,10 @@ +package makeus.cmc.malmo.application.port.out.chat; + +import makeus.cmc.malmo.domain.value.id.BookmarkId; + +import java.util.List; + +public interface DeleteBookmarkPort { + + void softDeleteBookmarks(List bookmarkIds); +} diff --git a/src/main/java/makeus/cmc/malmo/application/port/out/chat/LoadBookmarkPort.java b/src/main/java/makeus/cmc/malmo/application/port/out/chat/LoadBookmarkPort.java new file mode 100644 index 00000000..97f23197 --- /dev/null +++ b/src/main/java/makeus/cmc/malmo/application/port/out/chat/LoadBookmarkPort.java @@ -0,0 +1,40 @@ +package makeus.cmc.malmo.application.port.out.chat; + +import lombok.AllArgsConstructor; +import lombok.Data; +import makeus.cmc.malmo.domain.model.chat.Bookmark; +import makeus.cmc.malmo.domain.value.id.BookmarkId; +import makeus.cmc.malmo.domain.value.id.ChatRoomId; +import makeus.cmc.malmo.domain.value.id.MemberId; +import makeus.cmc.malmo.domain.value.type.SenderType; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; + +import java.time.LocalDateTime; +import java.util.List; +import java.util.Optional; + +public interface LoadBookmarkPort { + + Optional loadBookmarkById(BookmarkId bookmarkId); + + Optional loadBookmarkByMemberAndMessage(MemberId memberId, Long chatMessageId); + + Page loadBookmarksByMemberAndChatRoom(MemberId memberId, ChatRoomId chatRoomId, Pageable pageable); + + boolean existsByMemberAndMessage(MemberId memberId, Long chatMessageId); + + boolean isMemberOwnerOfBookmarks(MemberId memberId, List bookmarkIds); + + long countMessagesBeforeId(ChatRoomId chatRoomId, Long messageId, String sort); + + @Data + @AllArgsConstructor + class BookmarkDto { + private Long bookmarkId; + private Long chatMessageId; + private String content; + private SenderType senderType; + private LocalDateTime createdAt; + } +} diff --git a/src/main/java/makeus/cmc/malmo/application/port/out/chat/LoadMessagesPort.java b/src/main/java/makeus/cmc/malmo/application/port/out/chat/LoadMessagesPort.java index 31a170fe..1fdd91b0 100644 --- a/src/main/java/makeus/cmc/malmo/application/port/out/chat/LoadMessagesPort.java +++ b/src/main/java/makeus/cmc/malmo/application/port/out/chat/LoadMessagesPort.java @@ -4,16 +4,19 @@ import lombok.Data; import makeus.cmc.malmo.domain.model.chat.ChatMessage; import makeus.cmc.malmo.domain.value.id.ChatRoomId; +import makeus.cmc.malmo.domain.value.id.MemberId; import makeus.cmc.malmo.domain.value.type.SenderType; import org.springframework.data.domain.Page; import org.springframework.data.domain.Pageable; import java.time.LocalDateTime; import java.util.List; +import java.util.Optional; public interface LoadMessagesPort { - Page loadMessagesDto(ChatRoomId chatRoomId, Pageable pageable); - Page loadMessagesDtoAsc(ChatRoomId chatRoomId, Pageable pageable); + Optional loadMessageById(Long messageId); + Page loadMessagesDto(ChatRoomId chatRoomId, MemberId memberId, Pageable pageable); + Page loadMessagesDtoAsc(ChatRoomId chatRoomId, MemberId memberId, Pageable pageable); List loadChatRoomMessagesByLevel(ChatRoomId chatRoomId, int level); List loadChatRoomLevelAndDetailedLevelMessages(ChatRoomId chatRoomId, int level, int detailedLevel); diff --git a/src/main/java/makeus/cmc/malmo/application/port/out/chat/SaveBookmarkPort.java b/src/main/java/makeus/cmc/malmo/application/port/out/chat/SaveBookmarkPort.java new file mode 100644 index 00000000..be618faa --- /dev/null +++ b/src/main/java/makeus/cmc/malmo/application/port/out/chat/SaveBookmarkPort.java @@ -0,0 +1,8 @@ +package makeus.cmc.malmo.application.port.out.chat; + +import makeus.cmc.malmo.domain.model.chat.Bookmark; + +public interface SaveBookmarkPort { + + Bookmark saveBookmark(Bookmark bookmark); +} diff --git a/src/main/java/makeus/cmc/malmo/application/service/chat/BookmarkService.java b/src/main/java/makeus/cmc/malmo/application/service/chat/BookmarkService.java new file mode 100644 index 00000000..9d6a1229 --- /dev/null +++ b/src/main/java/makeus/cmc/malmo/application/service/chat/BookmarkService.java @@ -0,0 +1,167 @@ +package makeus.cmc.malmo.application.service.chat; + +import lombok.RequiredArgsConstructor; +import makeus.cmc.malmo.adaptor.in.aop.CheckValidMember; +import makeus.cmc.malmo.application.exception.BookmarkAlreadyExistsException; +import makeus.cmc.malmo.application.exception.MemberAccessDeniedException; +import makeus.cmc.malmo.application.helper.chat_room.BookmarkCommandHelper; +import makeus.cmc.malmo.application.helper.chat_room.BookmarkQueryHelper; +import makeus.cmc.malmo.application.helper.chat_room.ChatRoomQueryHelper; +import makeus.cmc.malmo.application.port.in.chat.CreateBookmarkUseCase; +import makeus.cmc.malmo.application.port.in.chat.DeleteBookmarksUseCase; +import makeus.cmc.malmo.application.port.in.chat.GetBookmarkListUseCase; +import makeus.cmc.malmo.application.port.in.chat.GetMessagesByBookmarkUseCase; +import makeus.cmc.malmo.application.port.out.chat.LoadBookmarkPort; +import makeus.cmc.malmo.application.port.out.chat.LoadMessagesPort; +import makeus.cmc.malmo.domain.model.chat.Bookmark; +import makeus.cmc.malmo.domain.model.chat.ChatMessage; +import makeus.cmc.malmo.domain.value.id.BookmarkId; +import makeus.cmc.malmo.domain.value.id.ChatRoomId; +import makeus.cmc.malmo.domain.value.id.MemberId; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Pageable; +import org.springframework.data.domain.Sort; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.List; + +@Service +@RequiredArgsConstructor +public class BookmarkService implements CreateBookmarkUseCase, DeleteBookmarksUseCase, + GetBookmarkListUseCase, GetMessagesByBookmarkUseCase { + + private final BookmarkQueryHelper bookmarkQueryHelper; + private final BookmarkCommandHelper bookmarkCommandHelper; + private final ChatRoomQueryHelper chatRoomQueryHelper; + + @Override + @CheckValidMember + @Transactional + public CreateBookmarkResponse createBookmark(CreateBookmarkCommand command) { + MemberId memberId = MemberId.of(command.getUserId()); + ChatRoomId chatRoomId = ChatRoomId.of(command.getChatRoomId()); + + // 1. 채팅방 소유권 검증 + chatRoomQueryHelper.validateChatRoomOwnership(memberId, chatRoomId); + + // 2. 이미 북마크가 존재하는지 확인 + if (bookmarkQueryHelper.existsByMemberAndMessage(memberId, command.getMessageId())) { + throw new BookmarkAlreadyExistsException(); + } + + // 3. 메시지가 해당 채팅방에 존재하는지 검증 + ChatMessage message = bookmarkQueryHelper.validateMessageInChatRoom( + command.getMessageId(), chatRoomId); + + // 4. 북마크 생성 및 저장 + Bookmark bookmark = Bookmark.create(chatRoomId, command.getMessageId(), memberId); + Bookmark savedBookmark = bookmarkCommandHelper.saveBookmark(bookmark); + + return CreateBookmarkResponse.builder() + .bookmarkId(savedBookmark.getId()) + .content(message.getContent()) + .type(message.getSenderType()) + .timestamp(message.getCreatedAt()) + .build(); + } + + @Override + @CheckValidMember + @Transactional + public void deleteBookmarks(DeleteBookmarksCommand command) { + MemberId memberId = MemberId.of(command.getUserId()); + ChatRoomId chatRoomId = ChatRoomId.of(command.getChatRoomId()); + + // 1. 채팅방 소유권 검증 + chatRoomQueryHelper.validateChatRoomOwnership(memberId, chatRoomId); + + // 2. 북마크 소유권 검증 + List bookmarkIds = command.getBookmarkIdList().stream() + .map(BookmarkId::of) + .toList(); + bookmarkQueryHelper.validateBookmarksOwnership(memberId, bookmarkIds); + + // 3. 북마크 soft delete + bookmarkCommandHelper.softDeleteBookmarks(bookmarkIds); + } + + @Override + @CheckValidMember + public GetBookmarkListResponse getBookmarkList(GetBookmarkListCommand command) { + MemberId memberId = MemberId.of(command.getUserId()); + ChatRoomId chatRoomId = ChatRoomId.of(command.getChatRoomId()); + + // 1. 채팅방 소유권 검증 + chatRoomQueryHelper.validateChatRoomOwnership(memberId, chatRoomId); + + // 2. 페이지네이션된 북마크 조회 + Page bookmarks = bookmarkQueryHelper.getBookmarksByMemberAndChatRoom( + memberId, chatRoomId, command.getPageable()); + + List dtos = bookmarks.getContent().stream() + .map(b -> BookmarkDto.builder() + .bookmarkId(b.getBookmarkId()) + .content(b.getContent()) + .type(b.getSenderType()) + .timestamp(b.getCreatedAt()) + .build()) + .toList(); + + return GetBookmarkListResponse.builder() + .bookmarkList(dtos) + .totalCount(bookmarks.getTotalElements()) + .build(); + } + + @Override + @CheckValidMember + public GetMessagesByBookmarkResponse getMessagesByBookmark(GetMessagesByBookmarkCommand command) { + MemberId memberId = MemberId.of(command.getUserId()); + + // 1. 북마크 조회 및 검증 + Bookmark bookmark = bookmarkQueryHelper.getBookmarkByIdOrThrow( + BookmarkId.of(command.getBookmarkId())); + + // 2. 북마크 소유권 검증 + if (!bookmark.getMemberId().getValue().equals(command.getUserId())) { + throw new MemberAccessDeniedException("북마크에 접근할 권한이 없습니다."); + } + + // 3. 북마크된 메시지가 포함된 페이지 계산 + int page = bookmarkQueryHelper.calculatePageForMessage( + bookmark.getChatRoomId(), + bookmark.getChatMessageId(), + command.getSize(), + command.getSort()); + + // 4. 해당 페이지의 메시지 조회 + Sort sort = "ASC".equalsIgnoreCase(command.getSort()) + ? Sort.by("createdAt").ascending() + : Sort.by("createdAt").descending(); + Pageable pageable = PageRequest.of(page, command.getSize(), sort); + + Page messages = + "ASC".equalsIgnoreCase(command.getSort()) + ? chatRoomQueryHelper.getChatMessagesDtoAsc(bookmark.getChatRoomId(), memberId, pageable) + : chatRoomQueryHelper.getChatMessagesDtoDesc(bookmark.getChatRoomId(), memberId, pageable); + + List messageDtos = messages.getContent().stream() + .map(m -> MessageDto.builder() + .messageId(m.getMessageId()) + .content(m.getContent()) + .senderType(m.getSenderType()) + .createdAt(m.getCreatedAt()) + .isSaved(m.isSaved()) + .build()) + .toList(); + + return GetMessagesByBookmarkResponse.builder() + .targetMessageId(bookmark.getChatMessageId()) + .size(command.getSize()) + .page(page) + .messages(messageDtos) + .build(); + } +} diff --git a/src/main/java/makeus/cmc/malmo/application/service/chat/ChatRoomService.java b/src/main/java/makeus/cmc/malmo/application/service/chat/ChatRoomService.java index 90fa5995..26427177 100644 --- a/src/main/java/makeus/cmc/malmo/application/service/chat/ChatRoomService.java +++ b/src/main/java/makeus/cmc/malmo/application/service/chat/ChatRoomService.java @@ -77,10 +77,11 @@ public GetChatRoomListResponse getChatRoomList(GetChatRoomListCommand command) { @Override @CheckValidMember public GetCurrentChatRoomMessagesResponse getChatRoomMessages(GetChatRoomMessagesCommand command) { - chatRoomQueryHelper.validateChatRoomOwnership(MemberId.of(command.getUserId()), ChatRoomId.of(command.getChatRoomId())); + MemberId memberId = MemberId.of(command.getUserId()); + chatRoomQueryHelper.validateChatRoomOwnership(memberId, ChatRoomId.of(command.getChatRoomId())); Page result = - chatRoomQueryHelper.getChatMessagesDtoAsc(ChatRoomId.of(command.getChatRoomId()), command.getPageable()); + chatRoomQueryHelper.getChatMessagesDtoAsc(ChatRoomId.of(command.getChatRoomId()), memberId, command.getPageable()); List list = result.stream().map(cm -> GetChatRoomMessagesUseCase.ChatRoomMessageDto.builder() diff --git a/src/main/java/makeus/cmc/malmo/application/service/chat/CurrentChatRoomService.java b/src/main/java/makeus/cmc/malmo/application/service/chat/CurrentChatRoomService.java index f17891c2..d4df9b5a 100644 --- a/src/main/java/makeus/cmc/malmo/application/service/chat/CurrentChatRoomService.java +++ b/src/main/java/makeus/cmc/malmo/application/service/chat/CurrentChatRoomService.java @@ -94,10 +94,11 @@ private ChatRoom createAndSaveNewChatRoom(MemberId memberId) { @CheckValidMember public GetCurrentChatRoomMessagesResponse getCurrentChatRoomMessages(GetCurrentChatRoomMessagesCommand command) { // 현재 채팅방 가져오기 - ChatRoom currentChatRoom = chatRoomQueryHelper.getCurrentChatRoomByMemberIdOrThrow(MemberId.of(command.getUserId())); + MemberId memberId = MemberId.of(command.getUserId()); + ChatRoom currentChatRoom = chatRoomQueryHelper.getCurrentChatRoomByMemberIdOrThrow(memberId); Page result = - chatRoomQueryHelper.getChatMessagesDtoDesc(ChatRoomId.of(currentChatRoom.getId()), command.getPageable()); + chatRoomQueryHelper.getChatMessagesDtoDesc(ChatRoomId.of(currentChatRoom.getId()), memberId, command.getPageable()); List list = result.stream().map(cm -> ChatRoomMessageDto.builder() diff --git a/src/main/java/makeus/cmc/malmo/domain/model/chat/Bookmark.java b/src/main/java/makeus/cmc/malmo/domain/model/chat/Bookmark.java new file mode 100644 index 00000000..aa3f61b3 --- /dev/null +++ b/src/main/java/makeus/cmc/malmo/domain/model/chat/Bookmark.java @@ -0,0 +1,55 @@ +package makeus.cmc.malmo.domain.model.chat; + +import lombok.AccessLevel; +import lombok.Builder; +import lombok.Getter; +import makeus.cmc.malmo.domain.value.id.ChatRoomId; +import makeus.cmc.malmo.domain.value.id.MemberId; +import makeus.cmc.malmo.domain.value.state.BookmarkState; + +import java.time.LocalDateTime; + +@Getter +@Builder(access = AccessLevel.PRIVATE) +public class Bookmark { + private Long id; + private ChatRoomId chatRoomId; + private Long chatMessageId; + private MemberId memberId; + private BookmarkState bookmarkState; + + // BaseTimeEntity fields + private LocalDateTime createdAt; + private LocalDateTime modifiedAt; + private LocalDateTime deletedAt; + + public static Bookmark create(ChatRoomId chatRoomId, Long chatMessageId, MemberId memberId) { + return Bookmark.builder() + .chatRoomId(chatRoomId) + .chatMessageId(chatMessageId) + .memberId(memberId) + .bookmarkState(BookmarkState.ALIVE) + .build(); + } + + public static Bookmark from(Long id, ChatRoomId chatRoomId, Long chatMessageId, + MemberId memberId, BookmarkState bookmarkState, + LocalDateTime createdAt, LocalDateTime modifiedAt, + LocalDateTime deletedAt) { + return Bookmark.builder() + .id(id) + .chatRoomId(chatRoomId) + .chatMessageId(chatMessageId) + .memberId(memberId) + .bookmarkState(bookmarkState) + .createdAt(createdAt) + .modifiedAt(modifiedAt) + .deletedAt(deletedAt) + .build(); + } + + public void softDelete() { + this.bookmarkState = BookmarkState.DELETED; + this.deletedAt = LocalDateTime.now(); + } +} diff --git a/src/main/java/makeus/cmc/malmo/domain/value/id/BookmarkId.java b/src/main/java/makeus/cmc/malmo/domain/value/id/BookmarkId.java new file mode 100644 index 00000000..8fc6239a --- /dev/null +++ b/src/main/java/makeus/cmc/malmo/domain/value/id/BookmarkId.java @@ -0,0 +1,12 @@ +package makeus.cmc.malmo.domain.value.id; + +import lombok.Value; + +@Value +public class BookmarkId { + Long value; + + public static BookmarkId of(Long value) { + return new BookmarkId(value); + } +} diff --git a/src/main/java/makeus/cmc/malmo/domain/value/state/BookmarkState.java b/src/main/java/makeus/cmc/malmo/domain/value/state/BookmarkState.java new file mode 100644 index 00000000..d645fac2 --- /dev/null +++ b/src/main/java/makeus/cmc/malmo/domain/value/state/BookmarkState.java @@ -0,0 +1,6 @@ +package makeus.cmc.malmo.domain.value.state; + +public enum BookmarkState { + ALIVE, + DELETED +} diff --git a/src/test/java/makeus/cmc/malmo/integration_test/BookmarkIntegrationTest.java b/src/test/java/makeus/cmc/malmo/integration_test/BookmarkIntegrationTest.java new file mode 100644 index 00000000..5863866f --- /dev/null +++ b/src/test/java/makeus/cmc/malmo/integration_test/BookmarkIntegrationTest.java @@ -0,0 +1,502 @@ +package makeus.cmc.malmo.integration_test; + +import com.fasterxml.jackson.databind.ObjectMapper; +import jakarta.persistence.EntityManager; +import makeus.cmc.malmo.adaptor.out.persistence.entity.chat.BookmarkEntity; +import makeus.cmc.malmo.adaptor.out.persistence.entity.chat.ChatMessageEntity; +import makeus.cmc.malmo.adaptor.out.persistence.entity.chat.ChatRoomEntity; +import makeus.cmc.malmo.adaptor.out.persistence.entity.member.MemberEntity; +import makeus.cmc.malmo.adaptor.out.persistence.entity.value.ChatMessageEntityId; +import makeus.cmc.malmo.adaptor.out.persistence.entity.value.ChatRoomEntityId; +import makeus.cmc.malmo.adaptor.out.persistence.entity.value.InviteCodeEntityValue; +import makeus.cmc.malmo.adaptor.out.persistence.entity.value.MemberEntityId; +import makeus.cmc.malmo.application.port.out.member.GenerateTokenPort; +import makeus.cmc.malmo.domain.value.state.BookmarkState; +import makeus.cmc.malmo.domain.value.state.ChatRoomState; +import makeus.cmc.malmo.domain.value.state.MemberState; +import makeus.cmc.malmo.domain.value.type.MemberRole; +import makeus.cmc.malmo.domain.value.type.Provider; +import makeus.cmc.malmo.domain.value.type.SenderType; +import makeus.cmc.malmo.integration_test.dto_factory.BookmarkRequestDtoFactory; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.http.MediaType; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.transaction.annotation.Transactional; + +import java.time.LocalDate; +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) +@AutoConfigureMockMvc +@Transactional +public class BookmarkIntegrationTest { + + @Autowired + private MockMvc mockMvc; + + @Autowired + private ObjectMapper objectMapper; + + @Autowired + private EntityManager em; + + @Autowired + private GenerateTokenPort generateTokenPort; + + private String accessToken; + private String otherAccessToken; + + private MemberEntity member; + private MemberEntity otherMember; + private ChatRoomEntity chatRoom; + private ChatMessageEntity chatMessage; + private ChatMessageEntity chatMessage2; + + @BeforeEach + void setup() { + member = createAndSaveMember("testUser", "test@email.com", "invite1"); + otherMember = createAndSaveMember("otherUser", "other@email.com", "invite2"); + + chatRoom = ChatRoomEntity.builder() + .memberEntityId(MemberEntityId.of(member.getId())) + .chatRoomState(ChatRoomState.COMPLETED) + .level(1) + .detailedLevel(1) + .build(); + em.persist(chatRoom); + + chatMessage = ChatMessageEntity.builder() + .chatRoomEntityId(ChatRoomEntityId.of(chatRoom.getId())) + .content("테스트 메시지 1") + .senderType(SenderType.USER) + .level(1) + .detailedLevel(1) + .build(); + em.persist(chatMessage); + + chatMessage2 = ChatMessageEntity.builder() + .chatRoomEntityId(ChatRoomEntityId.of(chatRoom.getId())) + .content("테스트 메시지 2") + .senderType(SenderType.ASSISTANT) + .level(1) + .detailedLevel(1) + .build(); + em.persist(chatMessage2); + + em.flush(); + + accessToken = generateTokenPort.generateToken(member.getId(), member.getMemberRole()).getAccessToken(); + otherAccessToken = generateTokenPort.generateToken(otherMember.getId(), otherMember.getMemberRole()).getAccessToken(); + } + + @Nested + @DisplayName("북마크 생성") + class CreateBookmark { + + @Test + @DisplayName("북마크 생성에 성공한다") + void 북마크_생성_성공() throws Exception { + BookmarkRequestDtoFactory.CreateBookmarkRequest request = + BookmarkRequestDtoFactory.createBookmarkRequest(chatMessage.getId()); + + mockMvc.perform(post("/chatrooms/{chatRoomId}/bookmarks", chatRoom.getId()) + .header("Authorization", "Bearer " + accessToken) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.success").value(true)) + .andExpect(jsonPath("$.data.bookmarkId").exists()) + .andExpect(jsonPath("$.data.content").value("테스트 메시지 1")) + .andExpect(jsonPath("$.data.type").value("USER")); + } + + @Test + @DisplayName("이미 북마크된 메시지에 대해 북마크 생성에 실패한다") + void 이미_북마크된_메시지_북마크_생성_실패() throws Exception { + // Given: 이미 북마크 존재 + BookmarkEntity existingBookmark = BookmarkEntity.builder() + .chatRoomEntityId(ChatRoomEntityId.of(chatRoom.getId())) + .chatMessageEntityId(ChatMessageEntityId.of(chatMessage.getId())) + .memberEntityId(MemberEntityId.of(member.getId())) + .bookmarkState(BookmarkState.ALIVE) + .build(); + em.persist(existingBookmark); + em.flush(); + + BookmarkRequestDtoFactory.CreateBookmarkRequest request = + BookmarkRequestDtoFactory.createBookmarkRequest(chatMessage.getId()); + + mockMvc.perform(post("/chatrooms/{chatRoomId}/bookmarks", chatRoom.getId()) + .header("Authorization", "Bearer " + accessToken) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.code").value(40015)); // BOOKMARK_ALREADY_EXISTS + } + + @Test + @DisplayName("다른 사용자의 채팅방에 대해 북마크 생성에 실패한다") + void 다른_사용자_채팅방_북마크_생성_실패() throws Exception { + BookmarkRequestDtoFactory.CreateBookmarkRequest request = + BookmarkRequestDtoFactory.createBookmarkRequest(chatMessage.getId()); + + mockMvc.perform(post("/chatrooms/{chatRoomId}/bookmarks", chatRoom.getId()) + .header("Authorization", "Bearer " + otherAccessToken) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))) + .andExpect(status().isForbidden()); + } + + @Test + @DisplayName("존재하지 않는 메시지에 대해 북마크 생성에 실패한다") + void 존재하지_않는_메시지_북마크_생성_실패() throws Exception { + BookmarkRequestDtoFactory.CreateBookmarkRequest request = + BookmarkRequestDtoFactory.createBookmarkRequest(999999L); + + mockMvc.perform(post("/chatrooms/{chatRoomId}/bookmarks", chatRoom.getId()) + .header("Authorization", "Bearer " + accessToken) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.code").value(40016)); // NO_SUCH_MESSAGE + } + } + + @Nested + @DisplayName("북마크 삭제") + class DeleteBookmarks { + + @Test + @DisplayName("북마크 단건 삭제에 성공한다") + void 북마크_단건_삭제_성공() throws Exception { + // Given + BookmarkEntity bookmark = BookmarkEntity.builder() + .chatRoomEntityId(ChatRoomEntityId.of(chatRoom.getId())) + .chatMessageEntityId(ChatMessageEntityId.of(chatMessage.getId())) + .memberEntityId(MemberEntityId.of(member.getId())) + .bookmarkState(BookmarkState.ALIVE) + .build(); + em.persist(bookmark); + em.flush(); + em.clear(); + + BookmarkRequestDtoFactory.DeleteBookmarksRequest request = + BookmarkRequestDtoFactory.deleteBookmarksRequest(List.of(bookmark.getId())); + + mockMvc.perform(delete("/chatrooms/{chatRoomId}/bookmarks", chatRoom.getId()) + .header("Authorization", "Bearer " + accessToken) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.success").value(true)); + + BookmarkEntity deletedBookmark = em.find(BookmarkEntity.class, bookmark.getId()); + assertThat(deletedBookmark.getBookmarkState()).isEqualTo(BookmarkState.DELETED); + } + + @Test + @DisplayName("북마크 다건 삭제에 성공한다") + void 북마크_다건_삭제_성공() throws Exception { + // Given + BookmarkEntity bookmark1 = BookmarkEntity.builder() + .chatRoomEntityId(ChatRoomEntityId.of(chatRoom.getId())) + .chatMessageEntityId(ChatMessageEntityId.of(chatMessage.getId())) + .memberEntityId(MemberEntityId.of(member.getId())) + .bookmarkState(BookmarkState.ALIVE) + .build(); + BookmarkEntity bookmark2 = BookmarkEntity.builder() + .chatRoomEntityId(ChatRoomEntityId.of(chatRoom.getId())) + .chatMessageEntityId(ChatMessageEntityId.of(chatMessage2.getId())) + .memberEntityId(MemberEntityId.of(member.getId())) + .bookmarkState(BookmarkState.ALIVE) + .build(); + em.persist(bookmark1); + em.persist(bookmark2); + em.flush(); + em.clear(); + + BookmarkRequestDtoFactory.DeleteBookmarksRequest request = + BookmarkRequestDtoFactory.deleteBookmarksRequest( + List.of(bookmark1.getId(), bookmark2.getId())); + + mockMvc.perform(delete("/chatrooms/{chatRoomId}/bookmarks", chatRoom.getId()) + .header("Authorization", "Bearer " + accessToken) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))) + .andExpect(status().isOk()); + + assertThat(em.find(BookmarkEntity.class, bookmark1.getId()).getBookmarkState()) + .isEqualTo(BookmarkState.DELETED); + assertThat(em.find(BookmarkEntity.class, bookmark2.getId()).getBookmarkState()) + .isEqualTo(BookmarkState.DELETED); + } + + @Test + @DisplayName("다른 사용자의 북마크 삭제에 실패한다") + void 다른_사용자_북마크_삭제_실패() throws Exception { + // Given: member's bookmark + BookmarkEntity bookmark = BookmarkEntity.builder() + .chatRoomEntityId(ChatRoomEntityId.of(chatRoom.getId())) + .chatMessageEntityId(ChatMessageEntityId.of(chatMessage.getId())) + .memberEntityId(MemberEntityId.of(member.getId())) + .bookmarkState(BookmarkState.ALIVE) + .build(); + em.persist(bookmark); + em.flush(); + + BookmarkRequestDtoFactory.DeleteBookmarksRequest request = + BookmarkRequestDtoFactory.deleteBookmarksRequest(List.of(bookmark.getId())); + + // otherMember tries to delete member's bookmark + mockMvc.perform(delete("/chatrooms/{chatRoomId}/bookmarks", chatRoom.getId()) + .header("Authorization", "Bearer " + otherAccessToken) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))) + .andExpect(status().isForbidden()); + } + } + + @Nested + @DisplayName("북마크 리스트 조회") + class GetBookmarkList { + + @Test + @DisplayName("북마크 리스트 조회에 성공한다") + void 북마크_리스트_조회_성공() throws Exception { + // Given + em.persist(BookmarkEntity.builder() + .chatRoomEntityId(ChatRoomEntityId.of(chatRoom.getId())) + .chatMessageEntityId(ChatMessageEntityId.of(chatMessage.getId())) + .memberEntityId(MemberEntityId.of(member.getId())) + .bookmarkState(BookmarkState.ALIVE) + .build()); + em.persist(BookmarkEntity.builder() + .chatRoomEntityId(ChatRoomEntityId.of(chatRoom.getId())) + .chatMessageEntityId(ChatMessageEntityId.of(chatMessage2.getId())) + .memberEntityId(MemberEntityId.of(member.getId())) + .bookmarkState(BookmarkState.ALIVE) + .build()); + em.flush(); + + mockMvc.perform(get("/chatrooms/{chatRoomId}/bookmarks", chatRoom.getId()) + .header("Authorization", "Bearer " + accessToken) + .param("page", "0") + .param("size", "10")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.success").value(true)) + .andExpect(jsonPath("$.data.totalCount").value(2)) + .andExpect(jsonPath("$.data.list").isArray()) + .andExpect(jsonPath("$.data.list.length()").value(2)); + } + + @Test + @DisplayName("삭제된 북마크는 조회되지 않는다") + void 삭제된_북마크_미조회() throws Exception { + // Given + em.persist(BookmarkEntity.builder() + .chatRoomEntityId(ChatRoomEntityId.of(chatRoom.getId())) + .chatMessageEntityId(ChatMessageEntityId.of(chatMessage.getId())) + .memberEntityId(MemberEntityId.of(member.getId())) + .bookmarkState(BookmarkState.DELETED) // 삭제된 상태 + .build()); + em.flush(); + + mockMvc.perform(get("/chatrooms/{chatRoomId}/bookmarks", chatRoom.getId()) + .header("Authorization", "Bearer " + accessToken)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.totalCount").value(0)); + } + + @Test + @DisplayName("페이지네이션이 정상 동작한다") + void 페이지네이션_동작() throws Exception { + // Given: 15개의 북마크 생성 + for (int i = 0; i < 15; i++) { + ChatMessageEntity msg = ChatMessageEntity.builder() + .chatRoomEntityId(ChatRoomEntityId.of(chatRoom.getId())) + .content("메시지 " + i) + .senderType(SenderType.USER) + .level(1) + .detailedLevel(1) + .build(); + em.persist(msg); + em.flush(); + + em.persist(BookmarkEntity.builder() + .chatRoomEntityId(ChatRoomEntityId.of(chatRoom.getId())) + .chatMessageEntityId(ChatMessageEntityId.of(msg.getId())) + .memberEntityId(MemberEntityId.of(member.getId())) + .bookmarkState(BookmarkState.ALIVE) + .build()); + } + em.flush(); + + mockMvc.perform(get("/chatrooms/{chatRoomId}/bookmarks", chatRoom.getId()) + .header("Authorization", "Bearer " + accessToken) + .param("page", "0") + .param("size", "10")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.totalCount").value(15)) + .andExpect(jsonPath("$.data.list.length()").value(10)); + + mockMvc.perform(get("/chatrooms/{chatRoomId}/bookmarks", chatRoom.getId()) + .header("Authorization", "Bearer " + accessToken) + .param("page", "1") + .param("size", "10")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.list.length()").value(5)); + } + + @Test + @DisplayName("다른 사용자의 채팅방 북마크 조회에 실패한다") + void 다른_사용자_채팅방_북마크_조회_실패() throws Exception { + mockMvc.perform(get("/chatrooms/{chatRoomId}/bookmarks", chatRoom.getId()) + .header("Authorization", "Bearer " + otherAccessToken)) + .andExpect(status().isForbidden()); + } + } + + @Nested + @DisplayName("북마크 기반 메시지 조회") + class GetMessagesByBookmark { + + @Test + @DisplayName("북마크 기반 메시지 조회에 성공한다") + void 북마크_기반_메시지_조회_성공() throws Exception { + // Given + BookmarkEntity bookmark = BookmarkEntity.builder() + .chatRoomEntityId(ChatRoomEntityId.of(chatRoom.getId())) + .chatMessageEntityId(ChatMessageEntityId.of(chatMessage.getId())) + .memberEntityId(MemberEntityId.of(member.getId())) + .bookmarkState(BookmarkState.ALIVE) + .build(); + em.persist(bookmark); + em.flush(); + + mockMvc.perform(get("/chatrooms/{chatRoomId}/bookmarks/{bookmarkId}/messages", + chatRoom.getId(), bookmark.getId()) + .header("Authorization", "Bearer " + accessToken) + .param("size", "10") + .param("sort", "ASC")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.success").value(true)) + .andExpect(jsonPath("$.data.targetMessageId").value(chatMessage.getId())) + .andExpect(jsonPath("$.data.messages").isArray()); + } + + @Test + @DisplayName("다른 사용자의 북마크 기반 메시지 조회에 실패한다") + void 다른_사용자_북마크_기반_메시지_조회_실패() throws Exception { + // Given + BookmarkEntity bookmark = BookmarkEntity.builder() + .chatRoomEntityId(ChatRoomEntityId.of(chatRoom.getId())) + .chatMessageEntityId(ChatMessageEntityId.of(chatMessage.getId())) + .memberEntityId(MemberEntityId.of(member.getId())) + .bookmarkState(BookmarkState.ALIVE) + .build(); + em.persist(bookmark); + em.flush(); + + mockMvc.perform(get("/chatrooms/{chatRoomId}/bookmarks/{bookmarkId}/messages", + chatRoom.getId(), bookmark.getId()) + .header("Authorization", "Bearer " + otherAccessToken) + .param("size", "10") + .param("sort", "ASC")) + .andExpect(status().isForbidden()); + } + + @Test + @DisplayName("존재하지 않는 북마크 기반 메시지 조회에 실패한다") + void 존재하지_않는_북마크_메시지_조회_실패() throws Exception { + mockMvc.perform(get("/chatrooms/{chatRoomId}/bookmarks/{bookmarkId}/messages", + chatRoom.getId(), 999999L) + .header("Authorization", "Bearer " + accessToken) + .param("size", "10") + .param("sort", "ASC")) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.code").value(40014)); // NO_SUCH_BOOKMARK + } + } + + @Nested + @DisplayName("채팅 메시지 리스트 조회 시 북마크 여부") + class GetChatRoomMessagesWithBookmarkStatus { + + @Test + @DisplayName("북마크된 메시지의 isSaved가 true로 반환된다") + void 북마크된_메시지_isSaved_true() throws Exception { + // Given + em.persist(BookmarkEntity.builder() + .chatRoomEntityId(ChatRoomEntityId.of(chatRoom.getId())) + .chatMessageEntityId(ChatMessageEntityId.of(chatMessage.getId())) + .memberEntityId(MemberEntityId.of(member.getId())) + .bookmarkState(BookmarkState.ALIVE) + .build()); + em.flush(); + + mockMvc.perform(get("/chatrooms/{chatRoomId}/messages", chatRoom.getId()) + .header("Authorization", "Bearer " + accessToken) + .param("page", "0") + .param("size", "10")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.list[?(@.messageId == " + chatMessage.getId() + ")].isSaved").value(true)); + } + + @Test + @DisplayName("북마크되지 않은 메시지의 isSaved가 false로 반환된다") + void 북마크되지_않은_메시지_isSaved_false() throws Exception { + mockMvc.perform(get("/chatrooms/{chatRoomId}/messages", chatRoom.getId()) + .header("Authorization", "Bearer " + accessToken) + .param("page", "0") + .param("size", "10")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.list[0].isSaved").value(false)); + } + + @Test + @DisplayName("삭제된 북마크는 isSaved가 false로 반환된다") + void 삭제된_북마크_isSaved_false() throws Exception { + // Given: 삭제된 북마크 + em.persist(BookmarkEntity.builder() + .chatRoomEntityId(ChatRoomEntityId.of(chatRoom.getId())) + .chatMessageEntityId(ChatMessageEntityId.of(chatMessage.getId())) + .memberEntityId(MemberEntityId.of(member.getId())) + .bookmarkState(BookmarkState.DELETED) + .build()); + em.flush(); + + mockMvc.perform(get("/chatrooms/{chatRoomId}/messages", chatRoom.getId()) + .header("Authorization", "Bearer " + accessToken) + .param("page", "0") + .param("size", "10")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.list[0].isSaved").value(false)); + } + } + + private MemberEntity createAndSaveMember(String nickname, String email, String inviteCode) { + MemberEntity memberEntity = MemberEntity.builder() + .provider(Provider.KAKAO) + .providerId(email) + .memberRole(MemberRole.MEMBER) + .memberState(MemberState.ALIVE) + .startLoveDate(LocalDate.of(2023, 1, 1)) + .nickname(nickname) + .email(email) + .inviteCodeEntityValue(InviteCodeEntityValue.of(inviteCode)) + .build(); + em.persist(memberEntity); + return memberEntity; + } +} diff --git a/src/test/java/makeus/cmc/malmo/integration_test/dto_factory/BookmarkRequestDtoFactory.java b/src/test/java/makeus/cmc/malmo/integration_test/dto_factory/BookmarkRequestDtoFactory.java new file mode 100644 index 00000000..6a97498c --- /dev/null +++ b/src/test/java/makeus/cmc/malmo/integration_test/dto_factory/BookmarkRequestDtoFactory.java @@ -0,0 +1,18 @@ +package makeus.cmc.malmo.integration_test.dto_factory; + +import java.util.List; + +public class BookmarkRequestDtoFactory { + + public static CreateBookmarkRequest createBookmarkRequest(Long messageId) { + return new CreateBookmarkRequest(messageId); + } + + public static DeleteBookmarksRequest deleteBookmarksRequest(List bookmarkIds) { + return new DeleteBookmarksRequest(bookmarkIds); + } + + public record CreateBookmarkRequest(Long messageId) {} + + public record DeleteBookmarksRequest(List bookmarkIdList) {} +}