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
Original file line number Diff line number Diff line change
@@ -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;
Expand All @@ -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
Expand Down Expand Up @@ -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)
}
)
Expand Down Expand Up @@ -145,4 +151,102 @@ public ResponseEntity<ApiResponseDto<DailyReportResponse>> 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<ApiResponseDto<MonthlyCalendarResponse>> 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<ApiResponseDto<CalendarRecentsResponse>> 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<ApiResponseDto<AnswerEntrySummaryResponse>> getAnswerByDate(
@AuthenticationPrincipal UserPrincipal principal,
@PathVariable LocalDate date
) {
AnswerEntrySummaryResponse response = answerQueryService.getAnswerByDate(
principal.getId(),
date
);
return ApiResponseEntity.ok(response);
}
}
Original file line number Diff line number Diff line change
@@ -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
) {
}
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 = "오늘 가장 기뻤던 순간은?")
Expand All @@ -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(
Expand All @@ -38,4 +41,11 @@ public static AnswerEntrySummaryResponse toResponse(SearchAnswerEntryDto dto, St
dto.answerDate()
);
}

/**
* 검색 키워드가 없는 경우 (첫 문장 추출)
*/
public static AnswerEntrySummaryResponse from(SearchAnswerEntryDto dto) {
return from(dto, null);
}
}
Original file line number Diff line number Diff line change
@@ -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
);
}
}
Original file line number Diff line number Diff line change
@@ -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<AnswerEntrySummaryResponse> items
) {
public static CalendarRecentsResponse from(List<AnswerEntrySummaryResponse> items) {
return new CalendarRecentsResponse(items);
}
}
Original file line number Diff line number Diff line change
@@ -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<CalendarEntryResponse> calendarEntries
) {
}
Original file line number Diff line number Diff line change
@@ -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;
Expand Down Expand Up @@ -64,7 +73,7 @@ public SearchAnswerEntryResponse searchAnswers(Long userId, SearchAnswerEntryReq

// DTO 변환
List<AnswerEntrySummaryResponse> responseItems = items.stream()
.map(dto -> AnswerEntrySummaryResponse.toResponse(dto, request.keyword()))
.map(dto -> AnswerEntrySummaryResponse.from(dto, request.keyword()))
.toList();

// nextCursor 생성
Expand All @@ -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<MonthlyCalendarDto> results = answerEntryQueryRepository.findCalendarEntriesInMonth(
userId,
range.monthStartDate(),
range.monthEndDate()
);

// DTO 변환
List<CalendarEntryResponse> 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<SearchAnswerEntryDto> results = answerEntryQueryRepository.findRecentAnswers(
userId,
pageable
);

// DTO 변환
List<AnswerEntrySummaryResponse> 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 검색용 키워드 준비 (이스케이핑 + % 추가)
*/
Expand Down
Original file line number Diff line number Diff line change
@@ -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 상태)
) {
}
Loading