diff --git a/docs/ErrorCode.md b/docs/ErrorCode.md index 9ffba12..14c8010 100644 --- a/docs/ErrorCode.md +++ b/docs/ErrorCode.md @@ -129,6 +129,7 @@ | M016 | MEETING_JOIN_NOT_ALLOWED | 약속 시작 24시간 이내에는 참가 신청할 수 없습니다. | 400 | | M017 | MEETING_UPDATE_NOT_ALLOWED | 약속 시작 24시간 이내에는 수정할 수 없습니다. | 400 | | M018 | MEETING_NOT_CONFIRMED | 약속이 확정된 경우에만 주제를 제안할 수 있습니다. | 400 | +| M019 | MEETING_DATE_REQUIRED | 약속 시작/종료 일시는 필수입니다. | 400 | --- diff --git a/src/main/java/com/dokdok/meeting/api/MeetingListApi.java b/src/main/java/com/dokdok/meeting/api/MeetingListApi.java index 6480e27..636ad7c 100644 --- a/src/main/java/com/dokdok/meeting/api/MeetingListApi.java +++ b/src/main/java/com/dokdok/meeting/api/MeetingListApi.java @@ -1,14 +1,10 @@ package com.dokdok.meeting.api; import com.dokdok.global.response.ApiResponse; -import com.dokdok.global.response.CursorResponse; import com.dokdok.global.response.PageResponse; import com.dokdok.meeting.dto.MeetingListFilter; -import com.dokdok.meeting.dto.MeetingListItemCursorResponse; import com.dokdok.meeting.dto.MeetingListItemPageResponse; import com.dokdok.meeting.dto.MeetingListItemResponse; -import com.dokdok.meeting.dto.MeetingListCursor; -import com.dokdok.meeting.dto.MeetingListCursorRequest; import com.dokdok.meeting.entity.MeetingStatus; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.Parameter; @@ -51,7 +47,7 @@ public interface MeetingListApi { responseCode = "200", description = "약속 리스트 조회 성공", content = @Content(mediaType = MediaType.APPLICATION_JSON_VALUE, - schema = @Schema(implementation = MeetingListItemCursorResponse.class), + schema = @Schema(implementation = MeetingListItemPageResponse.class), examples = @ExampleObject(value = """ { "code": "SUCCESS", @@ -69,12 +65,9 @@ public interface MeetingListApi { } ], "totalCount": 12, - "pageSize": 4, - "hasNext": true, - "nextCursor": { - "meetingId": 2, - "startDateTime": "2025-02-03T14:00:00" - } + "currentPage": 0, + "pageSize": 5, + "totalPages": 3 } } """)) @@ -100,11 +93,11 @@ public interface MeetingListApi { {"code": "E000", "message": "서버 에러가 발생했습니다. 담당자에게 문의 바랍니다.", "data": null} """))) }) - ResponseEntity>> getMeetingList( + ResponseEntity>> getMeetingList( @PathVariable Long gatheringId, @RequestParam MeetingListFilter filter, - @ParameterObject MeetingListCursorRequest cursor, - @RequestParam(defaultValue = "4") int size + @ParameterObject + @PageableDefault(size = 5, sort = {"meetingStartDate", "id"}) Pageable pageable ); @Operation( diff --git a/src/main/java/com/dokdok/meeting/controller/MeetingListController.java b/src/main/java/com/dokdok/meeting/controller/MeetingListController.java index 6be331a..2c7779f 100644 --- a/src/main/java/com/dokdok/meeting/controller/MeetingListController.java +++ b/src/main/java/com/dokdok/meeting/controller/MeetingListController.java @@ -1,11 +1,8 @@ package com.dokdok.meeting.controller; import com.dokdok.global.response.ApiResponse; -import com.dokdok.global.response.CursorResponse; import com.dokdok.global.response.PageResponse; import com.dokdok.meeting.api.MeetingListApi; -import com.dokdok.meeting.dto.MeetingListCursor; -import com.dokdok.meeting.dto.MeetingListCursorRequest; import com.dokdok.meeting.dto.MeetingListFilter; import com.dokdok.meeting.dto.MeetingListItemResponse; import com.dokdok.meeting.entity.MeetingStatus; @@ -31,15 +28,14 @@ public class MeetingListController implements MeetingListApi { @Override @GetMapping(produces = MediaType.APPLICATION_JSON_VALUE) - public ResponseEntity>> getMeetingList( + public ResponseEntity>> getMeetingList( @PathVariable Long gatheringId, @RequestParam MeetingListFilter filter, - @ParameterObject MeetingListCursorRequest cursor, - @RequestParam(defaultValue = "4") int size + @ParameterObject + @PageableDefault(size = 5, sort = {"meetingStartDate", "id"}) Pageable pageable ) { - MeetingListCursor cursorValue = cursor == null ? null : cursor.toCursorOrNull(); - CursorResponse response = - meetingService.meetingList(gatheringId, filter, size, cursorValue); + PageResponse response = + meetingService.meetingList(gatheringId, filter, pageable); return ApiResponse.success(response, "약속 리스트 조회에 성공했습니다."); } diff --git a/src/main/java/com/dokdok/meeting/dto/MeetingCreateRequest.java b/src/main/java/com/dokdok/meeting/dto/MeetingCreateRequest.java index 0534c64..7f84a30 100644 --- a/src/main/java/com/dokdok/meeting/dto/MeetingCreateRequest.java +++ b/src/main/java/com/dokdok/meeting/dto/MeetingCreateRequest.java @@ -2,6 +2,7 @@ import com.dokdok.meeting.entity.MeetingLocation; import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotNull; import jakarta.validation.constraints.Size; import lombok.Builder; @@ -21,9 +22,11 @@ public record MeetingCreateRequest( String meetingName, @Schema(description = "약속 시작 일시", example = "2025-02-01T14:00:00", requiredMode = Schema.RequiredMode.REQUIRED) + @NotNull LocalDateTime meetingStartDate, - @Schema(description = "약속 종료 일시", example = "2025-02-01T16:00:00") + @Schema(description = "약속 종료 일시", example = "2025-02-01T16:00:00", requiredMode = Schema.RequiredMode.REQUIRED) + @NotNull LocalDateTime meetingEndDate, @Schema(description = "최대 참가 인원 (null 허용)", example = "10") diff --git a/src/main/java/com/dokdok/meeting/dto/MeetingDetailProgressStatus.java b/src/main/java/com/dokdok/meeting/dto/MeetingDetailProgressStatus.java index c5cf8f8..3ee68f0 100644 --- a/src/main/java/com/dokdok/meeting/dto/MeetingDetailProgressStatus.java +++ b/src/main/java/com/dokdok/meeting/dto/MeetingDetailProgressStatus.java @@ -2,10 +2,9 @@ import io.swagger.v3.oas.annotations.media.Schema; -@Schema(description = "약속 상세 진행 상태 (시간 기준) - PRE(약속 전), ONGOING(약속 중), POST(약속 후), UNKNOWN(일정 미정)") +@Schema(description = "약속 상세 진행 상태 (시간 기준) - PRE(약속 전), ONGOING(약속 중), POST(약속 후)") public enum MeetingDetailProgressStatus { PRE, ONGOING, - POST, - UNKNOWN + POST } diff --git a/src/main/java/com/dokdok/meeting/dto/MeetingDetailResponse.java b/src/main/java/com/dokdok/meeting/dto/MeetingDetailResponse.java index cb3a8da..01f2bbe 100644 --- a/src/main/java/com/dokdok/meeting/dto/MeetingDetailResponse.java +++ b/src/main/java/com/dokdok/meeting/dto/MeetingDetailResponse.java @@ -105,7 +105,7 @@ private static MeetingDetailProgressStatus resolveProgressStatus( LocalDateTime now ) { if (meetingStartDate == null || meetingEndDate == null) { - return MeetingDetailProgressStatus.UNKNOWN; + throw new IllegalStateException("meetingStartDate/meetingEndDate must be non-null"); } if (now.isBefore(meetingStartDate)) { return MeetingDetailProgressStatus.PRE; diff --git a/src/main/java/com/dokdok/meeting/exception/MeetingErrorCode.java b/src/main/java/com/dokdok/meeting/exception/MeetingErrorCode.java index c953008..adb3d2a 100644 --- a/src/main/java/com/dokdok/meeting/exception/MeetingErrorCode.java +++ b/src/main/java/com/dokdok/meeting/exception/MeetingErrorCode.java @@ -27,6 +27,7 @@ public enum MeetingErrorCode implements BaseErrorCode { MEETING_JOIN_NOT_ALLOWED("M016", "약속 시작 24시간 이내에는 참가 신청할 수 없습니다.", HttpStatus.BAD_REQUEST), MEETING_UPDATE_NOT_ALLOWED("M017", "약속 시작 24시간 이내에는 수정할 수 없습니다.", HttpStatus.BAD_REQUEST), MEETING_NOT_CONFIRMED("M018", "약속이 확정된 경우에만 주제를 제안할 수 있습니다.", HttpStatus.BAD_REQUEST), + MEETING_DATE_REQUIRED("M019", "약속 시작/종료 일시는 필수입니다.", HttpStatus.BAD_REQUEST), INVALID_MAX_PARTICIPANTS("M013", "최대 참가 인원은 1명 이상이어야 하며, 모임 전체 인원을 초과할 수 없습니다.", HttpStatus.BAD_REQUEST), MAX_PARTICIPANTS_LESS_THAN_CURRENT("M014", "현재 참가 확정된 인원 수보다 적게 수정할 수 없습니다.", HttpStatus.BAD_REQUEST); diff --git a/src/main/java/com/dokdok/meeting/service/MeetingService.java b/src/main/java/com/dokdok/meeting/service/MeetingService.java index 867f787..7c8da29 100644 --- a/src/main/java/com/dokdok/meeting/service/MeetingService.java +++ b/src/main/java/com/dokdok/meeting/service/MeetingService.java @@ -117,6 +117,8 @@ public MeetingResponse createMeeting(MeetingCreateRequest request) { .countByGatheringIdAndRemovedAtIsNull(gathering.getId()); } + validateMeetingDatesRequired(request.meetingStartDate(), request.meetingEndDate()); + // 최대 참가 인원 검증 validateMaxParticipants(maxParticipants, gathering.getId()); @@ -467,6 +469,7 @@ private void validateMeetingDates(MeetingUpdateRequest request, Meeting meeting) LocalDateTime endDate = request.endDate() != null ? request.endDate() : meeting.getMeetingEndDate(); + validateMeetingDatesRequired(startDate, endDate); if (startDate != null && endDate != null && endDate.isBefore(startDate)) { throw new MeetingException( MeetingErrorCode.INVALID_MEETING_STATUS_CHANGE, @@ -475,6 +478,15 @@ private void validateMeetingDates(MeetingUpdateRequest request, Meeting meeting) } } + private void validateMeetingDatesRequired(LocalDateTime startDate, LocalDateTime endDate) { + if (startDate == null || endDate == null) { + throw new MeetingException( + MeetingErrorCode.MEETING_DATE_REQUIRED, + "약속 시작/종료 일시는 필수입니다." + ); + } + } + /** * 약속 리스트를 조회한다. * 약속 : 모임에서 약속이 확정된 전체 약속 @@ -483,24 +495,22 @@ private void validateMeetingDates(MeetingUpdateRequest request, Meeting meeting) * 내가 참여한 약속 : 완전히 끝난 약속 중 내가 참여한 약속 * @param gatheringId 모임 식별자 * @param filter 약속 리스트 필터 - * @param size 페이지 크기 - * @param cursor 커서 - * @return CursorResponse + * @param pageable 페이지 정보 + * @return PageResponse */ - public CursorResponse meetingList( + public PageResponse meetingList( Long gatheringId, MeetingListFilter filter, - int size, - MeetingListCursor cursor + Pageable pageable ) { Long userId = SecurityUtil.getCurrentUserId(); gatheringValidator.validateMembership(gatheringId, userId); return switch (filter) { - case ALL -> getAllMeetings(gatheringId, size, cursor, userId); - case UPCOMING -> getUpcomingMeetings(gatheringId, size, cursor, userId); - case DONE -> getDoneMeetings(gatheringId, size, cursor, userId); - case JOINED -> getJoinedMeetings(gatheringId, size, cursor, userId); + case ALL -> getAllMeetingsPage(gatheringId, pageable, userId); + case UPCOMING -> getUpcomingMeetingsPage(gatheringId, pageable, userId); + case DONE -> getDoneMeetingsPage(gatheringId, pageable, userId); + case JOINED -> getJoinedMeetingsPage(gatheringId, pageable, userId); }; } @@ -674,138 +684,88 @@ public MyMeetingTabCountsResponse getMyMeetingTabCounts() { } /** - * 모임의 약속 중 확정된 리스트를 전부 반환한다. + * 모임의 약속 중 확정된 리스트를 페이지로 반환한다. */ - private CursorResponse getAllMeetings( + private PageResponse getAllMeetingsPage( Long gatheringId, - int size, - MeetingListCursor cursor, + Pageable pageable, Long userId ) { - Pageable pageable = cursorPageable(size); - List meetings = meetingRepository.findByGatheringIdAndMeetingStatusAfterCursor( + Page meetingPage = meetingRepository.findByGatheringIdAndMeetingStatus( gatheringId, MeetingStatus.CONFIRMED, - cursorStartDateTime(cursor), - cursorMeetingId(cursor), pageable ); - Integer totalCount = cursor == null - ? meetingRepository.countByGatheringIdAndMeetingStatus(gatheringId, MeetingStatus.CONFIRMED) - : null; - return buildMeetingListResponse(meetings, size, userId, gatheringId, totalCount); + return buildMeetingPageResponse(meetingPage, userId, gatheringId); } /** - * 다가오는 약속 리스트를 반환한다. + * 다가오는 약속 리스트를 페이지로 반환한다. */ - private CursorResponse getUpcomingMeetings( + private PageResponse getUpcomingMeetingsPage( Long gatheringId, - int size, - MeetingListCursor cursor, + Pageable pageable, Long userId ) { LocalDateTime now = LocalDateTime.now(); - Pageable pageable = cursorPageable(size); - - List meetings = meetingRepository - .findByGatheringIdAndMeetingStatusAndMeetingStartDateBetweenAfterCursor( - gatheringId, - MeetingStatus.CONFIRMED, - now, - now.plusDays(3), - cursorStartDateTime(cursor), - cursorMeetingId(cursor), - pageable - ); - - Integer totalCount = cursor == null - ? meetingRepository.countUpcomingMeetings( - gatheringId, - MeetingStatus.CONFIRMED, - now, - now.plusDays(3) - ) - : null; - return buildMeetingListResponse(meetings, size, userId, gatheringId, totalCount); + Page meetingPage = meetingRepository.findByGatheringIdAndMeetingStatusAndMeetingStartDateBetween( + gatheringId, + MeetingStatus.CONFIRMED, + now, + now.plusDays(3), + pageable + ); + return buildMeetingPageResponse(meetingPage, userId, gatheringId); } /** - * 완료된 약속 리스트를 반환한다. + * 완료된 약속 리스트를 페이지로 반환한다. */ - private CursorResponse getDoneMeetings( + private PageResponse getDoneMeetingsPage( Long gatheringId, - int size, - MeetingListCursor cursor, + Pageable pageable, Long userId ) { - Pageable pageable = cursorPageable(size); - List meetings = meetingRepository.findByGatheringIdAndMeetingStatusAfterCursor( + Page meetingPage = meetingRepository.findByGatheringIdAndMeetingStatus( gatheringId, MeetingStatus.DONE, - cursorStartDateTime(cursor), - cursorMeetingId(cursor), pageable ); - Integer totalCount = cursor == null - ? meetingRepository.countByGatheringIdAndMeetingStatus(gatheringId, MeetingStatus.DONE) - : null; - return buildMeetingListResponse(meetings, size, userId, gatheringId, totalCount); + return buildMeetingPageResponse(meetingPage, userId, gatheringId); } /** - * 완료된 약속 중 내가 참여했던 약속 리스트를 반환한다. + * 완료된 약속 중 내가 참여했던 약속 리스트를 페이지로 반환한다. */ - private CursorResponse getJoinedMeetings( + private PageResponse getJoinedMeetingsPage( Long gatheringId, - int size, - MeetingListCursor cursor, + Pageable pageable, Long userId ) { - Pageable pageable = cursorPageable(size); - List meetings = meetingMemberRepository.findMeetingsByUserIdAndStatusAfterCursor( + Page meetingPage = meetingMemberRepository.findMeetingsByUserIdAndStatus( userId, gatheringId, MeetingStatus.DONE, - cursorStartDateTime(cursor), - cursorMeetingId(cursor), pageable ); - Integer totalCount = cursor == null - ? meetingMemberRepository.countMeetingsByUserIdAndStatus( - userId, - gatheringId, - MeetingStatus.DONE - ) - : null; - return buildMeetingListResponse(meetings, size, userId, gatheringId, totalCount); + return buildMeetingPageResponse(meetingPage, userId, gatheringId); } /** - * 약속 리스트 커서 응답을 구성한다. + * 약속 리스트 페이지 응답을 구성한다. */ - private CursorResponse buildMeetingListResponse( - List meetingCandidates, - int size, + private PageResponse buildMeetingPageResponse( + Page meetingPage, Long userId, - Long gatheringId, - Integer totalCount + Long gatheringId ) { - boolean hasNext = meetingCandidates.size() > size; - List meetings = hasNext ? meetingCandidates.subList(0, size) : meetingCandidates; - if (meetings.isEmpty()) { - return CursorResponse.of(List.of(), size, false, null, totalCount); - } - - List items = buildMeetingItems(meetings, userId, gatheringId); - - MeetingListCursor nextCursor = null; - if (hasNext) { - Meeting last = meetings.get(meetings.size() - 1); - nextCursor = new MeetingListCursor(last.getMeetingStartDate(), last.getId()); - } - - return CursorResponse.of(items, size, hasNext, nextCursor, totalCount); + List items = buildMeetingItems(meetingPage.getContent(), userId, gatheringId); + return PageResponse.of( + items, + meetingPage.getTotalElements(), + meetingPage.getNumber(), + meetingPage.getSize() + ); } /** diff --git a/src/test/java/com/dokdok/meeting/service/MeetingServiceTest.java b/src/test/java/com/dokdok/meeting/service/MeetingServiceTest.java index d3ef4bb..f56bcee 100644 --- a/src/test/java/com/dokdok/meeting/service/MeetingServiceTest.java +++ b/src/test/java/com/dokdok/meeting/service/MeetingServiceTest.java @@ -39,7 +39,6 @@ import org.mockito.Mock; import org.mockito.MockedStatic; import org.mockito.junit.jupiter.MockitoExtension; -import org.springframework.data.domain.Page; import org.springframework.data.domain.PageImpl; import org.springframework.data.domain.Pageable; import org.springframework.data.domain.Page; @@ -117,6 +116,8 @@ void setUp() { .id(meetingId) .meetingName("Meeting 1") .meetingStatus(MeetingStatus.PENDING) + .meetingStartDate(LocalDateTime.now().plusDays(2)) + .meetingEndDate(LocalDateTime.now().plusDays(2).plusHours(1)) .meetingLeader(leader) .gathering(gathering) .build(); @@ -153,7 +154,7 @@ void givenMeetingId_whenFindMeeting_thenMeetingResponse() { // then assertThat(findMeeting.meetingName()).isEqualTo(meeting.getMeetingName()); assertThat(findMeeting.meetingStatus()).isEqualTo(meeting.getMeetingStatus()); - assertThat(findMeeting.progressStatus()).isEqualTo(MeetingDetailProgressStatus.UNKNOWN); + assertThat(findMeeting.progressStatus()).isEqualTo(MeetingDetailProgressStatus.PRE); assertThat(findMeeting.confirmedTopic()).isFalse(); assertThat(findMeeting.confirmedTopicDate()).isNull(); } @@ -352,13 +353,14 @@ void givenMeetingCreateRequest_whenCreateMeeting_thenMeetingResponse() { Long bookId = 12L; Long userId = 7L; LocalDateTime startDate = LocalDateTime.of(2024, 1, 20, 20, 0); + LocalDateTime endDate = LocalDateTime.of(2024, 1, 20, 22, 0); int memberCount = 5; MeetingCreateRequest request = MeetingCreateRequest.builder() .gatheringId(gatheringId) .bookId(bookId) .meetingName(null) .meetingStartDate(startDate) - .meetingEndDate(null) + .meetingEndDate(endDate) .maxParticipants(null) .build(); @@ -388,7 +390,7 @@ void givenMeetingCreateRequest_whenCreateMeeting_thenMeetingResponse() { .meetingStatus(MeetingStatus.PENDING) .maxParticipants(memberCount) .meetingStartDate(startDate) - .meetingEndDate(null) + .meetingEndDate(endDate) .build(); given(gatheringRepository.findById(gatheringId)) @@ -413,6 +415,7 @@ void givenMeetingCreateRequest_whenCreateMeeting_thenMeetingResponse() { assertThat(response.meetingStatus()).isEqualTo(MeetingStatus.PENDING); assertThat(response.meetingName()).isEqualTo(book.getBookName()); assertThat(response.schedule().startDateTime()).isEqualTo(startDate); + assertThat(response.schedule().endDateTime()).isEqualTo(endDate); assertThat(response.participants().maxCount()).isEqualTo(memberCount); } } @@ -946,7 +949,7 @@ void givenMeetingId_whenMeetingCancel_thenDeleteWithTopics() { @Test void givenMeetingUpdateRequest_whenMeetingUpdate_thenSuccess() { // given - LocalDateTime now = LocalDateTime.now(); + LocalDateTime endDate = meeting.getMeetingStartDate().plusHours(2); MeetingUpdateRequest request = MeetingUpdateRequest.builder() .meetingName("약속명 변경") .location(new MeetingLocationDto( @@ -955,7 +958,7 @@ void givenMeetingUpdateRequest_whenMeetingUpdate_thenSuccess() { 37.0, 127.0 )) - .endDate(now) + .endDate(endDate) .build(); given(meetingValidator.findMeetingOrThrow(meetingId)).willReturn(meeting); @@ -1083,7 +1086,7 @@ void givenGatheringIdAndAllFilter_whenGetMeetingList_thenReturnItems() { // given Long gatheringId = 100L; Long userId = 55L; - int size = 10; + Pageable pageable = org.springframework.data.domain.PageRequest.of(0, 10); Book book1 = Book.builder().id(1L).bookName("book1").build(); Book book2 = Book.builder().id(2L).bookName("book2").build(); Meeting meeting1 = Meeting.builder() @@ -1106,13 +1109,12 @@ void givenGatheringIdAndAllFilter_whenGetMeetingList_thenReturnItems() { .build(); List meetings = List.of(meeting1, meeting2); - given(meetingRepository.findByGatheringIdAndMeetingStatusAfterCursor( + Page meetingPage = new PageImpl<>(meetings, pageable, meetings.size()); + given(meetingRepository.findByGatheringIdAndMeetingStatus( eq(gatheringId), eq(MeetingStatus.CONFIRMED), - any(), - any(), any() - )).willReturn(meetings); + )).willReturn(meetingPage); given(topicRepository.findTopicTypesByMeetingIds(List.of(1L, 2L))) .willReturn(List.of( new Object[]{1L, TopicType.FREE}, @@ -1126,14 +1128,14 @@ void givenGatheringIdAndAllFilter_whenGetMeetingList_thenReturnItems() { mock.when(SecurityUtil::getCurrentUserId).thenReturn(userId); // when - CursorResponse response = - meetingService.meetingList(gatheringId, MeetingListFilter.ALL, size, null); + PageResponse response = + meetingService.meetingList(gatheringId, MeetingListFilter.ALL, pageable); // then assertThat(response.items()).hasSize(2); assertThat(response.pageSize()).isEqualTo(10); - assertThat(response.hasNext()).isFalse(); - assertThat(response.nextCursor()).isNull(); + assertThat(response.currentPage()).isEqualTo(0); + assertThat(response.totalPages()).isEqualTo(1); MeetingListItemResponse item1 = response.items().stream() .filter(item -> item.meetingId().equals(1L)) .findFirst()