diff --git a/keewe-api/src/main/java/ccc/keeweapi/component/ProfileAssembler.java b/keewe-api/src/main/java/ccc/keeweapi/component/ProfileAssembler.java index 0c7b1eef..bb489dc7 100644 --- a/keewe-api/src/main/java/ccc/keeweapi/component/ProfileAssembler.java +++ b/keewe-api/src/main/java/ccc/keeweapi/component/ProfileAssembler.java @@ -176,4 +176,11 @@ public FollowFromInsightCountResponse toFollowFromInsightCountResponse(Long foll public ProfileVisitFromInsightCountResponse toProfileVisitFromInsightCountResponse(Long profileVisitFromInsightCount) { return ProfileVisitFromInsightCountResponse.of(profileVisitFromInsightCount); } + + public InviteeSearchResponse toInviteeSearchResponse(List invitees, String nextCursor) { + List inviteeResponse = invitees.stream() + .map(this::toRelatedUserResponse) + .collect(Collectors.toList()); + return InviteeSearchResponse.of(nextCursor, inviteeResponse); + } } \ No newline at end of file diff --git a/keewe-api/src/main/java/ccc/keeweapi/controller/api/user/UserInvitationController.java b/keewe-api/src/main/java/ccc/keeweapi/controller/api/user/UserInvitationController.java index 6845b7aa..435546c4 100644 --- a/keewe-api/src/main/java/ccc/keeweapi/controller/api/user/UserInvitationController.java +++ b/keewe-api/src/main/java/ccc/keeweapi/controller/api/user/UserInvitationController.java @@ -2,7 +2,9 @@ import ccc.keeweapi.dto.ApiResponse; import ccc.keeweapi.dto.user.InviteeListResponse; +import ccc.keeweapi.dto.user.InviteeSearchResponse; import ccc.keeweapi.service.user.ProfileApiService; +import ccc.keewedomain.persistence.repository.user.cursor.InviteeSearchCursor; import ccc.keewedomain.persistence.repository.utils.CursorPageable; import lombok.RequiredArgsConstructor; import org.springframework.format.annotation.DateTimeFormat; @@ -26,4 +28,14 @@ public ApiResponse paginateInvitees( CursorPageable cPage = CursorPageable.of(cursor, limit); return ApiResponse.ok(profileApiService.paginateInvitees(cPage)); } + + @GetMapping("/invitee/search") + public ApiResponse searchInvitees( + @RequestParam String searchWord, + @RequestParam(required = false) String cursor, + @RequestParam Long limit + ) { + CursorPageable cPage = CursorPageable.of(InviteeSearchCursor.from(cursor), limit); + return ApiResponse.ok(profileApiService.searchInvitees(searchWord, cPage)); + } } diff --git a/keewe-api/src/main/java/ccc/keeweapi/dto/user/InviteeSearchResponse.java b/keewe-api/src/main/java/ccc/keeweapi/dto/user/InviteeSearchResponse.java new file mode 100644 index 00000000..48195129 --- /dev/null +++ b/keewe-api/src/main/java/ccc/keeweapi/dto/user/InviteeSearchResponse.java @@ -0,0 +1,14 @@ +package ccc.keeweapi.dto.user; + +import lombok.AllArgsConstructor; +import lombok.Getter; + +import java.util.List; + +@Getter +@AllArgsConstructor(staticName = "of") +public class InviteeSearchResponse { + // 응답의 닉네임 중 사전순 가장 뒤 + private String nextCursor; + private List invitees; +} diff --git a/keewe-api/src/main/java/ccc/keeweapi/service/user/ProfileApiService.java b/keewe-api/src/main/java/ccc/keeweapi/service/user/ProfileApiService.java index aaf8edfa..7349e9d3 100644 --- a/keewe-api/src/main/java/ccc/keeweapi/service/user/ProfileApiService.java +++ b/keewe-api/src/main/java/ccc/keeweapi/service/user/ProfileApiService.java @@ -8,6 +8,7 @@ import ccc.keeweapi.dto.user.FollowUserListResponse; import ccc.keeweapi.dto.user.InterestsResponse; import ccc.keeweapi.dto.user.InviteeListResponse; +import ccc.keeweapi.dto.user.InviteeSearchResponse; import ccc.keeweapi.dto.user.MyBlockUserListResponse; import ccc.keeweapi.dto.user.MyPageTitleResponse; import ccc.keeweapi.dto.user.OnboardRequest; @@ -21,6 +22,7 @@ import ccc.keeweapi.utils.SecurityUtil; import ccc.keewecore.utils.ListUtils; import ccc.keewedomain.dto.user.FollowCheckDto; +import ccc.keewedomain.persistence.repository.user.cursor.InviteeSearchCursor; import ccc.keewedomain.persistence.domain.challenge.Challenge; import ccc.keewedomain.persistence.domain.challenge.ChallengeParticipation; import ccc.keewedomain.persistence.domain.title.TitleAchievement; @@ -170,22 +172,25 @@ public InterestsResponse getInterests() { public InviteeListResponse paginateInvitees(CursorPageable cPage) { Long userId = SecurityUtil.getUserId(); List relatedFollows = profileQueryDomainService.findRelatedFollows(userId, cPage); - List invitees = relatedFollows.stream() - .map(follow -> { - if (follow.getFollower().getId().equals(userId)) { // follower가 나인 경우 - return follow.getFollowee(); - } else { // followee가 나인 경우 - return follow.getFollower(); - } - }) - .distinct() // 양방향으로 팔로우 되어 있는 경우 중복 제거 - .collect(Collectors.toList()); + List invitees = getInvitees(userId, relatedFollows); String nextCursor = relatedFollows.size() == cPage.getLimit() ? ListUtils.getLast(relatedFollows).getCreatedAt().toString() : null; return profileAssembler.toInviteeListResponse(invitees, nextCursor); } + @Transactional(readOnly = true) + public InviteeSearchResponse searchInvitees(String searchWord, CursorPageable cPage) { + Long userId = SecurityUtil.getUserId(); + List searchedFollows = profileQueryDomainService.searchRelatedUsers(userId, searchWord, cPage); + List invitees = getInvitees(userId, searchedFollows); + User lastInvitee = ListUtils.getLast(invitees); + InviteeSearchCursor nextCursor = searchedFollows.size() == cPage.getLimit() + ? InviteeSearchCursor.of(lastInvitee.getNickname(), lastInvitee.getId()) + : null; + return profileAssembler.toInviteeSearchResponse(invitees, nextCursor != null ? nextCursor.toString() : null); + } + public AccountResponse getAccount() { User user = SecurityUtil.getUser(); return profileAssembler.toAccountResponse(user); @@ -196,4 +201,17 @@ private void afterGetMyProfile(Long userId, Long insightId) { insightStatisticsCommandDomainService.publishProfileVisitFromInsightEvent(userId, insightId); } } + + private static List getInvitees(Long userId, List searchedFollows) { + return searchedFollows.stream() + .map(follow -> { + if (follow.getFollower().getId().equals(userId)) { // follower가 나인 경우 + return follow.getFollowee(); + } else { // followee가 나인 경우 + return follow.getFollower(); + } + }) + .distinct() // 양방향으로 팔로우 되어 있는 경우 중복 제거 + .collect(Collectors.toList()); + } } diff --git a/keewe-api/src/test/java/ccc/keeweapi/controller/api/user/UserInvitationControllerTest.java b/keewe-api/src/test/java/ccc/keeweapi/controller/api/user/UserInvitationControllerTest.java index 3a228313..841118e4 100644 --- a/keewe-api/src/test/java/ccc/keeweapi/controller/api/user/UserInvitationControllerTest.java +++ b/keewe-api/src/test/java/ccc/keeweapi/controller/api/user/UserInvitationControllerTest.java @@ -3,6 +3,7 @@ import ccc.keeweapi.document.utils.ApiDocumentationTest; import ccc.keeweapi.dto.user.InviteeListResponse; import ccc.keeweapi.dto.user.InviteeResponse; +import ccc.keeweapi.dto.user.InviteeSearchResponse; import ccc.keeweapi.service.user.ProfileApiService; import com.epages.restdocs.apispec.ResourceSnippetParameters; import org.junit.jupiter.api.BeforeEach; @@ -83,4 +84,51 @@ void get_related_users() throws Exception { .build() ))); } + + @Test + @DisplayName("초대 유저 닉네임 검색 API") + void search_related_users() throws Exception { + long limit = 10L; + String cursor = "닉네임:1"; + String searchWord = "닉네"; + List inviteeResponse = List.of( + InviteeResponse.of(1L, "hello", "www.api-keewe.com/images/128398681"), + InviteeResponse.of(2L, "world", "www.api-keewe.com/images/128398681") + ); + + InviteeSearchResponse response = InviteeSearchResponse.of(cursor, inviteeResponse); + + when(profileApiService.searchInvitees(any(), any())) + .thenReturn(response); + + ResultActions resultActions = mockMvc.perform(get("/api/v1/invitee/search") + .param("searchWord", searchWord) + .param("cursor", cursor) + .param("limit", Long.toString(limit)) + .header(HttpHeaders.AUTHORIZATION, "Bearer " + JWT) + .contentType(MediaType.APPLICATION_JSON)) + .andExpect(status().isOk()); + + resultActions.andDo(restDocs.document(resource( + ResourceSnippetParameters.builder() + .description("초대 유저 닉네임 검색 API 입니다") + .summary("초대 유저 닉네임 검색 API") + .requestHeaders( + headerWithName("Authorization").description("유저의 JWT")) + .requestParameters( + parameterWithName("searchWord").description("검색할 닉네임 prefix"), + parameterWithName("cursor").description("페이징을 위한 커서. 응답의 nextCursor. 첫 요청엔 미포함").optional(), + parameterWithName("limit").description("한번에 조회할 개수")) + .responseFields( + fieldWithPath("message").description("요청 결과 메세지"), + fieldWithPath("code").description("결과 코드"), + fieldWithPath("data.nextCursor").description("다음 조회를 위한 커서. 없을 경우 null"), + fieldWithPath("data.invitees[].userId").description("유저의 ID"), + fieldWithPath("data.invitees[].nickname").description("유저의 닉네임"), + fieldWithPath("data.invitees[].imageURL").description("유저의 프로필 이미지 URL") + ) + .tag("UserInvitation") + .build() + ))); + } } diff --git a/keewe-domain/src/main/java/ccc/keewedomain/persistence/repository/user/FollowQueryRepository.java b/keewe-domain/src/main/java/ccc/keewedomain/persistence/repository/user/FollowQueryRepository.java index fbad3ae2..f1c08d69 100644 --- a/keewe-domain/src/main/java/ccc/keewedomain/persistence/repository/user/FollowQueryRepository.java +++ b/keewe-domain/src/main/java/ccc/keewedomain/persistence/repository/user/FollowQueryRepository.java @@ -1,8 +1,10 @@ package ccc.keewedomain.persistence.repository.user; +import ccc.keewedomain.persistence.repository.user.cursor.InviteeSearchCursor; import ccc.keewedomain.persistence.domain.user.Follow; import ccc.keewedomain.persistence.domain.user.User; import ccc.keewedomain.persistence.repository.utils.CursorPageable; +import com.querydsl.core.types.dsl.BooleanExpression; import com.querydsl.jpa.JPAExpressions; import com.querydsl.jpa.JPQLQuery; import com.querydsl.jpa.impl.JPAQueryFactory; @@ -11,11 +13,9 @@ import java.time.LocalDateTime; import java.util.List; -import java.util.Set; import static ccc.keewedomain.persistence.domain.title.QTitle.title; import static ccc.keewedomain.persistence.domain.user.QFollow.follow; -import static ccc.keewedomain.persistence.domain.user.QProfilePhoto.profilePhoto; import static ccc.keewedomain.persistence.domain.user.QUser.user; @Repository @@ -65,6 +65,31 @@ public List findAllByUserIdOrderByCreatedAtDesc(Long userId, CursorPagea .fetch(); } + public List findByUserIdAndStartsWithNickname(Long userId, String word, CursorPageable cPage) { + return queryFactory.selectFrom(follow) + .innerJoin(follow.follower, user) + .fetchJoin() + .innerJoin(follow.followee, user) + .fetchJoin() + .where(follow.follower.id.eq(userId) + .or(follow.followee.id.eq(userId))) + .where(user.nickname.startsWith(word) + .and(nicknameGt(cPage.getCursor().getNickname())) + .and(userIdGt(cPage.getCursor().getUserId()))) + .orderBy(user.nickname.asc(), user.id.asc()) + .limit(cPage.getLimit()) + .fetch(); + } + + private BooleanExpression userIdGt(Long userId) { + return userId != null ? user.id.gt(userId) : null; + } + + // note. cursor가 null인 경우 조건을 추가하지 않음 + private BooleanExpression nicknameGt(String nickname) { + return nickname != null ? user.nickname.gt(nickname) : null; + } + private JPQLQuery findFolloweeIdsOrderByCreatedAtDesc(User target, CursorPageable cPage) { return JPAExpressions.select(follow.followee.id) .from(follow) diff --git a/keewe-domain/src/main/java/ccc/keewedomain/persistence/repository/user/cursor/InviteeSearchCursor.java b/keewe-domain/src/main/java/ccc/keewedomain/persistence/repository/user/cursor/InviteeSearchCursor.java new file mode 100644 index 00000000..9626fcaf --- /dev/null +++ b/keewe-domain/src/main/java/ccc/keewedomain/persistence/repository/user/cursor/InviteeSearchCursor.java @@ -0,0 +1,32 @@ +package ccc.keewedomain.persistence.repository.user.cursor; + +import lombok.AccessLevel; +import lombok.AllArgsConstructor; +import lombok.Getter; + +@Getter +@AllArgsConstructor(access = AccessLevel.PRIVATE) +public class InviteeSearchCursor { + private String nickname; + private Long userId; + + public static InviteeSearchCursor from(String cursorStr) { + if(cursorStr == null) { + return new InviteeSearchCursor(null, null); + } + + int lastIndex = cursorStr.lastIndexOf(":"); + String nickname = cursorStr.substring(0, lastIndex); + Long userId = Long.valueOf(cursorStr.substring(lastIndex + 1)); + return new InviteeSearchCursor(nickname, userId); + } + + public static InviteeSearchCursor of(String nickname, Long userId) { + return new InviteeSearchCursor(nickname, userId); + } + + @Override + public String toString() { + return nickname + ":" + userId; + } +} diff --git a/keewe-domain/src/main/java/ccc/keewedomain/service/user/query/ProfileQueryDomainService.java b/keewe-domain/src/main/java/ccc/keewedomain/service/user/query/ProfileQueryDomainService.java index c1d7cc39..324193a9 100644 --- a/keewe-domain/src/main/java/ccc/keewedomain/service/user/query/ProfileQueryDomainService.java +++ b/keewe-domain/src/main/java/ccc/keewedomain/service/user/query/ProfileQueryDomainService.java @@ -1,19 +1,18 @@ package ccc.keewedomain.service.user.query; import ccc.keewedomain.dto.user.FollowCheckDto; +import ccc.keewedomain.persistence.repository.user.cursor.InviteeSearchCursor; import ccc.keewedomain.persistence.domain.common.Interest; import ccc.keewedomain.persistence.domain.title.TitleAchievement; import ccc.keewedomain.persistence.domain.user.Block; import ccc.keewedomain.persistence.domain.user.Follow; import ccc.keewedomain.persistence.domain.user.User; import ccc.keewedomain.persistence.domain.user.id.FollowId; -import ccc.keewedomain.persistence.repository.insight.FollowFromInsightQueryRepository; import ccc.keewedomain.persistence.repository.user.BlockQueryRepository; import ccc.keewedomain.persistence.repository.user.FollowQueryRepository; import ccc.keewedomain.persistence.repository.user.FollowRepository; import ccc.keewedomain.persistence.repository.user.TitleAchievedQueryRepository; import ccc.keewedomain.persistence.repository.user.TitleAchievementRepository; -import ccc.keewedomain.persistence.repository.user.UserQueryRepository; import ccc.keewedomain.persistence.repository.utils.CursorPageable; import ccc.keewedomain.service.user.UserDomainService; import lombok.RequiredArgsConstructor; @@ -35,9 +34,7 @@ public class ProfileQueryDomainService { private final TitleAchievementRepository titleAchievementRepository; private final TitleAchievedQueryRepository titleAchievedQueryRepository; private final FollowQueryRepository followQueryRepository; - private final UserQueryRepository userQueryRepository; private final BlockQueryRepository blockQueryRepository; - private final FollowFromInsightQueryRepository followFromInsightQueryRepository; public boolean isFollowing(FollowCheckDto followCheckDto) { return followRepository.existsById(FollowId.of(followCheckDto.getUserId(), followCheckDto.getTargetId())); @@ -102,4 +99,9 @@ public List getInterests(User user) { public List findRelatedFollows(Long userId, CursorPageable cPage) { return followQueryRepository.findAllByUserIdOrderByCreatedAtDesc(userId, cPage); } + + @Transactional(readOnly = true) + public List searchRelatedUsers(Long userId, String searchWord, CursorPageable cPage) { + return followQueryRepository.findByUserIdAndStartsWithNickname(userId, searchWord, cPage); + } } diff --git a/keewe-domain/src/main/resources/ddl/ddl.sql b/keewe-domain/src/main/resources/ddl/ddl.sql index 7d2d6cdd..1a60b908 100644 --- a/keewe-domain/src/main/resources/ddl/ddl.sql +++ b/keewe-domain/src/main/resources/ddl/ddl.sql @@ -181,7 +181,7 @@ CREATE TABLE IF NOT EXISTS `reaction` updated_at DATETIME(6) NOT NULL, FOREIGN KEY (insight_id) REFERENCES `insight`(insight_id), - FOREIGN KEY (reaction_id) REFERENCES `user`(user_id), + FOREIGN KEY (reactor_id) REFERENCES `user`(user_id), PRIMARY KEY (reaction_id) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;