diff --git a/src/main/java/com/codeit/todo/common/config/SecurityConfig.java b/src/main/java/com/codeit/todo/common/config/SecurityConfig.java index bb847d7..c08c754 100644 --- a/src/main/java/com/codeit/todo/common/config/SecurityConfig.java +++ b/src/main/java/com/codeit/todo/common/config/SecurityConfig.java @@ -28,6 +28,7 @@ public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Excepti authorizeRequests .requestMatchers("/signup").permitAll() .requestMatchers("/login").permitAll() + .requestMatchers("/searches").permitAll() .requestMatchers("/swagger-ui/**").permitAll() .requestMatchers("/**").permitAll() .anyRequest().authenticated() diff --git a/src/main/java/com/codeit/todo/common/exception/EntityNotFoundException.java b/src/main/java/com/codeit/todo/common/exception/EntityNotFoundException.java index 946d97e..c7fc76b 100644 --- a/src/main/java/com/codeit/todo/common/exception/EntityNotFoundException.java +++ b/src/main/java/com/codeit/todo/common/exception/EntityNotFoundException.java @@ -29,4 +29,5 @@ public EntityNotFoundException(String request, String entityType) { this.request = request; this.entityType = entityType; } + } diff --git a/src/main/java/com/codeit/todo/common/exception/search/SearchException.java b/src/main/java/com/codeit/todo/common/exception/search/SearchException.java new file mode 100644 index 0000000..646924d --- /dev/null +++ b/src/main/java/com/codeit/todo/common/exception/search/SearchException.java @@ -0,0 +1,13 @@ +package com.codeit.todo.common.exception.search; + +import com.codeit.todo.common.exception.ApplicationException; +import com.codeit.todo.common.exception.payload.ErrorStatus; + +public class SearchException extends ApplicationException { + /** + * @param errorStatus 상태 코드, 메세지, 발생시간을 저장한 객체 + */ + public SearchException(ErrorStatus errorStatus) { + super(errorStatus); + } +} diff --git a/src/main/java/com/codeit/todo/domain/User.java b/src/main/java/com/codeit/todo/domain/User.java index 1cf1965..2442117 100644 --- a/src/main/java/com/codeit/todo/domain/User.java +++ b/src/main/java/com/codeit/todo/domain/User.java @@ -31,6 +31,9 @@ public class User { @Column(name = "profile_pic", nullable = false) private String profilePic; + @OneToMany(mappedBy= "user", cascade = CascadeType.REMOVE, orphanRemoval = true) + private List goals = new ArrayList<>(); + //나를 팔로우 하는 사람들 @OneToMany(mappedBy = "followee", cascade = CascadeType.REMOVE, orphanRemoval = true) private List followers = new ArrayList<>(); diff --git a/src/main/java/com/codeit/todo/domain/enums/SearchField.java b/src/main/java/com/codeit/todo/domain/enums/SearchField.java new file mode 100644 index 0000000..a4cc4df --- /dev/null +++ b/src/main/java/com/codeit/todo/domain/enums/SearchField.java @@ -0,0 +1,16 @@ +package com.codeit.todo.domain.enums; + +import lombok.Getter; + +@Getter +public enum SearchField { + USER_NAME("유저명"), + GOAL_TITLE("목표명"); + + private final String value; + + SearchField(String value){ + this.value = value; + } + +} diff --git a/src/main/java/com/codeit/todo/repository/GoalRepository.java b/src/main/java/com/codeit/todo/repository/GoalRepository.java index cc2ec3e..e2c6895 100644 --- a/src/main/java/com/codeit/todo/repository/GoalRepository.java +++ b/src/main/java/com/codeit/todo/repository/GoalRepository.java @@ -38,4 +38,6 @@ public interface GoalRepository extends JpaRepository { and :today between t.startDate and t.endDate """) Slice findByUserAndHasTodosAfterLastGoalId(@Param("lastGoalId") Integer lastGoalId, @Param("userId") int userId, Pageable pageable, @Param("today") LocalDate today); + + List findByGoalTitleContains(@Param("keyword") String keyword); } diff --git a/src/main/java/com/codeit/todo/repository/UserRepository.java b/src/main/java/com/codeit/todo/repository/UserRepository.java index 73e8d39..d40c1ea 100644 --- a/src/main/java/com/codeit/todo/repository/UserRepository.java +++ b/src/main/java/com/codeit/todo/repository/UserRepository.java @@ -3,9 +3,12 @@ import com.codeit.todo.domain.Todo; import com.codeit.todo.domain.User; +import io.lettuce.core.dynamic.annotation.Param; import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; import org.springframework.stereotype.Repository; +import java.util.List; import java.util.Optional; @Repository @@ -13,4 +16,6 @@ public interface UserRepository extends JpaRepository { Optional findByEmail(String email); + + List findByNameContains(@Param("keyword") String keyword); } diff --git a/src/main/java/com/codeit/todo/service/search/SearchService.java b/src/main/java/com/codeit/todo/service/search/SearchService.java new file mode 100644 index 0000000..ec25fb0 --- /dev/null +++ b/src/main/java/com/codeit/todo/service/search/SearchService.java @@ -0,0 +1,10 @@ +package com.codeit.todo.service.search; + +import com.codeit.todo.web.dto.request.search.ReadSearchRequest; +import com.codeit.todo.web.dto.response.search.ReadSearchResponse; + +import java.util.List; + +public interface SearchService { + List findUserAndGoal(ReadSearchRequest request); +} diff --git a/src/main/java/com/codeit/todo/service/search/impl/SearchServiceImpl.java b/src/main/java/com/codeit/todo/service/search/impl/SearchServiceImpl.java new file mode 100644 index 0000000..f1724ff --- /dev/null +++ b/src/main/java/com/codeit/todo/service/search/impl/SearchServiceImpl.java @@ -0,0 +1,85 @@ +package com.codeit.todo.service.search.impl; + +import com.codeit.todo.common.exception.EntityNotFoundException; +import com.codeit.todo.common.exception.goal.GoalNotFoundException; +import com.codeit.todo.common.exception.payload.ErrorStatus; +import com.codeit.todo.common.exception.search.SearchException; +import com.codeit.todo.common.exception.user.UserNotFoundException; +import com.codeit.todo.domain.Goal; +import com.codeit.todo.domain.User; +import com.codeit.todo.domain.enums.SearchField; +import com.codeit.todo.repository.GoalRepository; +import com.codeit.todo.repository.UserRepository; +import com.codeit.todo.service.search.SearchService; +import com.codeit.todo.web.dto.request.search.ReadSearchRequest; +import com.codeit.todo.web.dto.response.goal.ReadGoalSearchResponse; +import com.codeit.todo.web.dto.response.search.ReadSearchResponse; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.List; +import java.util.Map; +import java.util.stream.Collectors; + +@Slf4j +@Service +@RequiredArgsConstructor +@Transactional +public class SearchServiceImpl implements SearchService { + private final UserRepository userRepository; + private final GoalRepository goalRepository; + + private static final int BAD_REQUEST = 400; + + private static final int NOT_FOUND = 404; + + @Override + public List findUserAndGoal(ReadSearchRequest request) { + String searchField = request.searchField(); + String keyword = request.keyword(); + + log.info("Starting search for field: {} with keyword: {}", searchField, keyword); + + if( searchField.equals(SearchField.USER_NAME.getValue()) ){ + List searchedUsers = userRepository.findByNameContains(keyword); + if(searchedUsers.isEmpty()) throw new SearchException(ErrorStatus.toErrorStatus("유저에 대한 검색 결과가 없습니다.", NOT_FOUND)); + + List responses = searchedUsers.stream() + .map(user -> { + List goals = user.getGoals(); + List goalsResponses = goals.stream() + .map(ReadGoalSearchResponse::from) + .toList(); + + return ReadSearchResponse.from(user, goalsResponses); + }).toList(); + return responses; + + }else if(searchField.equals(SearchField.GOAL_TITLE.getValue())){ + List searchedGoals = goalRepository.findByGoalTitleContains(keyword); + if(searchedGoals.isEmpty()) throw new SearchException(ErrorStatus.toErrorStatus("목표에 대한 검색 결과가 없습니다.", NOT_FOUND)); + + Map> userGoalsMap = searchedGoals.stream() + .collect(Collectors.groupingBy(Goal::getUser)); + + List responses = userGoalsMap.entrySet().stream() + .map(entry -> { + User user = entry.getKey(); + List goals = entry.getValue(); + + List goalsResponses = goals.stream() + .map(ReadGoalSearchResponse::from) + .toList(); + + return ReadSearchResponse.from(user, goalsResponses); + + }).toList(); + return responses; + }else{ + throw new SearchException(ErrorStatus.toErrorStatus("해당 필드로는 검색할 수 없습니다", BAD_REQUEST)); + } + } +} diff --git a/src/main/java/com/codeit/todo/web/controller/SearchController.java b/src/main/java/com/codeit/todo/web/controller/SearchController.java new file mode 100644 index 0000000..fcdd3b3 --- /dev/null +++ b/src/main/java/com/codeit/todo/web/controller/SearchController.java @@ -0,0 +1,39 @@ +package com.codeit.todo.web.controller; + +import com.codeit.todo.repository.CustomUserDetails; + +import com.codeit.todo.service.search.SearchService; +import com.codeit.todo.web.dto.request.search.ReadSearchRequest; +import com.codeit.todo.web.dto.response.Response; +import com.codeit.todo.web.dto.response.search.ReadSearchResponse; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.responses.ApiResponse; +import io.swagger.v3.oas.annotations.responses.ApiResponses; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +import java.util.List; + +@RestController +@RequiredArgsConstructor +@RequestMapping("/api/v1/searches") +public class SearchController { + + private final SearchService searchService; + + @Operation(summary = "유저 또는 목표 검색", description = "유저 또는 목표를 검색하고 유저와 목표를 함께 반환합니다") + @ApiResponses(value = { + @ApiResponse(responseCode = "200", description = "검색 성공") + }) + @GetMapping + public Response> getSearch( + @Valid @RequestBody ReadSearchRequest request + ){ + return Response.ok(searchService.findUserAndGoal(request)); + } +} diff --git a/src/main/java/com/codeit/todo/web/dto/request/search/ReadSearchRequest.java b/src/main/java/com/codeit/todo/web/dto/request/search/ReadSearchRequest.java new file mode 100644 index 0000000..cef0f87 --- /dev/null +++ b/src/main/java/com/codeit/todo/web/dto/request/search/ReadSearchRequest.java @@ -0,0 +1,13 @@ +package com.codeit.todo.web.dto.request.search; + +import jakarta.validation.constraints.NotNull; + +public record ReadSearchRequest( + @NotNull + String searchField, + + @NotNull + String keyword +) { + +} diff --git a/src/main/java/com/codeit/todo/web/dto/response/goal/ReadGoalSearchResponse.java b/src/main/java/com/codeit/todo/web/dto/response/goal/ReadGoalSearchResponse.java new file mode 100644 index 0000000..7af41f0 --- /dev/null +++ b/src/main/java/com/codeit/todo/web/dto/response/goal/ReadGoalSearchResponse.java @@ -0,0 +1,22 @@ +package com.codeit.todo.web.dto.response.goal; + +import com.codeit.todo.domain.Goal; +import lombok.Builder; + +import java.time.LocalDateTime; + +@Builder +public record ReadGoalSearchResponse( + int goalId, + String goalTitle, + String color + +) { + public static ReadGoalSearchResponse from(Goal goal){ + return ReadGoalSearchResponse.builder() + .goalId(goal.getGoalId()) + .goalTitle(goal.getGoalTitle()) + .color(goal.getColor()) + .build(); + } +} diff --git a/src/main/java/com/codeit/todo/web/dto/response/search/ReadSearchResponse.java b/src/main/java/com/codeit/todo/web/dto/response/search/ReadSearchResponse.java new file mode 100644 index 0000000..0c2fbe3 --- /dev/null +++ b/src/main/java/com/codeit/todo/web/dto/response/search/ReadSearchResponse.java @@ -0,0 +1,23 @@ +package com.codeit.todo.web.dto.response.search; + +import com.codeit.todo.domain.User; +import com.codeit.todo.web.dto.response.goal.ReadGoalSearchResponse; +import lombok.Builder; + +import java.util.List; + +@Builder +public record ReadSearchResponse ( + String name, + String profilePic, + + List goals +){ + public static ReadSearchResponse from(User user, List responses) { + return ReadSearchResponse.builder() + .name(user.getName()) + .profilePic(user.getProfilePic()) + .goals(responses) + .build(); + } +} diff --git a/src/main/java/com/codeit/todo/web/filter/JwtAuthenticationFilter.java b/src/main/java/com/codeit/todo/web/filter/JwtAuthenticationFilter.java index 7bcb045..03bc686 100644 --- a/src/main/java/com/codeit/todo/web/filter/JwtAuthenticationFilter.java +++ b/src/main/java/com/codeit/todo/web/filter/JwtAuthenticationFilter.java @@ -76,7 +76,7 @@ private void processExceptionHandle(HttpServletResponse response, ErrorStatus er */ @Override protected boolean shouldNotFilter(HttpServletRequest request) throws ServletException { - String[] excludedPaths = {"/api/v1/auths/signup", "/api/v1/auths/login", "/v3/**", "/swagger-ui/**"}; + String[] excludedPaths = {"/api/v1/auths/signup", "/api/v1/auths/login", "/api/v1/searches", "/v3/**", "/swagger-ui/**"}; AntPathMatcher antPathMatcher = new AntPathMatcher(); for (String excludedPath : excludedPaths) {