diff --git a/src/main/java/com/devkor/ifive/nadab/domain/dailyreport/api/AnswerController.java b/src/main/java/com/devkor/ifive/nadab/domain/dailyreport/api/AnswerController.java index 441223a..e7ddd2b 100644 --- a/src/main/java/com/devkor/ifive/nadab/domain/dailyreport/api/AnswerController.java +++ b/src/main/java/com/devkor/ifive/nadab/domain/dailyreport/api/AnswerController.java @@ -1,7 +1,11 @@ package com.devkor.ifive.nadab.domain.dailyreport.api; +import com.devkor.ifive.nadab.domain.dailyreport.api.dto.request.GetMonthlyCalendarRequest; import com.devkor.ifive.nadab.domain.dailyreport.api.dto.request.SearchAnswerEntryRequest; +import com.devkor.ifive.nadab.domain.dailyreport.api.dto.response.AnswerEntrySummaryResponse; +import com.devkor.ifive.nadab.domain.dailyreport.api.dto.response.CalendarRecentsResponse; import com.devkor.ifive.nadab.domain.dailyreport.api.dto.response.DailyReportResponse; +import com.devkor.ifive.nadab.domain.dailyreport.api.dto.response.MonthlyCalendarResponse; import com.devkor.ifive.nadab.domain.dailyreport.api.dto.response.SearchAnswerEntryResponse; import com.devkor.ifive.nadab.domain.dailyreport.application.AnswerQueryService; import com.devkor.ifive.nadab.domain.dailyreport.application.DailyReportQueryService; @@ -22,7 +26,9 @@ import org.springframework.security.core.annotation.AuthenticationPrincipal; import org.springframework.web.bind.annotation.*; -@Tag(name = "답변 API", description = "답변 검색 및 조회 관련 API") +import java.time.LocalDate; + +@Tag(name = "답변 API", description = "답변 검색, 조회 및 캘린더 관련 API") @RestController @RequestMapping("${api_prefix}/answers") @RequiredArgsConstructor @@ -79,7 +85,7 @@ public class AnswerController { description = "검색 성공", content = @Content(schema = @Schema(implementation = SearchAnswerEntryResponse.class)) ), - @ApiResponse(responseCode = "400", description = "잘못된 요청 (cursor 형식 오류 등)", content = @Content), + @ApiResponse(responseCode = "400", description = "ErrorCode: VALIDATION_FAILED - 잘못된 요청 (cursor 형식 오류 등)", content = @Content), @ApiResponse(responseCode = "401", description = "인증 실패", content = @Content) } ) @@ -145,4 +151,102 @@ public ResponseEntity> getDailyReportByAnswe DailyReportResponse response = dailyReportQueryService.getDailyReportByAnswerId(principal.getId(), answerId); return ApiResponseEntity.ok(response); } + + @GetMapping("/calendar") + @PreAuthorize("isAuthenticated()") + @Operation( + summary = "월별 캘린더 조회", + description = """ + 특정 월의 답변이 있는 날짜와 감정 코드를 조회합니다. + + - 답변이 없는 날짜는 결과에 포함되지 않습니다. + - emotionCode는 리포트가 COMPLETED 상태일 때만 제공됩니다. + - 리포트가 없거나 PENDING/FAILED 상태면 null입니다. + """, + security = @SecurityRequirement(name = "bearerAuth"), + responses = { + @ApiResponse( + responseCode = "200", + description = "조회 성공", + content = @Content(schema = @Schema(implementation = MonthlyCalendarResponse.class)) + ), + @ApiResponse(responseCode = "400", description = "ErrorCode: VALIDATION_FAILED - 잘못된 요청 (연도/월 범위 오류)", content = @Content), + @ApiResponse(responseCode = "401", description = "인증 실패", content = @Content) + } + ) + public ResponseEntity> getMonthlyCalendar( + @AuthenticationPrincipal UserPrincipal principal, + @Valid @ModelAttribute GetMonthlyCalendarRequest request + ) { + MonthlyCalendarResponse response = answerQueryService.getMonthlyCalendar( + principal.getId(), + request + ); + return ApiResponseEntity.ok(response); + } + + @GetMapping("/calendar/recents") + @PreAuthorize("isAuthenticated()") + @Operation( + summary = "캘린더 최근 기록 미리보기", + description = """ + 최근 답변을 최대 6개까지 조회합니다. (날짜 내림차순) + + - 답변이 6개 미만인 경우 전체 답변을 반환합니다. + - 답변이 없는 경우 빈 배열을 반환합니다. + """, + security = @SecurityRequirement(name = "bearerAuth"), + responses = { + @ApiResponse( + responseCode = "200", + description = "조회 성공", + content = @Content(schema = @Schema(implementation = CalendarRecentsResponse.class)) + ), + @ApiResponse(responseCode = "401", description = "인증 실패", content = @Content) + } + ) + public ResponseEntity> getCalendarRecents( + @AuthenticationPrincipal UserPrincipal principal + ) { + CalendarRecentsResponse response = answerQueryService.getRecentAnswers(principal.getId()); + return ApiResponseEntity.ok(response); + } + + + @GetMapping("/calendar/{date}") + @PreAuthorize("isAuthenticated()") + @Operation( + summary = "특정 날짜 답변 조회", + description = """ + 특정 날짜의 답변 미리보기를 조회합니다. + + - 해당 날짜에 답변이 없으면 404 에러를 반환합니다. + - 날짜 형식: yyyy-MM-dd (예: 2026-01-30) + """, + security = @SecurityRequirement(name = "bearerAuth"), + responses = { + @ApiResponse( + responseCode = "200", + description = "조회 성공", + content = @Content(schema = @Schema(implementation = AnswerEntrySummaryResponse.class)) + ), + @ApiResponse(responseCode = "400", description = "ErrorCode: VALIDATION_FAILED - 잘못된 날짜 형식", content = @Content), + @ApiResponse(responseCode = "401", description = "인증 실패", content = @Content), + @ApiResponse( + responseCode = "404", + description = "ErrorCode: ANSWER_NOT_FOUND - 해당 날짜에 답변이 없음", + content = @Content + ) + } + ) + public ResponseEntity> getAnswerByDate( + @AuthenticationPrincipal UserPrincipal principal, + @PathVariable LocalDate date + ) { + AnswerEntrySummaryResponse response = answerQueryService.getAnswerByDate( + principal.getId(), + date + ); + return ApiResponseEntity.ok(response); + } } \ No newline at end of file diff --git a/src/main/java/com/devkor/ifive/nadab/domain/dailyreport/api/dto/request/GetMonthlyCalendarRequest.java b/src/main/java/com/devkor/ifive/nadab/domain/dailyreport/api/dto/request/GetMonthlyCalendarRequest.java new file mode 100644 index 0000000..385991b --- /dev/null +++ b/src/main/java/com/devkor/ifive/nadab/domain/dailyreport/api/dto/request/GetMonthlyCalendarRequest.java @@ -0,0 +1,20 @@ +package com.devkor.ifive.nadab.domain.dailyreport.api.dto.request; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.Max; +import jakarta.validation.constraints.Min; +import jakarta.validation.constraints.NotNull; + +@Schema(description = "월별 캘린더 조회 요청") +public record GetMonthlyCalendarRequest( + @Schema(description = "연도", example = "2026") + @NotNull(message = "연도는 필수입니다") + Integer year, + + @Schema(description = "월 (1~12)", example = "1") + @NotNull(message = "월은 필수입니다") + @Min(value = 1, message = "월은 1~12 사이의 값이어야 합니다") + @Max(value = 12, message = "월은 1~12 사이의 값이어야 합니다") + Integer month +) { +} diff --git a/src/main/java/com/devkor/ifive/nadab/domain/dailyreport/api/dto/request/SearchAnswerEntryRequest.java b/src/main/java/com/devkor/ifive/nadab/domain/dailyreport/api/dto/request/SearchAnswerEntryRequest.java index 702149b..cae270c 100644 --- a/src/main/java/com/devkor/ifive/nadab/domain/dailyreport/api/dto/request/SearchAnswerEntryRequest.java +++ b/src/main/java/com/devkor/ifive/nadab/domain/dailyreport/api/dto/request/SearchAnswerEntryRequest.java @@ -9,7 +9,7 @@ public record SearchAnswerEntryRequest( @Size(min = 1, max = 100, message = "검색 키워드는 1~100자입니다") String keyword, - @Schema(description = "감정 코드 (JOY, PLEASURE, SADNESS, ANGER, REGRET, FRUSTRATION, GROWTH, ETC)", example = "JOY") + @Schema(description = "감정 코드 (ACHIEVEMENT, INTEREST, PEACE, PLEASURE, WILL, DEPRESSION, REGRET, ETC)", example = "ACHIEVEMENT") String emotionCode, @Schema(description = "다음 페이지 커서 (형식: date)", example = "2025-12-25") diff --git a/src/main/java/com/devkor/ifive/nadab/domain/dailyreport/api/dto/response/AnswerEntrySummaryResponse.java b/src/main/java/com/devkor/ifive/nadab/domain/dailyreport/api/dto/response/AnswerEntrySummaryResponse.java index bbd895a..27bde26 100644 --- a/src/main/java/com/devkor/ifive/nadab/domain/dailyreport/api/dto/response/AnswerEntrySummaryResponse.java +++ b/src/main/java/com/devkor/ifive/nadab/domain/dailyreport/api/dto/response/AnswerEntrySummaryResponse.java @@ -14,7 +14,7 @@ public record AnswerEntrySummaryResponse( @Schema(description = "관심분야 코드 (PREFERENCE, EMOTION, ROUTINE, RELATIONSHIP, LOVE, VALUES)", example = "EMOTION") String interestCode, - @Schema(description = "감정 코드 (리포트 생성 완료 시에만 제공, PENDING/FAILED 상태면 null)", example = "JOY") + @Schema(description = "감정 코드 (리포트 생성 완료 시에만 제공, PENDING/FAILED 상태면 null)", example = "ACHIEVEMENT") String emotionCode, @Schema(description = "질문 내용", example = "오늘 가장 기뻤던 순간은?") @@ -26,7 +26,10 @@ public record AnswerEntrySummaryResponse( @Schema(description = "답변 작성일", example = "2025-12-25") LocalDate answerDate ) { - public static AnswerEntrySummaryResponse toResponse(SearchAnswerEntryDto dto, String keyword) { + /** + * 검색 키워드가 있는 경우 (키워드 포함 문장 추출) + */ + public static AnswerEntrySummaryResponse from(SearchAnswerEntryDto dto, String keyword) { String snippet = MatchedSnippetExtractor.extract(dto.answerContent(), keyword); return new AnswerEntrySummaryResponse( @@ -38,4 +41,11 @@ public static AnswerEntrySummaryResponse toResponse(SearchAnswerEntryDto dto, St dto.answerDate() ); } + + /** + * 검색 키워드가 없는 경우 (첫 문장 추출) + */ + public static AnswerEntrySummaryResponse from(SearchAnswerEntryDto dto) { + return from(dto, null); + } } \ No newline at end of file diff --git a/src/main/java/com/devkor/ifive/nadab/domain/dailyreport/api/dto/response/CalendarEntryResponse.java b/src/main/java/com/devkor/ifive/nadab/domain/dailyreport/api/dto/response/CalendarEntryResponse.java new file mode 100644 index 0000000..4c3944a --- /dev/null +++ b/src/main/java/com/devkor/ifive/nadab/domain/dailyreport/api/dto/response/CalendarEntryResponse.java @@ -0,0 +1,22 @@ +package com.devkor.ifive.nadab.domain.dailyreport.api.dto.response; + +import com.devkor.ifive.nadab.domain.dailyreport.core.dto.MonthlyCalendarDto; +import io.swagger.v3.oas.annotations.media.Schema; + +import java.time.LocalDate; + +@Schema(description = "캘린더 날짜별 정보") +public record CalendarEntryResponse( + @Schema(description = "답변 날짜", example = "2026-01-30") + LocalDate date, + + @Schema(description = "감정 코드 (답변이 있고 리포트가 COMPLETED 상태일 때만 제공)", example = "ACHIEVEMENT") + String emotionCode +) { + public static CalendarEntryResponse from(MonthlyCalendarDto dto) { + return new CalendarEntryResponse( + dto.date(), + dto.emotionCode() != null ? dto.emotionCode().name() : null + ); + } +} diff --git a/src/main/java/com/devkor/ifive/nadab/domain/dailyreport/api/dto/response/CalendarRecentsResponse.java b/src/main/java/com/devkor/ifive/nadab/domain/dailyreport/api/dto/response/CalendarRecentsResponse.java new file mode 100644 index 0000000..23f456b --- /dev/null +++ b/src/main/java/com/devkor/ifive/nadab/domain/dailyreport/api/dto/response/CalendarRecentsResponse.java @@ -0,0 +1,15 @@ +package com.devkor.ifive.nadab.domain.dailyreport.api.dto.response; + +import io.swagger.v3.oas.annotations.media.Schema; + +import java.util.List; + +@Schema(description = "캘린더 최근 답변 미리보기 응답") +public record CalendarRecentsResponse( + @Schema(description = "최근 답변 목록 (최대 6개, 날짜 내림차순)") + List items +) { + public static CalendarRecentsResponse from(List items) { + return new CalendarRecentsResponse(items); + } +} diff --git a/src/main/java/com/devkor/ifive/nadab/domain/dailyreport/api/dto/response/MonthlyCalendarResponse.java b/src/main/java/com/devkor/ifive/nadab/domain/dailyreport/api/dto/response/MonthlyCalendarResponse.java new file mode 100644 index 0000000..bf4d867 --- /dev/null +++ b/src/main/java/com/devkor/ifive/nadab/domain/dailyreport/api/dto/response/MonthlyCalendarResponse.java @@ -0,0 +1,12 @@ +package com.devkor.ifive.nadab.domain.dailyreport.api.dto.response; + +import io.swagger.v3.oas.annotations.media.Schema; + +import java.util.List; + +@Schema(description = "월별 캘린더 응답") +public record MonthlyCalendarResponse( + @Schema(description = "답변이 있는 날짜의 정보 목록 (답변 없는 날짜는 포함되지 않음)") + List calendarEntries +) { +} diff --git a/src/main/java/com/devkor/ifive/nadab/domain/dailyreport/application/AnswerQueryService.java b/src/main/java/com/devkor/ifive/nadab/domain/dailyreport/application/AnswerQueryService.java index 77e2144..06a2a66 100644 --- a/src/main/java/com/devkor/ifive/nadab/domain/dailyreport/application/AnswerQueryService.java +++ b/src/main/java/com/devkor/ifive/nadab/domain/dailyreport/application/AnswerQueryService.java @@ -1,12 +1,21 @@ package com.devkor.ifive.nadab.domain.dailyreport.application; +import com.devkor.ifive.nadab.domain.dailyreport.api.dto.request.GetMonthlyCalendarRequest; import com.devkor.ifive.nadab.domain.dailyreport.api.dto.request.SearchAnswerEntryRequest; import com.devkor.ifive.nadab.domain.dailyreport.api.dto.response.AnswerEntrySummaryResponse; +import com.devkor.ifive.nadab.domain.dailyreport.api.dto.response.CalendarEntryResponse; +import com.devkor.ifive.nadab.domain.dailyreport.api.dto.response.CalendarRecentsResponse; +import com.devkor.ifive.nadab.domain.dailyreport.api.dto.response.MonthlyCalendarResponse; import com.devkor.ifive.nadab.domain.dailyreport.api.dto.response.SearchAnswerEntryResponse; +import com.devkor.ifive.nadab.domain.dailyreport.core.dto.MonthlyCalendarDto; import com.devkor.ifive.nadab.domain.dailyreport.core.dto.SearchAnswerEntryDto; import com.devkor.ifive.nadab.domain.dailyreport.core.entity.EmotionCode; import com.devkor.ifive.nadab.domain.dailyreport.core.repository.AnswerEntryQueryRepository; import com.devkor.ifive.nadab.domain.dailyreport.application.helper.CursorParser; +import com.devkor.ifive.nadab.global.core.response.ErrorCode; +import com.devkor.ifive.nadab.global.exception.NotFoundException; +import com.devkor.ifive.nadab.global.shared.util.MonthRangeCalculator; +import com.devkor.ifive.nadab.global.shared.util.dto.MonthRangeDto; import lombok.RequiredArgsConstructor; import org.springframework.data.domain.PageRequest; import org.springframework.data.domain.Pageable; @@ -64,7 +73,7 @@ public SearchAnswerEntryResponse searchAnswers(Long userId, SearchAnswerEntryReq // DTO 변환 List responseItems = items.stream() - .map(dto -> AnswerEntrySummaryResponse.toResponse(dto, request.keyword())) + .map(dto -> AnswerEntrySummaryResponse.from(dto, request.keyword())) .toList(); // nextCursor 생성 @@ -77,6 +86,50 @@ public SearchAnswerEntryResponse searchAnswers(Long userId, SearchAnswerEntryReq return new SearchAnswerEntryResponse(responseItems, nextCursor, hasNext); } + public MonthlyCalendarResponse getMonthlyCalendar(Long userId, GetMonthlyCalendarRequest request) { + // 월의 시작/종료 날짜 계산 + LocalDate anyDayInMonth = LocalDate.of(request.year(), request.month(), 1); + MonthRangeDto range = MonthRangeCalculator.monthRangeOf(anyDayInMonth); + + // 월별 데이터 조회 + List results = answerEntryQueryRepository.findCalendarEntriesInMonth( + userId, + range.monthStartDate(), + range.monthEndDate() + ); + + // DTO 변환 + List calendarEntries = results.stream() + .map(CalendarEntryResponse::from) + .toList(); + + return new MonthlyCalendarResponse(calendarEntries); + } + + public CalendarRecentsResponse getRecentAnswers(Long userId) { + // 최근 6개만 조회 (페이지 번호 0, 크기 6) + Pageable pageable = PageRequest.of(0, 6); + + List results = answerEntryQueryRepository.findRecentAnswers( + userId, + pageable + ); + + // DTO 변환 + List items = results.stream() + .map(AnswerEntrySummaryResponse::from) + .toList(); + + return CalendarRecentsResponse.from(items); + } + + public AnswerEntrySummaryResponse getAnswerByDate(Long userId, LocalDate date) { + SearchAnswerEntryDto dto = answerEntryQueryRepository.findByUserAndDate(userId, date) + .orElseThrow(() -> new NotFoundException(ErrorCode.ANSWER_NOT_FOUND)); + + return AnswerEntrySummaryResponse.from(dto); + } + /** * LIKE 검색용 키워드 준비 (이스케이핑 + % 추가) */ diff --git a/src/main/java/com/devkor/ifive/nadab/domain/dailyreport/core/dto/MonthlyCalendarDto.java b/src/main/java/com/devkor/ifive/nadab/domain/dailyreport/core/dto/MonthlyCalendarDto.java new file mode 100644 index 0000000..b71cb59 --- /dev/null +++ b/src/main/java/com/devkor/ifive/nadab/domain/dailyreport/core/dto/MonthlyCalendarDto.java @@ -0,0 +1,14 @@ +package com.devkor.ifive.nadab.domain.dailyreport.core.dto; + +import com.devkor.ifive.nadab.domain.dailyreport.core.entity.EmotionCode; + +import java.time.LocalDate; + +/** + * 월별 캘린더 쿼리 결과 DTO + */ +public record MonthlyCalendarDto( + LocalDate date, + EmotionCode emotionCode // null 가능 (리포트가 없거나 PENDING/FAILED 상태) +) { +} diff --git a/src/main/java/com/devkor/ifive/nadab/domain/dailyreport/core/repository/AnswerEntryQueryRepository.java b/src/main/java/com/devkor/ifive/nadab/domain/dailyreport/core/repository/AnswerEntryQueryRepository.java index 13e97bf..b7a4742 100644 --- a/src/main/java/com/devkor/ifive/nadab/domain/dailyreport/core/repository/AnswerEntryQueryRepository.java +++ b/src/main/java/com/devkor/ifive/nadab/domain/dailyreport/core/repository/AnswerEntryQueryRepository.java @@ -1,5 +1,6 @@ package com.devkor.ifive.nadab.domain.dailyreport.core.repository; +import com.devkor.ifive.nadab.domain.dailyreport.core.dto.MonthlyCalendarDto; import com.devkor.ifive.nadab.domain.dailyreport.core.dto.SearchAnswerEntryDto; import com.devkor.ifive.nadab.domain.dailyreport.core.entity.AnswerEntry; import com.devkor.ifive.nadab.domain.dailyreport.core.entity.EmotionCode; @@ -10,6 +11,7 @@ import java.time.LocalDate; import java.util.List; +import java.util.Optional; public interface AnswerEntryQueryRepository extends Repository { @@ -58,4 +60,61 @@ List searchAnswerEntriesWithCursor( @Param("cursorDate") LocalDate cursorDate, Pageable pageable ); + + /** + * 월별 캘린더 데이터 조회 + */ + @Query(""" + select new com.devkor.ifive.nadab.domain.dailyreport.core.dto.MonthlyCalendarDto( + ae.date, e.code + ) + from AnswerEntry ae + left join DailyReport dr on dr.answerEntry = ae and dr.status = com.devkor.ifive.nadab.domain.dailyreport.core.entity.DailyReportStatus.COMPLETED + left join dr.emotion e + where ae.user.id = :userId + and ae.date >= :startDate + and ae.date <= :endDate + order by ae.date asc + """) + List findCalendarEntriesInMonth( + @Param("userId") Long userId, + @Param("startDate") LocalDate startDate, + @Param("endDate") LocalDate endDate + ); + + /** + * 최근 N개 답변 조회 (날짜 내림차순) + */ + @Query(""" + select new com.devkor.ifive.nadab.domain.dailyreport.core.dto.SearchAnswerEntryDto( + ae.id, ae.question.interest.code, e.code, ae.question.questionText, ae.content, ae.date + ) + from AnswerEntry ae + left join DailyReport dr on dr.answerEntry = ae and dr.status = com.devkor.ifive.nadab.domain.dailyreport.core.entity.DailyReportStatus.COMPLETED + left join dr.emotion e + where ae.user.id = :userId + order by ae.date desc + """) + List findRecentAnswers( + @Param("userId") Long userId, + Pageable pageable + ); + + /** + * 특정 날짜 답변 조회 + */ + @Query(""" + select new com.devkor.ifive.nadab.domain.dailyreport.core.dto.SearchAnswerEntryDto( + ae.id, ae.question.interest.code, e.code, ae.question.questionText, ae.content, ae.date + ) + from AnswerEntry ae + left join DailyReport dr on dr.answerEntry = ae and dr.status = com.devkor.ifive.nadab.domain.dailyreport.core.entity.DailyReportStatus.COMPLETED + left join dr.emotion e + where ae.user.id = :userId + and ae.date = :date + """) + Optional findByUserAndDate( + @Param("userId") Long userId, + @Param("date") LocalDate date + ); } \ No newline at end of file diff --git a/src/main/java/com/devkor/ifive/nadab/global/exception/ExceptionController.java b/src/main/java/com/devkor/ifive/nadab/global/exception/ExceptionController.java index e50e715..d052928 100644 --- a/src/main/java/com/devkor/ifive/nadab/global/exception/ExceptionController.java +++ b/src/main/java/com/devkor/ifive/nadab/global/exception/ExceptionController.java @@ -17,6 +17,7 @@ import org.springframework.web.bind.MethodArgumentNotValidException; import org.springframework.web.bind.annotation.ExceptionHandler; import org.springframework.web.bind.annotation.RestControllerAdvice; +import org.springframework.web.method.annotation.MethodArgumentTypeMismatchException; import software.amazon.awssdk.services.s3.model.NoSuchKeyException; import java.util.stream.Collectors; @@ -56,6 +57,16 @@ public ResponseEntity> handleMethodArgumentNotValidExc return ApiResponseEntity.error(ErrorCode.VALIDATION_FAILED, validationErrors); } + @ExceptionHandler(MethodArgumentTypeMismatchException.class) + public ResponseEntity> handleMethodArgumentTypeMismatchException(MethodArgumentTypeMismatchException ex) { + log.warn("MethodArgumentTypeMismatchException: {}", ex.getMessage(), ex); + + String parameterName = ex.getName(); + String errorMessage = String.format("%s 형식이 올바르지 않습니다. 올바른 형식으로 입력해주세요.", parameterName); + + return ApiResponseEntity.error(ErrorCode.VALIDATION_FAILED, errorMessage); + } + @ExceptionHandler(UnauthorizedException.class) public ResponseEntity> handleUnauthorizedException(UnauthorizedException ex) { log.warn("UnauthorizedException: {}", ex.getMessage(), ex);