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
14 changes: 6 additions & 8 deletions src/main/java/com/dokdok/topic/api/TopicApi.java
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
package com.dokdok.topic.api;

import com.dokdok.global.response.ApiResponse;
import com.dokdok.global.response.CursorResponse;
import com.dokdok.topic.dto.request.ConfirmTopicsRequest;
import com.dokdok.topic.dto.request.SuggestTopicRequest;
import com.dokdok.topic.dto.response.ConfirmTopicsResponse;
Expand Down Expand Up @@ -161,12 +160,11 @@ ResponseEntity<ApiResponse<SuggestTopicResponse>> createTopic(
- 다음 페이지: `?pageSize=10&cursorLikeCount={nextCursor.likeCount}&cursorTopicId={nextCursor.topicId}`

**응답 구조**
- page: 주제 목록 페이지 정보
- items: 주제 목록
- pageSize: 페이지 크기
- hasNext: 다음 페이지 존재 여부
- nextCursor: 다음 페이지 요청 시 사용할 커서 (hasNext가 false면 null)
- totalCount : 전체 주제 수 (첫 요청 시만 포함, 이후 요청에서는 생략)
- items: 주제 목록
- pageSize: 페이지 크기
- hasNext: 다음 페이지 존재 여부
- nextCursor: 다음 페이지 요청 시 사용할 커서 (hasNext가 false면 null)
- totalCount : 전체 주제 수 (첫 요청 시만 포함, 이후 요청에서는 생략)
- actions: 현재 사용자의 권한 정보
- canConfirm: 주제 확정 가능 여부 (모임장과 약속장만 true)
- canSuggest: 주제 제안 가능 여부 (약속 멤버이고 약속 상태가 CONFIRMED일 때 true)
Expand All @@ -187,7 +185,7 @@ ResponseEntity<ApiResponse<SuggestTopicResponse>> createTopic(
mediaType = MediaType.APPLICATION_JSON_VALUE,
schema = @Schema(implementation = TopicsWithActionsResponse.class),
examples = @ExampleObject(value = """
{"code":"SUCCESS","message":"제안된 주제 조회를 성공했습니다.","data":{"page":{"items":[{"topicId":1,"meetingId":10,"title":"이 책의 핵심 메시지는 무엇인가?","description":"저자가 전달하고자 하는 핵심 메시지에 대해 토론합니다.","topicType":"DISCUSSION","topicTypeLabel":"토론형","topicStatus":"PROPOSED","likeCount":5,"canDelete":true,"createdByInfo":{"userId":1,"nickname":"독서왕"}}],"pageSize":10,"hasNext":true,"nextCursor":{"likeCount":5,"topicId":1},"totalCount":25},"actions":{"canConfirm":false,"canSuggest":true}}}
{"code":"SUCCESS","message":"제안된 주제 조회를 성공했습니다.","data":{"items":[{"topicId":1,"meetingId":10,"title":"이 책의 핵심 메시지는 무엇인가?","description":"저자가 전달하고자 하는 핵심 메시지에 대해 토론합니다.","topicType":"DISCUSSION","topicTypeLabel":"토론형","topicStatus":"PROPOSED","likeCount":5,"canDelete":true,"isLiked":false,"createdByInfo":{"userId":1,"nickname":"독서왕"}}],"pageSize":10,"hasNext":true,"nextCursor":{"likeCount":5,"topicId":1},"totalCount":25,"actions":{"canConfirm":false,"canSuggest":true}}}
""")
)
),
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
package com.dokdok.topic.dto.response;

import com.dokdok.global.response.CursorResponse;
import com.dokdok.topic.entity.Topic;
import com.dokdok.topic.entity.TopicStatus;
import com.dokdok.topic.entity.TopicType;
Expand All @@ -13,8 +12,20 @@
@Builder
@Schema(description = "주제 목록 및 권한 정보 응답")
public record TopicsWithActionsResponse(
@Schema(description = "주제 목록 페이지 정보")
CursorResponse<TopicDto, TopicsCursor> page,
@Schema(description = "주제 목록")
List<TopicDto> items,

@Schema(description = "페이지 크기", example = "10")
int pageSize,

@Schema(description = "다음 페이지 존재 여부")
boolean hasNext,

@Schema(description = "다음 페이지 커서 정보")
TopicsCursor nextCursor,

@Schema(description = "전체 주제 수")
Integer totalCount,

@Schema(description = "사용자 권한 정보")
Actions actions
Expand Down Expand Up @@ -65,10 +76,13 @@ public record TopicDto(
@Schema(description = "삭제 가능 여부", example = "true")
Boolean canDelete,

@Schema(description = "좋아요 여부", example = "true")
Boolean isLiked,

@Schema(description = "작성자 정보")
CreatedByInfo createdByInfo
) {
public static TopicDto from(Topic topic, Boolean canDelete) {
public static TopicDto from(Topic topic, Boolean canDelete, Boolean isLiked) {
return TopicDto.builder()
.topicId(topic.getId())
.meetingId(topic.getMeeting().getId())
Expand All @@ -79,6 +93,7 @@ public static TopicDto from(Topic topic, Boolean canDelete) {
.topicStatus(topic.getTopicStatus())
.likeCount(topic.getLikeCount())
.canDelete(canDelete)
.isLiked(isLiked)
.createdByInfo(
CreatedByInfo.of(
topic.getProposedBy().getId(),
Expand All @@ -93,13 +108,15 @@ public static TopicsWithActionsResponse from(
int pageSize,
boolean hasNext,
Set<Long> deletableTopicIds,
Set<Long> likedTopicIds,
Actions actions,
Long totalCount
) {
List<TopicDto> topicDtos = topics.stream()
.map(topic -> TopicDto.from(
topic,
deletableTopicIds.contains(topic.getId())
deletableTopicIds.contains(topic.getId()),
likedTopicIds.contains(topic.getId())
))
.toList();

Expand All @@ -109,10 +126,13 @@ public static TopicsWithActionsResponse from(
cursor = TopicsCursor.from(lastTopic);
}

CursorResponse<TopicDto, TopicsCursor> page =
CursorResponse.of(topicDtos, pageSize, hasNext, cursor,
totalCount != null ? totalCount.intValue() : null);

return new TopicsWithActionsResponse(page, actions);
return new TopicsWithActionsResponse(
topicDtos,
pageSize,
hasNext,
cursor,
totalCount != null ? totalCount.intValue() : null,
actions
);
}
}
16 changes: 14 additions & 2 deletions src/main/java/com/dokdok/topic/repository/TopicLikeRepository.java
Original file line number Diff line number Diff line change
@@ -1,17 +1,29 @@
package com.dokdok.topic.repository;

import com.dokdok.topic.entity.Topic;
import com.dokdok.topic.entity.TopicLike;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;
import org.springframework.stereotype.Repository;

import java.util.Optional;
import java.util.List;
import java.util.Set;

@Repository
public interface TopicLikeRepository extends JpaRepository<TopicLike, Long> {

boolean existsByTopicId(Long topicId);

void deleteByTopicIdAndUserId(Long topicId, Long id);

@Query("""
SELECT tl.topic.id
FROM TopicLike tl
WHERE tl.topic.id IN :topicIds
AND tl.user.id = :userId
""")
Set<Long> findLikedTopicIds(
@Param("topicIds") List<Long> topicIds,
@Param("userId") Long userId
);
}
4 changes: 3 additions & 1 deletion src/main/java/com/dokdok/topic/service/TopicService.java
Original file line number Diff line number Diff line change
Expand Up @@ -117,20 +117,22 @@ public TopicsWithActionsResponse getTopics(
}

Set<Long> deletableTopicIds = Set.of();
Set<Long> likedTopicIds = Set.of();

if (userId != null && !topics.isEmpty()) {
List<Long> topicIds = topics.stream()
.map(Topic::getId)
.toList();
deletableTopicIds = topicRepository.findDeletableTopicIds(topicIds, userId);
likedTopicIds = topicLikeRepository.findLikedTopicIds(topicIds, userId);
}

Long totalCount = null;
if (!hasCursor) {
totalCount = topicRepository.countByMeetingIdAndDeletedAtIsNull(meetingId);
}

return TopicsWithActionsResponse.from(topics, pageSize, hasNext, deletableTopicIds, actions, totalCount);
return TopicsWithActionsResponse.from(topics, pageSize, hasNext, deletableTopicIds, likedTopicIds, actions, totalCount);
}

@Transactional
Expand Down
116 changes: 84 additions & 32 deletions src/test/java/com/dokdok/topic/service/TopicServiceTest.java
Original file line number Diff line number Diff line change
Expand Up @@ -549,12 +549,21 @@ void getTopics_FirstPage_WithAuthenticatedUser() {
doNothing().when(meetingValidator)
.validateMeetingInGathering(meetingId, gatheringId);

given(topicRepository.canConfirmTopic(meetingId, userId))
.willReturn(false);

given(topicRepository.canSuggestTopic(meetingId, userId))
.willReturn(true);

given(topicRepository.findTopicsFirstPage(eq(meetingId), any(Pageable.class)))
.willReturn(topics);

given(topicRepository.findDeletableTopicIds(any(), eq(userId)))
.willReturn(Set.of(1L));

given(topicLikeRepository.findLikedTopicIds(any(), eq(userId)))
.willReturn(Set.of(1L));

given(topicRepository.countByMeetingIdAndDeletedAtIsNull(meetingId))
.willReturn(2L);

Expand All @@ -563,25 +572,31 @@ void getTopics_FirstPage_WithAuthenticatedUser() {
);

assertThat(response).isNotNull();
assertThat(response.page().items()).hasSize(2);
assertThat(response.page().pageSize()).isEqualTo(10);
assertThat(response.page().hasNext()).isFalse();
assertThat(response.page().nextCursor()).isNull();
assertThat(response.page().totalCount()).isEqualTo(2);

assertThat(response.page().items().get(0).title()).isEqualTo("의미 있는 이름 짓기");
assertThat(response.page().items().get(0).likeCount()).isEqualTo(5);
assertThat(response.page().items().get(0).topicType()).isEqualTo(TopicType.DISCUSSION);
assertThat(response.page().items().get(0).canDelete()).isTrue();

assertThat(response.page().items().get(1).title()).isEqualTo("함수 작성 원칙");
assertThat(response.page().items().get(1).likeCount()).isEqualTo(3);
assertThat(response.page().items().get(1).canDelete()).isFalse();
assertThat(response.items()).hasSize(2);
assertThat(response.pageSize()).isEqualTo(10);
assertThat(response.hasNext()).isFalse();
assertThat(response.nextCursor()).isNull();
assertThat(response.totalCount()).isEqualTo(2);

assertThat(response.actions().canConfirm()).isFalse();
assertThat(response.actions().canSuggest()).isTrue();

assertThat(response.items().get(0).title()).isEqualTo("의미 있는 이름 짓기");
assertThat(response.items().get(0).likeCount()).isEqualTo(5);
assertThat(response.items().get(0).topicType()).isEqualTo(TopicType.DISCUSSION);
assertThat(response.items().get(0).canDelete()).isTrue();
assertThat(response.items().get(0).isLiked()).isTrue();

assertThat(response.items().get(1).title()).isEqualTo("함수 작성 원칙");
assertThat(response.items().get(1).likeCount()).isEqualTo(3);
assertThat(response.items().get(1).canDelete()).isFalse();
assertThat(response.items().get(1).isLiked()).isFalse();

verify(gatheringValidator).validateGathering(gatheringId);
verify(meetingValidator).validateMeetingInGathering(meetingId, gatheringId);
verify(topicRepository).findTopicsFirstPage(eq(meetingId), any(Pageable.class));
verify(topicRepository).findDeletableTopicIds(any(), eq(userId));
verify(topicLikeRepository).findLikedTopicIds(any(), eq(userId));
}
}

Expand Down Expand Up @@ -621,6 +636,12 @@ void getTopics_FirstPage_WithAnonymousUser() {
doNothing().when(meetingValidator)
.validateMeetingInGathering(meetingId, gatheringId);

given(topicRepository.canConfirmTopic(meetingId, null))
.willReturn(false);

given(topicRepository.canSuggestTopic(meetingId, null))
.willReturn(false);

given(topicRepository.findTopicsFirstPage(eq(meetingId), any(Pageable.class)))
.willReturn(topics);

Expand All @@ -632,11 +653,15 @@ void getTopics_FirstPage_WithAnonymousUser() {
);

assertThat(response).isNotNull();
assertThat(response.page().items()).hasSize(1);
assertThat(response.page().items().get(0).canDelete()).isFalse();
assertThat(response.page().totalCount()).isEqualTo(1);
assertThat(response.items()).hasSize(1);
assertThat(response.items().get(0).canDelete()).isFalse();
assertThat(response.items().get(0).isLiked()).isFalse();
assertThat(response.totalCount()).isEqualTo(1);
assertThat(response.actions().canConfirm()).isFalse();
assertThat(response.actions().canSuggest()).isFalse();

verify(topicRepository, never()).findDeletableTopicIds(any(), any());
verify(topicLikeRepository, never()).findLikedTopicIds(any(), any());
}
}

Expand All @@ -658,6 +683,12 @@ void getTopics_EmptyList() {
doNothing().when(meetingValidator)
.validateMeetingInGathering(meetingId, gatheringId);

given(topicRepository.canConfirmTopic(meetingId, userId))
.willReturn(false);

given(topicRepository.canSuggestTopic(meetingId, userId))
.willReturn(false);

given(topicRepository.findTopicsFirstPage(eq(meetingId), any(Pageable.class)))
.willReturn(List.of());

Expand All @@ -669,16 +700,17 @@ void getTopics_EmptyList() {
);

assertThat(response).isNotNull();
assertThat(response.page().items()).isEmpty();
assertThat(response.page().pageSize()).isEqualTo(10);
assertThat(response.page().hasNext()).isFalse();
assertThat(response.page().nextCursor()).isNull();
assertThat(response.page().totalCount()).isEqualTo(0);
assertThat(response.items()).isEmpty();
assertThat(response.pageSize()).isEqualTo(10);
assertThat(response.hasNext()).isFalse();
assertThat(response.nextCursor()).isNull();
assertThat(response.totalCount()).isEqualTo(0);

verify(gatheringValidator).validateGathering(gatheringId);
verify(meetingValidator).validateMeetingInGathering(meetingId, gatheringId);
verify(topicRepository).findTopicsFirstPage(eq(meetingId), any(Pageable.class));
verify(topicRepository, never()).findDeletableTopicIds(any(), any());
verify(topicLikeRepository, never()).findLikedTopicIds(any(), any());
}
}

Expand Down Expand Up @@ -731,12 +763,21 @@ void getTopics_WithNextPage() {
doNothing().when(meetingValidator)
.validateMeetingInGathering(meetingId, gatheringId);

given(topicRepository.canConfirmTopic(meetingId, userId))
.willReturn(false);

given(topicRepository.canSuggestTopic(meetingId, userId))
.willReturn(true);

given(topicRepository.findTopicsFirstPage(eq(meetingId), any(Pageable.class)))
.willReturn(topics);

given(topicRepository.findDeletableTopicIds(any(), eq(userId)))
.willReturn(Set.of());

given(topicLikeRepository.findLikedTopicIds(any(), eq(userId)))
.willReturn(Set.of());

given(topicRepository.countByMeetingIdAndDeletedAtIsNull(meetingId))
.willReturn(10L);

Expand All @@ -745,12 +786,13 @@ void getTopics_WithNextPage() {
);

assertThat(response).isNotNull();
assertThat(response.page().items()).hasSize(2);
assertThat(response.page().hasNext()).isTrue();
assertThat(response.page().nextCursor()).isNotNull();
assertThat(response.page().nextCursor().likeCount()).isEqualTo(5);
assertThat(response.page().nextCursor().topicId()).isEqualTo(2L);
assertThat(response.page().totalCount()).isEqualTo(10);
assertThat(response.items()).hasSize(2);
assertThat(response.pageSize()).isEqualTo(2);
assertThat(response.hasNext()).isTrue();
assertThat(response.nextCursor()).isNotNull();
assertThat(response.nextCursor().likeCount()).isEqualTo(5);
assertThat(response.nextCursor().topicId()).isEqualTo(2L);
assertThat(response.totalCount()).isEqualTo(10);

verify(topicRepository).findTopicsFirstPage(eq(meetingId), any(Pageable.class));
}
Expand Down Expand Up @@ -788,22 +830,32 @@ void getTopics_WithCursor() {
doNothing().when(meetingValidator)
.validateMeetingInGathering(meetingId, gatheringId);

given(topicRepository.canConfirmTopic(meetingId, userId))
.willReturn(false);

given(topicRepository.canSuggestTopic(meetingId, userId))
.willReturn(true);

given(topicRepository.findTopicsAfterCursor(
eq(meetingId), eq(cursorLikeCount), eq(cursorTopicId), any(Pageable.class)
)).willReturn(topics);

given(topicRepository.findDeletableTopicIds(any(), eq(userId)))
.willReturn(Set.of());

given(topicLikeRepository.findLikedTopicIds(any(), eq(userId)))
.willReturn(Set.of(3L));

TopicsWithActionsResponse response = topicService.getTopics(
gatheringId, meetingId, pageSize, cursorLikeCount, cursorTopicId
);

assertThat(response).isNotNull();
assertThat(response.page().items()).hasSize(1);
assertThat(response.page().hasNext()).isFalse();
assertThat(response.page().nextCursor()).isNull();
assertThat(response.page().totalCount()).isNull();
assertThat(response.items()).hasSize(1);
assertThat(response.hasNext()).isFalse();
assertThat(response.nextCursor()).isNull();
assertThat(response.totalCount()).isNull();
assertThat(response.items().get(0).isLiked()).isTrue();

verify(topicRepository).findTopicsAfterCursor(
eq(meetingId), eq(cursorLikeCount), eq(cursorTopicId), any(Pageable.class)
Expand Down