diff --git a/src/main/java/com/dokdok/book/api/PersonalBookRecordApi.java b/src/main/java/com/dokdok/book/api/PersonalBookRecordApi.java index c3b85467..0dd31f43 100644 --- a/src/main/java/com/dokdok/book/api/PersonalBookRecordApi.java +++ b/src/main/java/com/dokdok/book/api/PersonalBookRecordApi.java @@ -2,8 +2,10 @@ import com.dokdok.book.dto.request.PersonalReadingRecordCreateRequest; import com.dokdok.book.dto.request.PersonalReadingRecordUpdateRequest; +import com.dokdok.book.dto.request.PreOpinionTimeType; import com.dokdok.book.dto.response.*; import com.dokdok.global.response.ApiResponse; +import com.dokdok.global.response.CursorResponse; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.Parameter; import io.swagger.v3.oas.annotations.media.Content; @@ -17,6 +19,7 @@ import org.springframework.web.bind.annotation.*; import java.time.OffsetDateTime; +import java.time.LocalDateTime; @Tag(name = "독서 기록", description = "책별 독서 기록 관련 API") @RequestMapping("/api/book") @@ -606,4 +609,70 @@ ResponseEntity> getMyTopicAnswer @Parameter(description = "개인 책장 ID (personal_book 테이블 PK)", required = true, example = "10") @PathVariable Long personalBookId ); + + @Operation( + summary = "독서 타임라인 조회 (developer: 권우희)", + description = """ + 독서 기록/사전 의견/개인 회고를 하나의 타임라인으로 커서 기반 조회합니다. + - personalBook의 gatheringId가 null이면 사전 의견/회고는 제외됩니다. + - 사전 의견(PRE_OPINION)은 **내 답변이 있는 미팅만** 포함합니다. + - 정렬: eventAt DESC, typeOrder DESC, sourceId DESC + - preOpinionTime: 사전 의견 정렬 기준 (MEETING_START | ANSWER_CREATED, 기본값 ANSWER_CREATED) + + **사용 방법** + - 첫 페이지: `?size=10&preOpinionTime=ANSWER_CREATED` + - 다음 페이지: `?size=10&cursorEventAt={nextCursor.eventAt}&cursorType={nextCursor.type}&cursorSourceId={nextCursor.sourceId}` + """ + ) + @ApiResponses({ + @io.swagger.v3.oas.annotations.responses.ApiResponse( + responseCode = "200", + description = "독서 타임라인 조회 성공", + content = @Content(mediaType = MediaType.APPLICATION_JSON_VALUE, + schema = @Schema(implementation = ReadingTimelineCursorResponse.class), + examples = @ExampleObject(value = """ + { + "code": "SUCCESS", + "message": "독서 타임라인 조회 성공", + "data": { + "items": [ + { + "type": "READING_RECORD", + "eventAt": "2026-01-25T22:39:57.899858", + "sourceId": 1, + "readingRecord": { + "recordId": 1, + "recordType": "QUOTE", + "recordContent": "기억에 남는 구절", + "meta": {"page": "12", "excerpt": "..."}, + "createdAt": "2026-01-25T22:39:57.899858", + "bookId": 1 + } + } + ], + "pageSize": 10, + "hasNext": false, + "nextCursor": null + } + } + """)) + ) + }) + @GetMapping("/{personalBookId}/records/timeline") + ResponseEntity>> getMyReadingTimeline( + @Parameter(description = "개인 책장 ID (personal_book 테이블 PK)", required = true, example = "10") + @PathVariable Long personalBookId, + @Parameter(description = "커서 - 마지막 이벤트 시간 (ISO 8601)", example = "") + @RequestParam(required = false) + @DateTimeFormat(iso = DateTimeFormat.ISO.DATE_TIME) + LocalDateTime cursorEventAt, + @Parameter(description = "커서 - 마지막 이벤트 타입 (READING_RECORD | PERSONAL_RETROSPECTIVE | PRE_OPINION)") + @RequestParam(required = false) ReadingTimelineType cursorType, + @Parameter(description = "커서 - 마지막 이벤트 원본 ID") + @RequestParam(required = false) Long cursorSourceId, + @Parameter(description = "한 페이지당 아이템 수", example = "10") + @RequestParam(required = false) Integer size, + @Parameter(description = "사전 의견 정렬 기준 (MEETING_START | ANSWER_CREATED)", example = "ANSWER_CREATED") + @RequestParam(required = false, defaultValue = "ANSWER_CREATED") PreOpinionTimeType preOpinionTime + ); } diff --git a/src/main/java/com/dokdok/book/controller/PersonalBookRecordController.java b/src/main/java/com/dokdok/book/controller/PersonalBookRecordController.java index 5b0bffd5..27b91c67 100644 --- a/src/main/java/com/dokdok/book/controller/PersonalBookRecordController.java +++ b/src/main/java/com/dokdok/book/controller/PersonalBookRecordController.java @@ -3,14 +3,18 @@ import com.dokdok.book.api.PersonalBookRecordApi; import com.dokdok.book.dto.request.PersonalReadingRecordCreateRequest; import com.dokdok.book.dto.request.PersonalReadingRecordUpdateRequest; +import com.dokdok.book.dto.request.PreOpinionTimeType; import com.dokdok.book.dto.response.*; import com.dokdok.book.service.PersonalReadingRecordService; +import com.dokdok.book.service.ReadingTimelineService; +import com.dokdok.global.response.CursorResponse; import com.dokdok.global.response.ApiResponse; import lombok.RequiredArgsConstructor; import org.springframework.format.annotation.DateTimeFormat; import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.*; +import java.time.LocalDateTime; import java.time.OffsetDateTime; @RestController @@ -19,6 +23,7 @@ public class PersonalBookRecordController implements PersonalBookRecordApi { private final PersonalReadingRecordService personalReadingRecordService; + private final ReadingTimelineService readingTimelineService; @Override @PostMapping("/{personalBookId}/records") @@ -65,4 +70,28 @@ public ResponseEntity> getMyTopi PersonalReadingTopicAnswerResponse response = personalReadingRecordService.getTopicAnswers(personalBookId); return ApiResponse.success(response, "사전 의견 조회 성공"); } + + @Override + @GetMapping("/{personalBookId}/records/timeline") + public ResponseEntity>> getMyReadingTimeline( + @PathVariable Long personalBookId, + @RequestParam(required = false) + @DateTimeFormat(iso = DateTimeFormat.ISO.DATE_TIME) + LocalDateTime cursorEventAt, + @RequestParam(required = false) ReadingTimelineType cursorType, + @RequestParam(required = false) Long cursorSourceId, + @RequestParam(required = false) Integer size, + @RequestParam(required = false, defaultValue = "ANSWER_CREATED") PreOpinionTimeType preOpinionTime + ) { + CursorResponse response = + readingTimelineService.getTimeline( + personalBookId, + cursorEventAt, + cursorType, + cursorSourceId, + size, + preOpinionTime + ); + return ApiResponse.success(response, "독서 타임라인 조회 성공"); + } } diff --git a/src/main/java/com/dokdok/book/dto/request/PreOpinionTimeType.java b/src/main/java/com/dokdok/book/dto/request/PreOpinionTimeType.java new file mode 100644 index 00000000..f378a277 --- /dev/null +++ b/src/main/java/com/dokdok/book/dto/request/PreOpinionTimeType.java @@ -0,0 +1,6 @@ +package com.dokdok.book.dto.request; + +public enum PreOpinionTimeType { + MEETING_START, + ANSWER_CREATED +} diff --git a/src/main/java/com/dokdok/book/dto/response/ReadingTimelineCursor.java b/src/main/java/com/dokdok/book/dto/response/ReadingTimelineCursor.java new file mode 100644 index 00000000..5cb1b5ad --- /dev/null +++ b/src/main/java/com/dokdok/book/dto/response/ReadingTimelineCursor.java @@ -0,0 +1,19 @@ +package com.dokdok.book.dto.response; + +import io.swagger.v3.oas.annotations.media.Schema; + +import java.time.LocalDateTime; + +@Schema(description = "독서 타임라인 커서") +public record ReadingTimelineCursor( + @Schema(description = "마지막 이벤트 시간", example = "2026-01-05T21:38:00") + LocalDateTime eventAt, + @Schema(description = "마지막 이벤트 타입", example = "READING_RECORD") + ReadingTimelineType type, + @Schema(description = "마지막 이벤트 원본 ID", example = "10") + Long sourceId +) { + public static ReadingTimelineCursor from(LocalDateTime eventAt, ReadingTimelineType type, Long sourceId) { + return new ReadingTimelineCursor(eventAt, type, sourceId); + } +} diff --git a/src/main/java/com/dokdok/book/dto/response/ReadingTimelineCursorResponse.java b/src/main/java/com/dokdok/book/dto/response/ReadingTimelineCursorResponse.java new file mode 100644 index 00000000..2ee105df --- /dev/null +++ b/src/main/java/com/dokdok/book/dto/response/ReadingTimelineCursorResponse.java @@ -0,0 +1,21 @@ +package com.dokdok.book.dto.response; + +import io.swagger.v3.oas.annotations.media.Schema; + +import java.util.List; + +@Schema(description = "독서 타임라인 커서 응답(문서용)") +public record ReadingTimelineCursorResponse( + @Schema(description = "아이템 목록") + List items, + + @Schema(description = "페이지 크기", example = "10") + int pageSize, + + @Schema(description = "다음 페이지 존재 여부", example = "true") + boolean hasNext, + + @Schema(description = "다음 커서") + ReadingTimelineCursor nextCursor +) { +} diff --git a/src/main/java/com/dokdok/book/dto/response/ReadingTimelineItem.java b/src/main/java/com/dokdok/book/dto/response/ReadingTimelineItem.java new file mode 100644 index 00000000..013035ad --- /dev/null +++ b/src/main/java/com/dokdok/book/dto/response/ReadingTimelineItem.java @@ -0,0 +1,69 @@ +package com.dokdok.book.dto.response; + +import com.dokdok.retrospective.dto.response.RetrospectiveRecordResponse; +import com.fasterxml.jackson.annotation.JsonInclude; +import io.swagger.v3.oas.annotations.media.Schema; + +import java.time.LocalDateTime; + +@Schema(description = "독서 타임라인 아이템") +@JsonInclude(JsonInclude.Include.NON_NULL) +public record ReadingTimelineItem( + @Schema(description = "타임라인 타입", example = "READING_RECORD") + ReadingTimelineType type, + @Schema(description = "정렬 기준 시간", example = "2026-01-05T21:38:00") + LocalDateTime eventAt, + @Schema(description = "원본 ID", example = "1") + Long sourceId, + @Schema(description = "독서 기록 데이터 (type=READING_RECORD)") + PersonalReadingRecordListResponse readingRecord, + @Schema(description = "개인 회고 데이터 (type=PERSONAL_RETROSPECTIVE)") + RetrospectiveRecordResponse retrospective, + @Schema(description = "사전 의견 데이터 (type=PRE_OPINION)") + PersonalReadingTopicAnswerResponse preOpinion +) { + public static ReadingTimelineItem readingRecord( + LocalDateTime eventAt, + Long sourceId, + PersonalReadingRecordListResponse readingRecord + ) { + return new ReadingTimelineItem( + ReadingTimelineType.READING_RECORD, + eventAt, + sourceId, + readingRecord, + null, + null + ); + } + + public static ReadingTimelineItem retrospective( + LocalDateTime eventAt, + Long sourceId, + RetrospectiveRecordResponse retrospective + ) { + return new ReadingTimelineItem( + ReadingTimelineType.PERSONAL_RETROSPECTIVE, + eventAt, + sourceId, + null, + retrospective, + null + ); + } + + public static ReadingTimelineItem preOpinion( + LocalDateTime eventAt, + Long sourceId, + PersonalReadingTopicAnswerResponse preOpinion + ) { + return new ReadingTimelineItem( + ReadingTimelineType.PRE_OPINION, + eventAt, + sourceId, + null, + null, + preOpinion + ); + } +} diff --git a/src/main/java/com/dokdok/book/dto/response/ReadingTimelineType.java b/src/main/java/com/dokdok/book/dto/response/ReadingTimelineType.java new file mode 100644 index 00000000..4a231961 --- /dev/null +++ b/src/main/java/com/dokdok/book/dto/response/ReadingTimelineType.java @@ -0,0 +1,21 @@ +package com.dokdok.book.dto.response; + +public enum ReadingTimelineType { + READING_RECORD(3), + PERSONAL_RETROSPECTIVE(2), + PRE_OPINION(1); + + private final int order; + + ReadingTimelineType(int order) { + this.order = order; + } + + public int getOrder() { + return order; + } + + public static ReadingTimelineType from(String value) { + return ReadingTimelineType.valueOf(value); + } +} diff --git a/src/main/java/com/dokdok/book/repository/PersonalReadingRecordRepository.java b/src/main/java/com/dokdok/book/repository/PersonalReadingRecordRepository.java index 47610e1e..b89c2dd2 100644 --- a/src/main/java/com/dokdok/book/repository/PersonalReadingRecordRepository.java +++ b/src/main/java/com/dokdok/book/repository/PersonalReadingRecordRepository.java @@ -15,6 +15,7 @@ public interface PersonalReadingRecordRepository extends JpaRepository findByIdAndPersonalBook_IdAndUserId(Long id, Long personalBookId, Long userId); Page findAllByPersonalBook_IdAndUserId(Long personalBookId, Long userId, Pageable pageable); long countByPersonalBook_IdAndUserId(Long personalBookId, Long userId); + List findByIdInAndPersonalBook_IdAndUserId(List ids, Long personalBookId, Long userId); @Query(""" select record diff --git a/src/main/java/com/dokdok/book/repository/ReadingTimelineRepository.java b/src/main/java/com/dokdok/book/repository/ReadingTimelineRepository.java new file mode 100644 index 00000000..38027aaf --- /dev/null +++ b/src/main/java/com/dokdok/book/repository/ReadingTimelineRepository.java @@ -0,0 +1,141 @@ +package com.dokdok.book.repository; + +import com.dokdok.book.repository.dto.ReadingTimelineIndexRow; +import jakarta.persistence.EntityManager; +import jakarta.persistence.PersistenceContext; +import jakarta.persistence.Query; +import org.springframework.stereotype.Repository; + +import java.sql.Timestamp; +import java.time.LocalDateTime; +import java.util.List; + +@Repository +public class ReadingTimelineRepository { + + @PersistenceContext + private EntityManager entityManager; + + public List findTimeline( + Long personalBookId, + Long userId, + Long bookId, + Long gatheringId, + String preOpinionTime, + LocalDateTime cursorEventAt, + Integer cursorTypeOrder, + Long cursorSourceId, + int limit + ) { + String sql = """ + WITH timeline AS ( + SELECT + prr.created_at AS event_at, + 'READING_RECORD' AS type, + prr.record_id AS source_id, + 3 AS type_order + FROM personal_reading_record prr + WHERE prr.personal_book_id = :personalBookId + AND prr.user_id = :userId + AND prr.deleted_at IS NULL + + UNION ALL + + SELECT + pmr.created_at AS event_at, + 'PERSONAL_RETROSPECTIVE' AS type, + pmr.personal_meeting_retrospective_id AS source_id, + 2 AS type_order + FROM personal_meeting_retrospective pmr + JOIN meeting m ON m.meeting_id = pmr.meeting_id + WHERE pmr.user_id = :userId + AND pmr.deleted_at IS NULL + AND m.deleted_at IS NULL + AND CAST(:gatheringId AS bigint) IS NOT NULL + AND m.gathering_id = :gatheringId + AND m.book_id = :bookId + + UNION ALL + + SELECT + pre.event_at AS event_at, + 'PRE_OPINION' AS type, + pre.meeting_id AS source_id, + 1 AS type_order + FROM ( + SELECT + m.meeting_id, + CASE + WHEN :preOpinionTime = 'ANSWER_CREATED' + THEN MAX(ta.created_at) + ELSE m.meeting_start_date + END AS event_at + FROM meeting m + JOIN topic t + ON t.meeting_id = m.meeting_id + AND t.topic_status = 'CONFIRMED' + JOIN topic_answer ta + ON ta.topic_id = t.topic_id + AND ta.user_id = :userId + AND ta.deleted_at IS NULL + WHERE m.deleted_at IS NULL + AND CAST(:gatheringId AS bigint) IS NOT NULL + AND m.gathering_id = :gatheringId + AND m.book_id = :bookId + GROUP BY m.meeting_id, m.meeting_start_date + ) pre + ) + SELECT + event_at AS eventAt, + type AS type, + source_id AS sourceId, + type_order AS typeOrder + FROM timeline + WHERE ( + CAST(:cursorEventAt AS timestamp) IS NULL + OR event_at < :cursorEventAt + OR (event_at = :cursorEventAt AND ( + type_order < :cursorTypeOrder + OR (type_order = :cursorTypeOrder AND source_id < :cursorSourceId) + )) + ) + ORDER BY event_at DESC, type_order DESC, source_id DESC + """; + + Query query = entityManager.createNativeQuery(sql); + query.setParameter("personalBookId", personalBookId); + query.setParameter("userId", userId); + query.setParameter("bookId", bookId); + query.setParameter("gatheringId", gatheringId); + query.setParameter("preOpinionTime", preOpinionTime); + query.setParameter("cursorEventAt", cursorEventAt); + query.setParameter("cursorTypeOrder", cursorTypeOrder); + query.setParameter("cursorSourceId", cursorSourceId); + query.setMaxResults(limit); + + @SuppressWarnings("unchecked") + List rows = query.getResultList(); + + return rows.stream() + .map(row -> new ReadingTimelineIndexRow( + toLocalDateTime(row[0]), + (String) row[1], + row[2] == null ? null : ((Number) row[2]).longValue(), + row[3] == null ? null : ((Number) row[3]).intValue() + )) + .toList(); + } + + private LocalDateTime toLocalDateTime(Object value) { + if (value == null) { + return null; + } + if (value instanceof LocalDateTime localDateTime) { + return localDateTime; + } + if (value instanceof Timestamp timestamp) { + return timestamp.toLocalDateTime(); + } + throw new IllegalArgumentException("Unsupported date type: " + value.getClass()); + } +} diff --git a/src/main/java/com/dokdok/book/repository/dto/ReadingTimelineIndexRow.java b/src/main/java/com/dokdok/book/repository/dto/ReadingTimelineIndexRow.java new file mode 100644 index 00000000..845baf4d --- /dev/null +++ b/src/main/java/com/dokdok/book/repository/dto/ReadingTimelineIndexRow.java @@ -0,0 +1,11 @@ +package com.dokdok.book.repository.dto; + +import java.time.LocalDateTime; + +public record ReadingTimelineIndexRow( + LocalDateTime eventAt, + String type, + Long sourceId, + Integer typeOrder +) { +} diff --git a/src/main/java/com/dokdok/book/service/ReadingTimelineService.java b/src/main/java/com/dokdok/book/service/ReadingTimelineService.java new file mode 100644 index 00000000..bc76854e --- /dev/null +++ b/src/main/java/com/dokdok/book/service/ReadingTimelineService.java @@ -0,0 +1,296 @@ +package com.dokdok.book.service; + +import com.dokdok.book.dto.request.PreOpinionTimeType; +import com.dokdok.book.dto.response.*; +import com.dokdok.book.entity.PersonalBook; +import com.dokdok.book.entity.PersonalReadingRecord; +import com.dokdok.book.repository.PersonalReadingRecordRepository; +import com.dokdok.book.repository.ReadingTimelineRepository; +import com.dokdok.book.repository.dto.ReadingTimelineIndexRow; +import com.dokdok.global.response.CursorResponse; +import com.dokdok.global.util.SecurityUtil; +import com.dokdok.meeting.entity.Meeting; +import com.dokdok.meeting.repository.MeetingRepository; +import com.dokdok.retrospective.dto.projection.ChangedThoughtProjection; +import com.dokdok.retrospective.dto.projection.FreeTextProjection; +import com.dokdok.retrospective.dto.projection.OtherPerspectiveProjection; +import com.dokdok.retrospective.dto.response.RetrospectiveRecordResponse; +import com.dokdok.retrospective.entity.PersonalMeetingRetrospective; +import com.dokdok.retrospective.repository.ChangedThoughtRepository; +import com.dokdok.retrospective.repository.FreeTextRepository; +import com.dokdok.retrospective.repository.OthersPerspectiveRepository; +import com.dokdok.retrospective.repository.PersonalRetrospectiveRepository; +import com.dokdok.retrospective.service.PersonalRetrospectiveAssembler; +import com.dokdok.topic.entity.Topic; +import com.dokdok.topic.entity.TopicAnswer; +import com.dokdok.topic.repository.TopicAnswerRepository; +import com.dokdok.topic.repository.TopicRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.time.LocalDateTime; +import java.util.Comparator; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +import static java.util.stream.Collectors.groupingBy; + +@Service +@RequiredArgsConstructor +@Transactional(readOnly = true) +public class ReadingTimelineService { + + private static final int DEFAULT_PAGE_SIZE = 10; + + private final ReadingTimelineRepository readingTimelineRepository; + private final PersonalReadingRecordRepository personalReadingRecordRepository; + private final PersonalRetrospectiveRepository personalRetrospectiveRepository; + private final ChangedThoughtRepository changedThoughtRepository; + private final OthersPerspectiveRepository othersPerspectiveRepository; + private final FreeTextRepository freeTextRepository; + private final TopicRepository topicRepository; + private final TopicAnswerRepository topicAnswerRepository; + private final MeetingRepository meetingRepository; + private final BookValidator bookValidator; + private final PersonalRetrospectiveAssembler personalRetrospectiveAssembler; + + public CursorResponse getTimeline( + Long personalBookId, + LocalDateTime cursorEventAt, + ReadingTimelineType cursorType, + Long cursorSourceId, + Integer size, + PreOpinionTimeType preOpinionTime + ) { + Long userId = SecurityUtil.getCurrentUserId(); + PersonalBook personalBook = bookValidator.validatePersonalBook(userId, personalBookId); + + Long bookId = personalBook.getBook().getId(); + Long gatheringId = personalBook.getGathering() != null + ? personalBook.getGathering().getId() + : null; + + int pageSize = resolvePageSize(size); + + boolean hasCursor = cursorEventAt != null && cursorType != null && cursorSourceId != null; + LocalDateTime cursorEventAtValue = hasCursor ? cursorEventAt : null; + Integer cursorTypeOrder = hasCursor ? cursorType.getOrder() : null; + Long cursorSourceIdValue = hasCursor ? cursorSourceId : null; + + List indexRows = readingTimelineRepository.findTimeline( + personalBookId, + userId, + bookId, + gatheringId, + (preOpinionTime != null ? preOpinionTime.name() : PreOpinionTimeType.ANSWER_CREATED.name()), + cursorEventAtValue, + cursorTypeOrder, + cursorSourceIdValue, + pageSize + 1 + ); + + boolean hasNext = indexRows.size() > pageSize; + List pageRows = hasNext + ? indexRows.subList(0, pageSize) + : indexRows; + + if (pageRows.isEmpty()) { + return CursorResponse.of(List.of(), pageSize, false, null); + } + + List readingRecordIds = pageRows.stream() + .filter(row -> ReadingTimelineType.READING_RECORD.name().equals(row.type())) + .map(ReadingTimelineIndexRow::sourceId) + .toList(); + List retrospectiveIds = pageRows.stream() + .filter(row -> ReadingTimelineType.PERSONAL_RETROSPECTIVE.name().equals(row.type())) + .map(ReadingTimelineIndexRow::sourceId) + .toList(); + List meetingIds = pageRows.stream() + .filter(row -> ReadingTimelineType.PRE_OPINION.name().equals(row.type())) + .map(ReadingTimelineIndexRow::sourceId) + .toList(); + + Map readingRecordMap = + fetchReadingRecords(readingRecordIds, personalBookId, userId); + Map retrospectiveMap = + fetchRetrospectives(retrospectiveIds, userId); + Map preOpinionMap = + fetchPreOpinions(meetingIds, userId); + + List items = pageRows.stream() + .map(row -> { + ReadingTimelineType type = ReadingTimelineType.from(row.type()); + return switch (type) { + case READING_RECORD -> ReadingTimelineItem.readingRecord( + row.eventAt(), + row.sourceId(), + readingRecordMap.get(row.sourceId()) + ); + case PERSONAL_RETROSPECTIVE -> ReadingTimelineItem.retrospective( + row.eventAt(), + row.sourceId(), + retrospectiveMap.get(row.sourceId()) + ); + case PRE_OPINION -> ReadingTimelineItem.preOpinion( + row.eventAt(), + row.sourceId(), + preOpinionMap.get(row.sourceId()) + ); + }; + }) + .toList(); + + ReadingTimelineCursor nextCursor = null; + if (hasNext) { + ReadingTimelineIndexRow last = pageRows.get(pageRows.size() - 1); + nextCursor = ReadingTimelineCursor.from( + last.eventAt(), + ReadingTimelineType.from(last.type()), + last.sourceId() + ); + } + + return CursorResponse.of(items, pageSize, hasNext, nextCursor); + } + + private Map fetchReadingRecords( + List recordIds, + Long personalBookId, + Long userId + ) { + if (recordIds.isEmpty()) { + return Map.of(); + } + List records = + personalReadingRecordRepository.findByIdInAndPersonalBook_IdAndUserId( + recordIds, personalBookId, userId + ); + Map map = new HashMap<>(); + for (PersonalReadingRecord record : records) { + map.put(record.getId(), PersonalReadingRecordListResponse.from(record)); + } + return map; + } + + private Map fetchRetrospectives( + List retrospectiveIds, + Long userId + ) { + if (retrospectiveIds.isEmpty()) { + return Map.of(); + } + + List retrospectives = + personalRetrospectiveRepository.findByIdsWithMeeting(retrospectiveIds, userId); + + if (retrospectives.isEmpty()) { + return Map.of(); + } + + List ids = retrospectives.stream() + .map(PersonalMeetingRetrospective::getId) + .toList(); + + Map> changedThoughtsMap = + changedThoughtRepository.findByRetrospectiveIds(ids) + .stream() + .collect(groupingBy(ChangedThoughtProjection::retrospectiveId)); + + Map> othersPerspectivesMap = + othersPerspectiveRepository.findByRetrospectiveIds(ids) + .stream() + .collect(groupingBy(OtherPerspectiveProjection::retrospectiveId)); + + Map> freeTextsMap = + freeTextRepository.findByRetrospectiveIds(ids) + .stream() + .collect(groupingBy(FreeTextProjection::retrospectiveId)); + + List responses = personalRetrospectiveAssembler.assembleRecords( + retrospectives, + changedThoughtsMap, + othersPerspectivesMap, + freeTextsMap + ); + + Map map = new HashMap<>(); + for (RetrospectiveRecordResponse response : responses) { + map.put(response.retrospectiveId(), response); + } + return map; + } + + private Map fetchPreOpinions( + List meetingIds, + Long userId + ) { + if (meetingIds.isEmpty()) { + return Map.of(); + } + + List meetings = meetingRepository.findByIdInWithGathering(meetingIds); + Map meetingMap = new HashMap<>(); + for (Meeting meeting : meetings) { + meetingMap.put(meeting.getId(), meeting); + } + + List topics = topicRepository.findTopicsInfoByMeetingIds(meetingIds); + Map> topicsByMeeting = topics.stream() + .collect(groupingBy(topic -> topic.getMeeting().getId())); + + List answers = topicAnswerRepository.findByMeetingIdsUserId(meetingIds, userId); + Map> answersByMeeting = new HashMap<>(); + for (TopicAnswer answer : answers) { + Long meetingId = answer.getTopic().getMeeting().getId(); + answersByMeeting + .computeIfAbsent(meetingId, key -> new HashMap<>()) + .put(answer.getTopic().getId(), answer); + } + + Map map = new HashMap<>(); + for (Long meetingId : meetingIds) { + Meeting meeting = meetingMap.get(meetingId); + if (meeting == null) { + continue; + } + List meetingTopics = topicsByMeeting.getOrDefault(meetingId, List.of()); + meetingTopics = meetingTopics.stream() + .sorted(Comparator + .comparing(Topic::getConfirmOrder, Comparator.nullsLast(Integer::compareTo)) + .thenComparing(Topic::getId)) + .toList(); + + Map answerMap = answersByMeeting.getOrDefault(meetingId, Map.of()); + List items = meetingTopics.stream() + .map(topic -> new PersonalReadingTopicAnswerResponse.TopicAnswerInfo( + topic.getTitle(), + topic.getDescription(), + topic.getConfirmOrder(), + answerMap.containsKey(topic.getId()) + ? answerMap.get(topic.getId()).getContent() + : null + )) + .toList(); + + PersonalReadingTopicAnswerResponse response = new PersonalReadingTopicAnswerResponse( + "PRE_OPINION", + meeting.getGathering().getGatheringName(), + meeting.getMeetingStartDate(), + items + ); + map.put(meetingId, response); + } + + return map; + } + + private int resolvePageSize(Integer size) { + if (size == null || size < 1) { + return DEFAULT_PAGE_SIZE; + } + return size; + } +} diff --git a/src/main/java/com/dokdok/meeting/repository/MeetingRepository.java b/src/main/java/com/dokdok/meeting/repository/MeetingRepository.java index ccf47c2f..f3753528 100644 --- a/src/main/java/com/dokdok/meeting/repository/MeetingRepository.java +++ b/src/main/java/com/dokdok/meeting/repository/MeetingRepository.java @@ -105,4 +105,12 @@ Optional findTopByGatheringIdAndBookIdAndMeetingStatusOrderByMeetingSta MeetingStatus meetingStatus ); + @EntityGraph(attributePaths = {"gathering"}) + @Query(""" + SELECT m + FROM Meeting m + WHERE m.id IN :meetingIds + """) + List findByIdInWithGathering(@Param("meetingIds") List meetingIds); + } diff --git a/src/main/java/com/dokdok/retrospective/repository/PersonalRetrospectiveRepository.java b/src/main/java/com/dokdok/retrospective/repository/PersonalRetrospectiveRepository.java index f245ff9b..2efebf84 100644 --- a/src/main/java/com/dokdok/retrospective/repository/PersonalRetrospectiveRepository.java +++ b/src/main/java/com/dokdok/retrospective/repository/PersonalRetrospectiveRepository.java @@ -1,17 +1,15 @@ package com.dokdok.retrospective.repository; -import com.querydsl.core.annotations.QueryEmbedded; +import com.dokdok.retrospective.entity.PersonalMeetingRetrospective; import org.springframework.data.domain.Pageable; +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 com.dokdok.retrospective.entity.PersonalMeetingRetrospective; -import org.springframework.data.jpa.repository.JpaRepository; - import java.time.LocalDateTime; -import java.util.Optional; import java.util.List; +import java.util.Optional; @Repository @@ -87,4 +85,16 @@ int countRetrospectivesByBookAndUser( boolean existsByIdAndUserId(Long retrospectiveId, Long userId); + @Query(""" + SELECT pmr + FROM PersonalMeetingRetrospective pmr + JOIN FETCH pmr.meeting m + JOIN FETCH m.gathering g + WHERE pmr.id IN :retrospectiveIds + AND pmr.user.id = :userId + """) + List findByIdsWithMeeting( + @Param("retrospectiveIds") List retrospectiveIds, + @Param("userId") Long userId + ); } diff --git a/src/main/java/com/dokdok/topic/repository/TopicAnswerRepository.java b/src/main/java/com/dokdok/topic/repository/TopicAnswerRepository.java index f58b4398..44880dce 100644 --- a/src/main/java/com/dokdok/topic/repository/TopicAnswerRepository.java +++ b/src/main/java/com/dokdok/topic/repository/TopicAnswerRepository.java @@ -45,6 +45,20 @@ boolean existsByMeetingIdAndUserId(@Param("meetingId") Long meetingId, """) List findByMeetingIdUserId(Long meetingId, Long userId); + @Query(""" + SELECT ta + FROM TopicAnswer ta + JOIN FETCH ta.topic t + JOIN FETCH t.meeting m + WHERE m.id IN :meetingIds + AND ta.user.id = :userId + ORDER BY m.id, t.id + """) + List findByMeetingIdsUserId( + @Param("meetingIds") List meetingIds, + @Param("userId") Long userId + ); + @Query(""" SELECT ta FROM TopicAnswer ta diff --git a/src/main/java/com/dokdok/topic/repository/TopicRepository.java b/src/main/java/com/dokdok/topic/repository/TopicRepository.java index 5de2ca2f..9bf8f7ff 100644 --- a/src/main/java/com/dokdok/topic/repository/TopicRepository.java +++ b/src/main/java/com/dokdok/topic/repository/TopicRepository.java @@ -183,6 +183,17 @@ List findTopicsInfoByMeetingId( @Param("meetingId") Long meetingId ); + @Query(""" + SELECT t + FROM Topic t + WHERE t.meeting.id IN :meetingIds + AND t.topicStatus = com.dokdok.topic.entity.TopicStatus.CONFIRMED + ORDER BY t.meeting.id, t.confirmOrder, t.id + """) + List findTopicsInfoByMeetingIds( + @Param("meetingIds") List meetingIds + ); + @Query(""" SELECT MAX(t.updatedAt) FROM Topic t