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
Expand Up @@ -2,14 +2,11 @@

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.AnswerDetailResponse;
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;
import com.devkor.ifive.nadab.domain.search.application.SearchHistoryCommandService;
import com.devkor.ifive.nadab.global.core.response.ApiResponseDto;
import com.devkor.ifive.nadab.global.core.response.ApiResponseEntity;
import com.devkor.ifive.nadab.global.security.principal.UserPrincipal;
Expand All @@ -34,9 +31,7 @@
@RequiredArgsConstructor
public class AnswerController {

private final DailyReportQueryService dailyReportQueryService;
private final AnswerQueryService answerQueryService;
private final SearchHistoryCommandService searchHistoryCommandService;

@GetMapping
@PreAuthorize("isAuthenticated()")
Expand Down Expand Up @@ -74,9 +69,9 @@ public class AnswerController {
- GET /api/v1/answers?keyword=행복&emotionCode=JOY&cursor=2025-12-06

### 참고사항
- 검색어는 자동으로 저장됩니다.
- keyword와 emotionCode는 동시에 사용 가능합니다.
- emotionCode만 사용 시 keyword는 생략 가능합니다.
- 검색어 저장은 POST /api/v1/search/histories 엔드포인트를 통해 별도로 수행할 수 있습니다.
""",
security = @SecurityRequirement(name = "bearerAuth"),
responses = {
Expand All @@ -93,12 +88,7 @@ public ResponseEntity<ApiResponseDto<SearchAnswerEntryResponse>> searchAnswers(
@AuthenticationPrincipal UserPrincipal principal,
@Valid @ModelAttribute SearchAnswerEntryRequest request
) {
// 검색 실행
SearchAnswerEntryResponse response = answerQueryService.searchAnswers(principal.getId(), request);

// 검색어 저장 (동기)
searchHistoryCommandService.saveOrRefreshSearchHistory(principal.getId(), request.keyword());

return ApiResponseEntity.ok(response);
}

Expand All @@ -109,6 +99,9 @@ public ResponseEntity<ApiResponseDto<SearchAnswerEntryResponse>> searchAnswers(
description = """
답변 ID로 해당 답변의 상세 정보와 리포트를 조회합니다.

- 질문 내용 (questionText)
- 질문 카테고리 (interestCode)
- 답변 작성일 (answerDate)
- 답변 내용 (answer)
- 리포트 내용 (content)
- 감정 상태 (emotion)
Expand All @@ -119,36 +112,28 @@ public ResponseEntity<ApiResponseDto<SearchAnswerEntryResponse>> searchAnswers(
responses = {
@ApiResponse(
responseCode = "200",
description = "리포트 조회 성공",
content = @Content(schema = @Schema(implementation = DailyReportResponse.class), mediaType = "application/json")
description = "상세 조회 성공",
content = @Content(schema = @Schema(implementation = AnswerDetailResponse.class), mediaType = "application/json")
),
@ApiResponse(
responseCode = "401",
description = "인증 실패",
content = @Content
),
@ApiResponse(
responseCode = "403",
description = """
- ErrorCode: ANSWER_ACCESS_FORBIDDEN - 본인의 답변이 아님
""",
content = @Content
),
@ApiResponse(
responseCode = "404",
description = """
- ErrorCode: ANSWER_NOT_FOUND - 답변을 찾을 수 없음
- ErrorCode: DAILY_REPORT_NOT_FOUND - 리포트가 생성되지 않았음
- ErrorCode: ANSWER_NOT_FOUND - 답변을 찾을 수 없음 (또는 본인의 답변이 아님)
""",
content = @Content
)
}
)
public ResponseEntity<ApiResponseDto<DailyReportResponse>> getDailyReportByAnswerId(
public ResponseEntity<ApiResponseDto<AnswerDetailResponse>> getAnswerDetailById(
@AuthenticationPrincipal UserPrincipal principal,
@PathVariable Long answerId
) {
DailyReportResponse response = dailyReportQueryService.getDailyReportByAnswerId(principal.getId(), answerId);
AnswerDetailResponse response = answerQueryService.getAnswerDetailById(principal.getId(), answerId);
return ApiResponseEntity.ok(response);
}

Expand Down Expand Up @@ -216,19 +201,28 @@ public ResponseEntity<ApiResponseDto<CalendarRecentsResponse>> getCalendarRecent
@GetMapping("/calendar/{date}")
@PreAuthorize("isAuthenticated()")
@Operation(
summary = "특정 날짜 답변 조회",
summary = "특정 날짜 답변 상세 조회",
description = """
특정 날짜의 답변 미리보기를 조회합니다.
특정 날짜의 답변 전체 정보를 조회합니다.

응답 데이터:
- 질문 내용 (questionText)
- 질문 카테고리 (interestCode)
- 답변 작성일 (answerDate)
- 답변 내용 (answer)
- 리포트 내용 (content)
- 감정 상태 (emotion)

- 해당 날짜에 답변이 없으면 404 에러를 반환합니다.
- COMPLETED 상태의 리포트만 조회 가능합니다.
- 날짜 형식: yyyy-MM-dd (예: 2026-01-30)
""",
security = @SecurityRequirement(name = "bearerAuth"),
responses = {
@ApiResponse(
responseCode = "200",
description = "조회 성공",
content = @Content(schema = @Schema(implementation = AnswerEntrySummaryResponse.class))
content = @Content(schema = @Schema(implementation = AnswerDetailResponse.class))
),
@ApiResponse(responseCode = "400", description = "ErrorCode: VALIDATION_FAILED - 잘못된 날짜 형식", content = @Content),
@ApiResponse(responseCode = "401", description = "인증 실패", content = @Content),
Expand All @@ -239,11 +233,11 @@ public ResponseEntity<ApiResponseDto<CalendarRecentsResponse>> getCalendarRecent
)
}
)
public ResponseEntity<ApiResponseDto<AnswerEntrySummaryResponse>> getAnswerByDate(
public ResponseEntity<ApiResponseDto<AnswerDetailResponse>> getAnswerDetailByDate(
@AuthenticationPrincipal UserPrincipal principal,
@PathVariable LocalDate date
) {
AnswerEntrySummaryResponse response = answerQueryService.getAnswerByDate(
AnswerDetailResponse response = answerQueryService.getAnswerDetailByDate(
principal.getId(),
date
);
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
package com.devkor.ifive.nadab.domain.dailyreport.api.dto.response;

import com.devkor.ifive.nadab.domain.dailyreport.core.dto.AnswerDetailDto;
import io.swagger.v3.oas.annotations.media.Schema;

import java.time.LocalDate;

@Schema(description = "답변 상세 조회 응답")
public record AnswerDetailResponse(

@Schema(description = "질문 내용", example = "오늘 가장 기뻤던 순간은?")
String questionText,

@Schema(description = "질문 카테고리 (관심분야 코드)", example = "EMOTION")
String interestCode,

@Schema(description = "답변 작성일", example = "2025-12-25")
LocalDate answerDate,

@Schema(description = "나의 답변")
String answer,

@Schema(description = "리포트 내용")
String content,

@Schema(description = "리포트 감정 상태", example = "ACHIEVEMENT")
String emotion
) {
public static AnswerDetailResponse from(AnswerDetailDto dto) {
return new AnswerDetailResponse(
dto.questionText(),
dto.interestCode() != null ? dto.interestCode().name() : null,
dto.answerDate(),
dto.answerContent(),
dto.reportContent(),
dto.emotionCode() != null ? dto.emotionCode().name() : null
);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -2,11 +2,13 @@

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.AnswerDetailResponse;
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.AnswerDetailDto;
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;
Expand Down Expand Up @@ -123,11 +125,18 @@ public CalendarRecentsResponse getRecentAnswers(Long userId) {
return CalendarRecentsResponse.from(items);
}

public AnswerEntrySummaryResponse getAnswerByDate(Long userId, LocalDate date) {
SearchAnswerEntryDto dto = answerEntryQueryRepository.findByUserAndDate(userId, date)
public AnswerDetailResponse getAnswerDetailById(Long userId, Long answerId) {
AnswerDetailDto dto = answerEntryQueryRepository.findDetailByAnswerId(userId, answerId)
.orElseThrow(() -> new NotFoundException(ErrorCode.ANSWER_NOT_FOUND));

return AnswerEntrySummaryResponse.from(dto);
return AnswerDetailResponse.from(dto);
}

public AnswerDetailResponse getAnswerDetailByDate(Long userId, LocalDate date) {
AnswerDetailDto dto = answerEntryQueryRepository.findDetailByUserAndDate(userId, date)
.orElseThrow(() -> new NotFoundException(ErrorCode.ANSWER_NOT_FOUND));

return AnswerDetailResponse.from(dto);
}

/**
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,6 @@
import com.devkor.ifive.nadab.domain.user.core.entity.User;
import com.devkor.ifive.nadab.domain.user.core.repository.UserRepository;
import com.devkor.ifive.nadab.global.core.response.ErrorCode;
import com.devkor.ifive.nadab.global.exception.ForbiddenException;
import com.devkor.ifive.nadab.global.exception.NotFoundException;
import com.devkor.ifive.nadab.global.shared.util.TodayDateTimeProvider;
import com.devkor.ifive.nadab.global.shared.util.dto.TodayDateTimeRangeDto;
Expand Down Expand Up @@ -44,24 +43,4 @@ public DailyReportResponse getDailyReport(Long id) {
report.getEmotion().getCode().toString()
);
}

public DailyReportResponse getDailyReportByAnswerId(Long userId, Long answerId) {
// 답변 조회 및 권한 확인
AnswerEntry entry = answerEntryRepository.findById(answerId)
.orElseThrow(() -> new NotFoundException(ErrorCode.ANSWER_NOT_FOUND));

if (!entry.getUser().getId().equals(userId)) {
throw new ForbiddenException(ErrorCode.ANSWER_ACCESS_FORBIDDEN);
}

// 리포트 조회
DailyReport report = dailyReportRepository.findByAnswerEntryIdAndCompleted(answerId)
.orElseThrow(() -> new NotFoundException(ErrorCode.DAILY_REPORT_NOT_FOUND));

return new DailyReportResponse(
entry.getContent(),
report.getContent(),
report.getEmotion().getCode().toString()
);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
package com.devkor.ifive.nadab.domain.dailyreport.core.dto;

import com.devkor.ifive.nadab.domain.dailyreport.core.entity.EmotionCode;
import com.devkor.ifive.nadab.domain.user.core.entity.InterestCode;

import java.time.LocalDate;

/**
* 답변 상세 조회용 Projection DTO
* Repository 쿼리 결과를 담는 DTO
*/
public record AnswerDetailDto(
String questionText,
InterestCode interestCode,
LocalDate answerDate,
String answerContent,
String reportContent,
EmotionCode emotionCode
) {
}
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
package com.devkor.ifive.nadab.domain.dailyreport.core.repository;

import com.devkor.ifive.nadab.domain.dailyreport.core.dto.AnswerDetailDto;
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;
Expand Down Expand Up @@ -100,20 +101,39 @@ List<SearchAnswerEntryDto> findRecentAnswers(
Pageable pageable
);


/**
* 특정 날짜 답변 조회
* 답변 ID로 상세 조회
*/
@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
select new com.devkor.ifive.nadab.domain.dailyreport.core.dto.AnswerDetailDto(
ae.question.questionText, ae.question.interest.code, ae.date, ae.content, dr.content, 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.id = :answerId
and ae.user.id = :userId
""")
Optional<AnswerDetailDto> findDetailByAnswerId(
@Param("userId") Long userId,
@Param("answerId") Long answerId
);

/**
* 날짜로 상세 조회
*/
@Query("""
select new com.devkor.ifive.nadab.domain.dailyreport.core.dto.AnswerDetailDto(
ae.question.questionText, ae.question.interest.code, ae.date, ae.content, dr.content, 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 = :date
""")
Optional<SearchAnswerEntryDto> findByUserAndDate(
Optional<AnswerDetailDto> findDetailByUserAndDate(
@Param("userId") Long userId,
@Param("date") LocalDate date
);
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
package com.devkor.ifive.nadab.domain.search.api;

import com.devkor.ifive.nadab.domain.search.api.dto.request.SaveSearchHistoryRequest;
import com.devkor.ifive.nadab.domain.search.api.dto.response.SearchHistoryListResponse;
import com.devkor.ifive.nadab.domain.search.application.SearchHistoryCommandService;
import com.devkor.ifive.nadab.domain.search.application.SearchHistoryQueryService;
Expand All @@ -13,6 +14,7 @@
import io.swagger.v3.oas.annotations.responses.ApiResponse;
import io.swagger.v3.oas.annotations.security.SecurityRequirement;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor;
import org.springframework.http.ResponseEntity;
import org.springframework.security.access.prepost.PreAuthorize;
Expand Down Expand Up @@ -53,6 +55,42 @@ public ResponseEntity<ApiResponseDto<SearchHistoryListResponse>> getRecentSearch
return ApiResponseEntity.ok(response);
}

@PostMapping("/histories")
@PreAuthorize("isAuthenticated()")
@Operation(
summary = "검색어 저장",
description = """
사용자가 검색어를 확정했을 때 검색어를 저장합니다.

- 엔터를 치거나 검색 버튼을 누르거나 답변 검색 결과를 클릭했을 때 호출됩니다.
- 이미 존재하는 검색어는 최신 순서로 갱신됩니다.
- 빈 문자열이나 공백만 있는 경우 저장되지 않습니다.

### 에러 처리
- DB 장애 등으로 내부 오류가 발생해서 실제 저장이 실패하더라도 204를 반환합니다.
""",
security = @SecurityRequirement(name = "bearerAuth"),
responses = {
@ApiResponse(
responseCode = "204",
description = "저장 완료 (내부 오류 발생 시에도 204 반환)"
),
@ApiResponse(
responseCode = "400",
description = "ErrorCode: VALIDATION_FAILED - 잘못된 요청 (키워드 누락 또는 길이 초과)",
content = @Content
),
@ApiResponse(responseCode = "401", description = "인증 실패", content = @Content)
}
)
public ResponseEntity<ApiResponseDto<Void>> saveSearchHistory(
@AuthenticationPrincipal UserPrincipal principal,
@Valid @RequestBody SaveSearchHistoryRequest request
) {
searchHistoryCommandService.saveOrRefreshSearchHistory(principal.getId(), request.keyword());
return ApiResponseEntity.noContent();
}

@DeleteMapping("/histories/{historyId}")
@PreAuthorize("isAuthenticated()")
@Operation(
Expand Down
Loading