diff --git a/src/main/java/com/depromeet/domain/auth/application/AuthService.java b/src/main/java/com/depromeet/domain/auth/application/AuthService.java index 6af856d49..3f20dc6cd 100644 --- a/src/main/java/com/depromeet/domain/auth/application/AuthService.java +++ b/src/main/java/com/depromeet/domain/auth/application/AuthService.java @@ -98,7 +98,7 @@ public SocialLoginResponse socialLoginMember(IdTokenRequest request, OauthProvid TokenPairResponse loginResponse = getLoginResponse(member); - return SocialLoginResponse.from(loginResponse); + return SocialLoginResponse.from(member, loginResponse); } private Member fetchOrCreate(OidcUser oidcUser) { diff --git a/src/main/java/com/depromeet/domain/auth/dto/response/SocialLoginResponse.java b/src/main/java/com/depromeet/domain/auth/dto/response/SocialLoginResponse.java index 9ef9190f7..9cca6c7db 100644 --- a/src/main/java/com/depromeet/domain/auth/dto/response/SocialLoginResponse.java +++ b/src/main/java/com/depromeet/domain/auth/dto/response/SocialLoginResponse.java @@ -1,14 +1,19 @@ package com.depromeet.domain.auth.dto.response; +import com.depromeet.domain.member.domain.Member; import io.swagger.v3.oas.annotations.media.Schema; public record SocialLoginResponse( + @Schema(description = "멤버 ID", defaultValue = "1") Long memberId, @Schema(description = "엑세스 토큰", defaultValue = "accessToken") String accessToken, @Schema(description = "리프레시 토큰", defaultValue = "refreshToken") String refreshToken, @Schema(description = "게스트 여부", defaultValue = "false") boolean isGuest) { - public static SocialLoginResponse from(TokenPairResponse tokenPairResponse) { + public static SocialLoginResponse from(Member member, TokenPairResponse tokenPairResponse) { return new SocialLoginResponse( - tokenPairResponse.accessToken(), tokenPairResponse.refreshToken(), false); + member.getId(), + tokenPairResponse.accessToken(), + tokenPairResponse.refreshToken(), + false); } } diff --git a/src/main/java/com/depromeet/domain/feed/api/FeedController.java b/src/main/java/com/depromeet/domain/feed/api/FeedController.java new file mode 100644 index 000000000..8d802e43b --- /dev/null +++ b/src/main/java/com/depromeet/domain/feed/api/FeedController.java @@ -0,0 +1,34 @@ +package com.depromeet.domain.feed.api; + +import com.depromeet.domain.feed.application.FeedService; +import com.depromeet.domain.feed.dto.response.FeedOneByProfileResponse; +import com.depromeet.domain.feed.dto.response.FeedOneResponse; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import java.util.List; +import lombok.RequiredArgsConstructor; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +@RestController +@Tag(name = "6. [피드]", description = "피드 관련 API입니다.") +@RequestMapping("/feed") +@RequiredArgsConstructor +public class FeedController { + + private final FeedService feedService; + + @Operation(summary = "피드 탭", description = "피드 탭을 조회합니다.") + @GetMapping("/me") + public List feedFindAll() { + return feedService.findAllFeed(); + } + + @Operation(summary = "프로필 피드", description = "피드 탭을 조회합니다.") + @GetMapping("/{memberId}") + public List feedFindAllByTargetId(@PathVariable Long memberId) { + return feedService.findAllFeedByTargetId(memberId); + } +} diff --git a/src/main/java/com/depromeet/domain/feed/application/FeedService.java b/src/main/java/com/depromeet/domain/feed/application/FeedService.java new file mode 100644 index 000000000..5600253cc --- /dev/null +++ b/src/main/java/com/depromeet/domain/feed/application/FeedService.java @@ -0,0 +1,92 @@ +package com.depromeet.domain.feed.application; + +import com.depromeet.domain.feed.dto.response.FeedOneByProfileResponse; +import com.depromeet.domain.feed.dto.response.FeedOneResponse; +import com.depromeet.domain.follow.dao.MemberRelationRepository; +import com.depromeet.domain.follow.domain.MemberRelation; +import com.depromeet.domain.member.domain.Member; +import com.depromeet.domain.mission.domain.MissionVisibility; +import com.depromeet.domain.missionRecord.dao.MissionRecordRepository; +import com.depromeet.domain.missionRecord.domain.MissionRecord; +import com.depromeet.global.util.MemberUtil; +import com.depromeet.global.util.SecurityUtil; +import java.util.ArrayList; +import java.util.List; +import java.util.stream.Collectors; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +// TODO: Redis 사용해서 캐싱 작업 필요 +@Service +@RequiredArgsConstructor +@Transactional +public class FeedService { + private final MemberUtil memberUtil; + private final MissionRecordRepository missionRecordRepository; + private final MemberRelationRepository memberRelationRepository; + private final SecurityUtil securityUtil; + + @Transactional(readOnly = true) + public List findAllFeed() { + final Member currentMember = memberUtil.getCurrentMember(); + List members = + memberRelationRepository.findAllBySourceId(currentMember.getId()).stream() + .map(MemberRelation::getTarget) + .collect(Collectors.toList()); + members.add(currentMember); + + return missionRecordRepository.findFeedAll(members); + } + + @Transactional(readOnly = true) + public List findAllFeedByTargetId(Long targetId) { + final Long sourceId = securityUtil.getCurrentMemberId(); + + if (isMyFeedRequired(targetId, sourceId)) { + return findFeedByOtherMember(sourceId, targetId); + } + return findFeedByCurrentMember(sourceId); + } + + private boolean isMyFeedRequired(Long targetId, Long sourceId) { + return !targetId.equals(sourceId); + } + + private List findFeedByOtherMember(Long sourceId, Long targetId) { + final Member targetMember = memberUtil.getMemberByMemberId(targetId); + + // 팔로우 관계 true: visibility.FOLLOW and ALL, false: visibility.ALL only + boolean isMemberRelationExistsWithMe = + memberRelationRepository.existsBySourceIdAndTargetId( + sourceId, targetMember.getId()); + List visibilities = + determineVisibilityConditionsByRelationsWithMe(isMemberRelationExistsWithMe); + List feedAllByMemberId = + missionRecordRepository.findFeedAllByMemberId(targetId, visibilities); + return extractFeedResponses(feedAllByMemberId); + } + + private List findFeedByCurrentMember(Long sourceId) { + List visibilities = + List.of(MissionVisibility.NONE, MissionVisibility.FOLLOWER, MissionVisibility.ALL); + List feedAllByMemberId = + missionRecordRepository.findFeedAllByMemberId(sourceId, visibilities); + return extractFeedResponses(feedAllByMemberId); + } + + private List extractFeedResponses(List records) { + return records.stream().map(FeedOneByProfileResponse::of).toList(); + } + + private List determineVisibilityConditionsByRelationsWithMe( + boolean isMemberRelationExistsWithMe) { + List visibilities = new ArrayList<>(); + visibilities.add(MissionVisibility.ALL); + + if (isMemberRelationExistsWithMe) { + visibilities.add(MissionVisibility.FOLLOWER); + } + return visibilities; + } +} diff --git a/src/main/java/com/depromeet/domain/feed/dto/response/FeedOneByProfileResponse.java b/src/main/java/com/depromeet/domain/feed/dto/response/FeedOneByProfileResponse.java new file mode 100644 index 000000000..16577d97f --- /dev/null +++ b/src/main/java/com/depromeet/domain/feed/dto/response/FeedOneByProfileResponse.java @@ -0,0 +1,49 @@ +package com.depromeet.domain.feed.dto.response; + +import com.depromeet.domain.missionRecord.domain.MissionRecord; +import com.fasterxml.jackson.annotation.JsonFormat; +import io.swagger.v3.oas.annotations.media.Schema; +import java.time.Duration; +import java.time.LocalDateTime; + +public record FeedOneByProfileResponse( + @Schema(description = "미션 ID", defaultValue = "1") Long missionId, + @Schema(description = "미션 기록 ID", defaultValue = "1") Long recordId, + @Schema(description = "미션 이름", defaultValue = "default name") String name, + @Schema( + description = "미션 기록 인증 사진 Url", + defaultValue = "https://image.10mm.today/default.png") + String recordImageUrl, + @Schema(description = "미션 수행한 시간", defaultValue = "21") long duration, + @Schema(description = "미션 시작한 지 N일차", defaultValue = "3") long sinceDay, + @JsonFormat( + shape = JsonFormat.Shape.STRING, + pattern = "yyyy-MM-dd HH:mm:ss", + timezone = "Asia/Seoul") + @Schema( + description = "미션 기록 시작 시간", + defaultValue = "2023-01-06 00:00:00", + type = "string") + LocalDateTime startedAt, + @JsonFormat( + shape = JsonFormat.Shape.STRING, + pattern = "yyyy-MM-dd HH:mm:ss", + timezone = "Asia/Seoul") + @Schema( + description = "미션 기록 종료 시간", + defaultValue = "2024-01-20 00:34:00", + type = "string") + LocalDateTime finishedAt) { + + public static FeedOneByProfileResponse of(MissionRecord record) { + return new FeedOneByProfileResponse( + record.getMission().getId(), + record.getId(), + record.getMission().getName(), + record.getImageUrl(), + record.getDuration().toMinutes(), + Duration.between(record.getStartedAt(), LocalDateTime.now()).toDays() + 1, + record.getStartedAt(), + record.getFinishedAt()); + } +} diff --git a/src/main/java/com/depromeet/domain/feed/dto/response/FeedOneResponse.java b/src/main/java/com/depromeet/domain/feed/dto/response/FeedOneResponse.java new file mode 100644 index 000000000..9cf071b9a --- /dev/null +++ b/src/main/java/com/depromeet/domain/feed/dto/response/FeedOneResponse.java @@ -0,0 +1,96 @@ +package com.depromeet.domain.feed.dto.response; + +import com.fasterxml.jackson.annotation.JsonFormat; +import com.querydsl.core.annotations.QueryProjection; +import io.swagger.v3.oas.annotations.media.Schema; +import java.time.Duration; +import java.time.LocalDateTime; + +public record FeedOneResponse( + @Schema(description = "작성자 ID", defaultValue = "1") Long memberId, + @Schema(description = "작성자 닉네임", defaultValue = "default name") String nickname, + @Schema(description = "작성자 프로필 이미지", defaultValue = "https://image.10mm.today/default.png") + String profileImage, + @Schema(description = "미션 ID", defaultValue = "1") Long missionId, + @Schema(description = "미션 이름", defaultValue = "default name") String name, + @Schema(description = "미션 기록 ID", defaultValue = "1") Long recordId, + @Schema(description = "미션 일지 내용", defaultValue = "default remark") String remark, + @Schema( + description = "미션 기록 인증 사진 Url", + defaultValue = "https://image.10mm.today/default.png") + String recordImageUrl, + @Schema(description = "미션 수행한 시간", defaultValue = "21") long duration, + @Schema(description = "미션 시작한 지 N일차", defaultValue = "3") long sinceDay, + @JsonFormat( + shape = JsonFormat.Shape.STRING, + pattern = "yyyy-MM-dd HH:mm:ss", + timezone = "Asia/Seoul") + @Schema( + description = "미션 기록 시작 시간", + defaultValue = "2024-01-06 00:00:00", + type = "string") + LocalDateTime startedAt, + @JsonFormat( + shape = JsonFormat.Shape.STRING, + pattern = "yyyy-MM-dd HH:mm:ss", + timezone = "Asia/Seoul") + @Schema( + description = "미션 기록 종료 시간", + defaultValue = "2024-01-20 00:34:00", + type = "string") + LocalDateTime finishedAt) { + @QueryProjection + public FeedOneResponse( + Long memberId, + String nickname, + String profileImage, + Long missionId, + String name, + Long recordId, + String remark, + String recordImageUrl, + Duration duration, + LocalDateTime startedAt, + LocalDateTime finishedAt) { + this( + memberId, + nickname, + profileImage, + missionId, + name, + recordId, + remark, + recordImageUrl, + duration.toMinutes(), + Duration.between(startedAt, LocalDateTime.now()).toDays() + 1, + startedAt, + finishedAt); + } + + public static FeedOneResponse of( + Long memberId, + String nickname, + String profileImage, + Long missionId, + String name, + Long recordId, + String remark, + String recordImageUrl, + Duration duration, + LocalDateTime startedAt, + LocalDateTime finishedAt) { + return new FeedOneResponse( + memberId, + nickname, + profileImage, + missionId, + name, + recordId, + remark, + recordImageUrl, + duration.toMinutes(), + Duration.between(startedAt, LocalDateTime.now()).toDays() + 1, + startedAt, + finishedAt); + } +} diff --git a/src/main/java/com/depromeet/domain/follow/api/FollowController.java b/src/main/java/com/depromeet/domain/follow/api/FollowController.java index afac7d44c..b85fe03bf 100644 --- a/src/main/java/com/depromeet/domain/follow/api/FollowController.java +++ b/src/main/java/com/depromeet/domain/follow/api/FollowController.java @@ -3,10 +3,7 @@ import com.depromeet.domain.follow.application.FollowService; import com.depromeet.domain.follow.dto.request.FollowCreateRequest; import com.depromeet.domain.follow.dto.request.FollowDeleteRequest; -import com.depromeet.domain.follow.dto.response.FollowFindMeInfoResponse; -import com.depromeet.domain.follow.dto.response.FollowFindTargetInfoResponse; -import com.depromeet.domain.follow.dto.response.FollowListResponse; -import com.depromeet.domain.follow.dto.response.MemberFollowedResponse; +import com.depromeet.domain.follow.dto.response.*; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.tags.Tag; import jakarta.validation.Valid; @@ -63,4 +60,10 @@ public List followedUserFindAll() { public FollowListResponse followList(@PathVariable Long targetId) { return followService.findFollowList(targetId); } + + @DeleteMapping({"/{targetId}"}) + @Operation(summary = "팔로워 삭제", description = "내 팔로워 목록 중 targetId로 팔로워를 삭제합니다.") + public ResponseEntity followerDelete(@PathVariable Long targetId) { + return ResponseEntity.ok(followService.deleteFollower(targetId)); + } } diff --git a/src/main/java/com/depromeet/domain/follow/application/FollowService.java b/src/main/java/com/depromeet/domain/follow/application/FollowService.java index cbf842b0d..34c488f91 100644 --- a/src/main/java/com/depromeet/domain/follow/application/FollowService.java +++ b/src/main/java/com/depromeet/domain/follow/application/FollowService.java @@ -11,7 +11,8 @@ import com.depromeet.domain.mission.domain.Mission; import com.depromeet.domain.missionRecord.domain.ImageUploadStatus; import com.depromeet.domain.missionRecord.domain.MissionRecord; -import com.depromeet.domain.notification.application.NotificationService; +import com.depromeet.domain.notification.dao.NotificationRepository; +import com.depromeet.domain.notification.domain.Notification; import com.depromeet.domain.notification.domain.NotificationType; import com.depromeet.global.config.fcm.FcmService; import com.depromeet.global.error.exception.CustomException; @@ -27,7 +28,7 @@ @RequiredArgsConstructor @Transactional public class FollowService { - private final NotificationService notificationService; + private final NotificationRepository notificationRepository; private final MemberUtil memberUtil; private final MemberRepository memberRepository; private final MemberRelationRepository memberRelationRepository; @@ -54,9 +55,10 @@ public void createFollow(FollowCreateRequest request) { targetMember.getFcmInfo().getFcmToken(), PUSH_SERVICE_TITLE, String.format(PUSH_SERVICE_CONTENT, currentMember.getProfile().getNickname())); - notificationService.createNotification( - NotificationType.FOLLOW, currentMember, targetMember); - + Notification notification = + Notification.createNotification( + NotificationType.FOLLOW, currentMember, targetMember); + notificationRepository.save(notification); memberRelationRepository.save(memberRelation); } @@ -69,6 +71,13 @@ public void deleteFollow(FollowDeleteRequest request) { .findBySourceIdAndTargetId(currentMember.getId(), targetMember.getId()) .orElseThrow(() -> new CustomException(ErrorCode.FOLLOW_NOT_EXIST)); + Optional optionalNotification = + notificationRepository.findBySourceMemberIdAndTargetMemberIdAndNotificationType( + currentMember.getId(), targetMember.getId(), NotificationType.FOLLOW); + if (optionalNotification.isPresent()) { + Notification notification = optionalNotification.get(); + notificationRepository.delete(notification); + } memberRelationRepository.delete(memberRelation); } @@ -187,6 +196,7 @@ private Member getTargetMember(Long targetId) { return targetMember; } + @Transactional(readOnly = true) public FollowListResponse findFollowList(Long targetId) { final Member currentMember = memberUtil.getCurrentMember(); Member targetMember = getTargetMember(targetId); @@ -224,6 +234,24 @@ public FollowListResponse findFollowList(Long targetId) { targetMember.getProfile().getNickname(), followingList, followerList); } + public FollowerDeletedResponse deleteFollower(Long targetId) { + final Member currentMember = memberUtil.getCurrentMember(); + final Member targetMember = getTargetMember(targetId); + + MemberRelation memberRelation = + memberRelationRepository + .findBySourceIdAndTargetId(targetMember.getId(), currentMember.getId()) + .orElseThrow(() -> new CustomException(ErrorCode.FOLLOW_NOT_EXIST)); + memberRelationRepository.delete(memberRelation); + + Optional optionalMemberRelation = + memberRelationRepository.findBySourceIdAndTargetId( + currentMember.getId(), targetMember.getId()); + return optionalMemberRelation.isPresent() + ? FollowerDeletedResponse.from(FollowStatus.FOLLOWING) + : FollowerDeletedResponse.from(FollowStatus.NOT_FOLLOWING); + } + private static void getFollowStatusIncludeList( List targetMembers, List currentMemberSources, diff --git a/src/main/java/com/depromeet/domain/follow/dao/MemberRelationRepositoryImpl.java b/src/main/java/com/depromeet/domain/follow/dao/MemberRelationRepositoryImpl.java index 76c856115..712b75994 100644 --- a/src/main/java/com/depromeet/domain/follow/dao/MemberRelationRepositoryImpl.java +++ b/src/main/java/com/depromeet/domain/follow/dao/MemberRelationRepositoryImpl.java @@ -22,6 +22,7 @@ public List findAllBySourceId(Long memberId) { return jpaQueryFactory .selectFrom(memberRelation) .leftJoin(memberRelation.target, member) + .fetchJoin() .leftJoin(memberRelation.target.missions, mission) .leftJoin(mission.missionRecords, missionRecord) .where(sourceIdEq(memberId)) diff --git a/src/main/java/com/depromeet/domain/follow/dto/response/FollowerDeletedResponse.java b/src/main/java/com/depromeet/domain/follow/dto/response/FollowerDeletedResponse.java new file mode 100644 index 000000000..6373e551b --- /dev/null +++ b/src/main/java/com/depromeet/domain/follow/dto/response/FollowerDeletedResponse.java @@ -0,0 +1,12 @@ +package com.depromeet.domain.follow.dto.response; + +import io.swagger.v3.oas.annotations.media.Schema; + +public record FollowerDeletedResponse( + @Schema(description = "팔로워 삭제 후 나와 target과의 팔로우 상태", defaultValue = "NOT_FOLLOWING") + FollowStatus followStatus) { + public static FollowerDeletedResponse from(FollowStatus followStatus) { + return new FollowerDeletedResponse(followStatus); + } + ; +} diff --git a/src/main/java/com/depromeet/domain/member/application/MemberService.java b/src/main/java/com/depromeet/domain/member/application/MemberService.java index 1baca346a..d94f9551c 100644 --- a/src/main/java/com/depromeet/domain/member/application/MemberService.java +++ b/src/main/java/com/depromeet/domain/member/application/MemberService.java @@ -81,6 +81,9 @@ private void validateNicknameNotDuplicate(String nickname) { public List searchMemberNickname(String nickname) { final Member currentMember = memberUtil.getCurrentMember(); final String escapingNickname = escapeSpecialCharacters(nickname); + if (escapingNickname.isBlank()) { + return List.of(); + } List members = memberRepository.nicknameSearch( diff --git a/src/main/java/com/depromeet/domain/mission/api/MissionController.java b/src/main/java/com/depromeet/domain/mission/api/MissionController.java index ac2b0047f..caa807264 100644 --- a/src/main/java/com/depromeet/domain/mission/api/MissionController.java +++ b/src/main/java/com/depromeet/domain/mission/api/MissionController.java @@ -3,6 +3,7 @@ import com.depromeet.domain.mission.application.MissionService; import com.depromeet.domain.mission.dto.request.MissionCreateRequest; import com.depromeet.domain.mission.dto.request.MissionUpdateRequest; +import com.depromeet.domain.mission.dto.response.FinishedMissionResponse; import com.depromeet.domain.mission.dto.response.FollowMissionFindAllResponse; import com.depromeet.domain.mission.dto.response.MissionCreateResponse; import com.depromeet.domain.mission.dto.response.MissionFindAllResponse; @@ -66,6 +67,12 @@ public MissionRecordSummaryResponse missionRecordFindSummary() { return missionService.findSummaryMissionRecord(); } + @Operation(summary = "종료미션 보관함", description = "종료된 미션 리스트를 조회합니다.") + @GetMapping("/finished") + public List missionFindAllFinished() { + return missionService.findAllFinishedMission(); + } + @Operation(summary = "번개 스택 조회", description = "완료한 미션 대상으로 번개 스택을 조회합니다.") @GetMapping("/symbol/{memberId}") public MissionSymbolStackResponse missionSymbolStackFind(@PathVariable Long memberId) { diff --git a/src/main/java/com/depromeet/domain/mission/application/MissionService.java b/src/main/java/com/depromeet/domain/mission/application/MissionService.java index d24edee7c..fd0ef7f8b 100644 --- a/src/main/java/com/depromeet/domain/mission/application/MissionService.java +++ b/src/main/java/com/depromeet/domain/mission/application/MissionService.java @@ -15,6 +15,7 @@ import com.depromeet.global.error.exception.CustomException; import com.depromeet.global.error.exception.ErrorCode; import com.depromeet.global.util.MemberUtil; +import com.depromeet.global.util.SecurityUtil; import java.time.Duration; import java.time.LocalDate; import java.time.LocalDateTime; @@ -35,6 +36,7 @@ public class MissionService { private final MissionRecordTtlRepository missionRecordTtlRepository; private final MemberRelationRepository memberRelationRepository; private final MemberUtil memberUtil; + private final SecurityUtil securityUtil; public MissionCreateResponse createMission(MissionCreateRequest missionCreateRequest) { Mission mission = createMissionEntity(missionCreateRequest); @@ -116,8 +118,8 @@ public MissionRecordSummaryResponse findSummaryMissionRecord() { missions.stream() .mapToLong( mission -> - Duration.between(mission.getStartedAt().minusDays(1), today) - .toDays()) + Duration.between(mission.getStartedAt(), today).toDays() + + 1) .sum(); // Duration을 초로 바꾸고 합산 long sumDuration = @@ -201,6 +203,39 @@ public MissionSymbolStackResponse findMissionSymbolStack(Long memberId) { return MissionSymbolStackResponse.of(symbolStack); } + @Transactional(readOnly = true) + public List findAllFinishedMission() { + Long currentMemberId = securityUtil.getCurrentMemberId(); + + List finishedMissions = missionRepository.findAllFinishedMission(currentMemberId); + + return finishedMissions.stream() + .map( + mission -> { + long totalMissionDay = + Duration.between( + mission.getStartedAt(), + mission.getFinishedAt()) + .toDays() + + 1; + long completeCount = + mission.getMissionRecords().stream() + .filter( + missionRecord -> + missionRecord + .getUploadStatus() + .equals( + ImageUploadStatus + .COMPLETE)) + .count(); + + return FinishedMissionResponse.of( + mission, + calculateMissionAttainRate(completeCount, totalMissionDay)); + }) + .toList(); + } + public MissionUpdateResponse updateMission( MissionUpdateRequest missionUpdateRequest, Long missionId) { Mission mission = diff --git a/src/main/java/com/depromeet/domain/mission/dao/MissionRepositoryCustom.java b/src/main/java/com/depromeet/domain/mission/dao/MissionRepositoryCustom.java index 211e83f2f..085b4e967 100644 --- a/src/main/java/com/depromeet/domain/mission/dao/MissionRepositoryCustom.java +++ b/src/main/java/com/depromeet/domain/mission/dao/MissionRepositoryCustom.java @@ -13,4 +13,6 @@ public interface MissionRepositoryCustom { List findMissionsWithRecordsByRelations(Long memberId, boolean existsMemberRelations); void updateFinishedDurationStatus(LocalDateTime today); + + List findAllFinishedMission(Long memberId); } diff --git a/src/main/java/com/depromeet/domain/mission/dao/MissionRepositoryImpl.java b/src/main/java/com/depromeet/domain/mission/dao/MissionRepositoryImpl.java index d51e8fac9..6ce7a6f29 100644 --- a/src/main/java/com/depromeet/domain/mission/dao/MissionRepositoryImpl.java +++ b/src/main/java/com/depromeet/domain/mission/dao/MissionRepositoryImpl.java @@ -1,5 +1,6 @@ package com.depromeet.domain.mission.dao; +import static com.depromeet.domain.member.domain.QMember.*; import static com.depromeet.domain.mission.domain.QMission.*; import static com.depromeet.domain.missionRecord.domain.QMissionRecord.*; @@ -12,6 +13,9 @@ import java.time.LocalDateTime; import java.util.List; import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Pageable; +import org.springframework.data.domain.Slice; +import org.springframework.data.domain.SliceImpl; import org.springframework.stereotype.Repository; @Repository @@ -69,6 +73,18 @@ public void updateFinishedDurationStatus(LocalDateTime today) { .execute(); } + @Override + public List findAllFinishedMission(Long memberId) { + return jpaQueryFactory + .selectFrom(mission) + .leftJoin(mission.missionRecords, missionRecord) + .fetchJoin() + .where(memberIdEq(memberId), durationStatusFinishedEq()) + .orderBy(mission.finishedAt.desc()) + .fetch(); + } + + // 미션의 사용자 id 조건 검증 메서드 private BooleanExpression memberIdEq(Long memberId) { return memberId == null ? null : mission.member.id.eq(memberId); } @@ -82,4 +98,27 @@ private BooleanExpression visibilityByRelations(boolean existsRelations) { private BooleanExpression durationStatusInProgress() { return mission.durationStatus.in(DurationStatus.IN_PROGRESS); } + + // lastId보다 작은 미션 id 찾는 조건 메서드 (lastId 가 있다면 마지막 요청) + private BooleanExpression ltMissionId(Long lastId) { + return lastId == null ? null : mission.id.lt(lastId); + } + + private BooleanExpression durationStatusFinishedEq() { + return mission.durationStatus.eq(DurationStatus.FINISHED); + } + + // 무한 스크롤 방식 처리하는 메서드 + private Slice checkLastPage(int size, List result) { + + boolean hasNext = false; + + // 조회한 결과 개수가 요청한 페이지 사이즈보다 크면 뒤에 더 있음, next = true + if (result.size() > size) { + hasNext = true; + result.remove(size); + } + Pageable pageable = Pageable.unpaged(); + return new SliceImpl<>(result, pageable, hasNext); + } } diff --git a/src/main/java/com/depromeet/domain/mission/dto/response/FinishedMissionResponse.java b/src/main/java/com/depromeet/domain/mission/dto/response/FinishedMissionResponse.java new file mode 100644 index 000000000..4a4d59dea --- /dev/null +++ b/src/main/java/com/depromeet/domain/mission/dto/response/FinishedMissionResponse.java @@ -0,0 +1,43 @@ +package com.depromeet.domain.mission.dto.response; + +import com.depromeet.domain.mission.domain.Mission; +import com.depromeet.domain.mission.domain.MissionCategory; +import com.fasterxml.jackson.annotation.JsonFormat; +import io.swagger.v3.oas.annotations.media.Schema; +import java.time.LocalDateTime; + +public record FinishedMissionResponse( + @Schema(description = "미션 ID", defaultValue = "1") Long missionId, + @Schema(description = "미션 이름", defaultValue = "default name") String name, + @Schema(description = "미션 내용", defaultValue = "default content") String content, + @Schema(description = "미션 카테고리", defaultValue = "STUDY") MissionCategory category, + @Schema(description = "미션 달성률", defaultValue = "1.1") double missionAttainRate, + @JsonFormat( + shape = JsonFormat.Shape.STRING, + pattern = "yyyy-MM-dd HH:mm:ss", + timezone = "Asia/Seoul") + @Schema( + description = "미션 시작 시간", + defaultValue = "2024-01-01 00:34:00", + type = "string") + LocalDateTime startedAt, + @JsonFormat( + shape = JsonFormat.Shape.STRING, + pattern = "yyyy-MM-dd HH:mm:ss", + timezone = "Asia/Seoul") + @Schema( + description = "미션 종료 시간", + defaultValue = "2024-01-15 00:34:00", + type = "string") + LocalDateTime finishedAt) { + public static FinishedMissionResponse of(Mission mission, double missionAttainRate) { + return new FinishedMissionResponse( + mission.getId(), + mission.getName(), + mission.getContent(), + mission.getCategory(), + missionAttainRate, + mission.getStartedAt(), + mission.getFinishedAt()); + } +} diff --git a/src/main/java/com/depromeet/domain/missionRecord/dao/MissionRecordRepositoryCustom.java b/src/main/java/com/depromeet/domain/missionRecord/dao/MissionRecordRepositoryCustom.java index ee09b5e1e..03ce2853a 100644 --- a/src/main/java/com/depromeet/domain/missionRecord/dao/MissionRecordRepositoryCustom.java +++ b/src/main/java/com/depromeet/domain/missionRecord/dao/MissionRecordRepositoryCustom.java @@ -1,5 +1,8 @@ package com.depromeet.domain.missionRecord.dao; +import com.depromeet.domain.feed.dto.response.FeedOneResponse; +import com.depromeet.domain.member.domain.Member; +import com.depromeet.domain.mission.domain.MissionVisibility; import com.depromeet.domain.missionRecord.domain.MissionRecord; import java.time.YearMonth; import java.util.List; @@ -8,6 +11,10 @@ public interface MissionRecordRepositoryCustom { List findAllByMissionIdAndYearMonth(Long missionId, YearMonth yearMonth); + List findFeedAll(List members); + + List findFeedAllByMemberId(Long memberId, List visibilities); + boolean isCompletedMissionExistsToday(Long missionId); void deleteByMissionRecordId(Long missionRecordId); diff --git a/src/main/java/com/depromeet/domain/missionRecord/dao/MissionRecordRepositoryImpl.java b/src/main/java/com/depromeet/domain/missionRecord/dao/MissionRecordRepositoryImpl.java index 498e8de8f..63d5d7338 100644 --- a/src/main/java/com/depromeet/domain/missionRecord/dao/MissionRecordRepositoryImpl.java +++ b/src/main/java/com/depromeet/domain/missionRecord/dao/MissionRecordRepositoryImpl.java @@ -1,8 +1,15 @@ package com.depromeet.domain.missionRecord.dao; +import static com.depromeet.domain.member.domain.QMember.*; +import static com.depromeet.domain.mission.domain.QMission.*; import static com.depromeet.domain.missionRecord.domain.QMissionRecord.*; +import com.depromeet.domain.feed.dto.response.FeedOneResponse; +import com.depromeet.domain.member.domain.Member; +import com.depromeet.domain.mission.domain.MissionVisibility; +import com.depromeet.domain.missionRecord.domain.ImageUploadStatus; import com.depromeet.domain.missionRecord.domain.MissionRecord; +import com.querydsl.core.types.Projections; import com.querydsl.core.types.dsl.BooleanExpression; import com.querydsl.jpa.impl.JPAQueryFactory; import java.time.LocalDate; @@ -16,6 +23,7 @@ public class MissionRecordRepositoryImpl implements MissionRecordRepositoryCustom { private final JPAQueryFactory jpaQueryFactory; + private static final long FEED_TAB_LIMIT = 100; @Override public List findAllByMissionIdAndYearMonth(Long missionId, YearMonth yearMonth) { @@ -44,6 +52,53 @@ public boolean isCompletedMissionExistsToday(Long missionId) { return missionRecordFetchOne != null; } + @Override + public List findFeedAll(List members) { + return jpaQueryFactory + .select( + Projections.constructor( + FeedOneResponse.class, + member.id, + member.profile.nickname, + member.profile.profileImageUrl, + mission.id, + mission.name, + missionRecord.id, + missionRecord.remark, + missionRecord.imageUrl, + missionRecord.duration, + missionRecord.startedAt, + missionRecord.finishedAt)) + .from(missionRecord) + .leftJoin(missionRecord.mission, mission) + .on(mission.id.eq(missionRecord.mission.id)) + .leftJoin(mission.member, member) + .on(mission.member.id.eq(missionRecord.mission.member.id)) + .where( + missionRecord.mission.member.in(members), + missionRecord.mission.visibility.in( + MissionVisibility.FOLLOWER, MissionVisibility.ALL), + missionRecord.uploadStatus.eq(ImageUploadStatus.COMPLETE)) + .orderBy(missionRecord.startedAt.desc()) + .limit(FEED_TAB_LIMIT) + .fetch(); + } + + @Override + public List findFeedAllByMemberId( + Long memberId, List visibilities) { + return jpaQueryFactory + .selectFrom(missionRecord) + .leftJoin(missionRecord.mission, mission) + .fetchJoin() + .where( + mission.visibility.in(visibilities), + mission.member.id.eq(memberId), + missionRecord.uploadStatus.eq(ImageUploadStatus.COMPLETE)) + .orderBy(missionRecord.startedAt.desc()) + .fetch(); + } + @Override public void deleteByMissionRecordId(Long missionRecordId) { jpaQueryFactory.delete(missionRecord).where(missionRecord.id.eq(missionRecordId)).execute(); diff --git a/src/main/java/com/depromeet/domain/notification/api/.gitkeep b/src/main/java/com/depromeet/domain/notification/api/.gitkeep deleted file mode 100644 index e69de29bb..000000000 diff --git a/src/main/java/com/depromeet/domain/notification/application/NotificationService.java b/src/main/java/com/depromeet/domain/notification/application/NotificationService.java deleted file mode 100644 index e3bc361fd..000000000 --- a/src/main/java/com/depromeet/domain/notification/application/NotificationService.java +++ /dev/null @@ -1,24 +0,0 @@ -package com.depromeet.domain.notification.application; - -import com.depromeet.domain.member.domain.Member; -import com.depromeet.domain.notification.dao.NotificationRepository; -import com.depromeet.domain.notification.domain.Notification; -import com.depromeet.domain.notification.domain.NotificationType; -import lombok.RequiredArgsConstructor; -import org.springframework.stereotype.Service; -import org.springframework.transaction.annotation.Transactional; - -@Service -@RequiredArgsConstructor -@Transactional -public class NotificationService { - - private final NotificationRepository notificationRepository; - - public void createNotification( - NotificationType notificationType, Member currentMember, Member targetMember) { - Notification notification = - Notification.createNotification(notificationType, currentMember, targetMember); - notificationRepository.save(notification); - } -} diff --git a/src/main/java/com/depromeet/domain/notification/dao/NotificationRepository.java b/src/main/java/com/depromeet/domain/notification/dao/NotificationRepository.java index 1cd6cb78b..0b70f4801 100644 --- a/src/main/java/com/depromeet/domain/notification/dao/NotificationRepository.java +++ b/src/main/java/com/depromeet/domain/notification/dao/NotificationRepository.java @@ -1,6 +1,11 @@ package com.depromeet.domain.notification.dao; import com.depromeet.domain.notification.domain.Notification; +import com.depromeet.domain.notification.domain.NotificationType; +import java.util.Optional; import org.springframework.data.jpa.repository.JpaRepository; -public interface NotificationRepository extends JpaRepository {} +public interface NotificationRepository extends JpaRepository { + Optional findBySourceMemberIdAndTargetMemberIdAndNotificationType( + Long sourceId, Long targetId, NotificationType notificationType); +} diff --git a/src/main/java/com/depromeet/domain/notification/dto/.gitkeep b/src/main/java/com/depromeet/domain/notification/dto/.gitkeep deleted file mode 100644 index e69de29bb..000000000 diff --git a/src/main/java/com/depromeet/global/common/constants/UrlConstants.java b/src/main/java/com/depromeet/global/common/constants/UrlConstants.java index 501277f99..eefb6fb6d 100644 --- a/src/main/java/com/depromeet/global/common/constants/UrlConstants.java +++ b/src/main/java/com/depromeet/global/common/constants/UrlConstants.java @@ -13,6 +13,9 @@ public enum UrlConstants { PROD_DOMAIN_URL("https://www.10mm.today"), DEV_DOMAIN_URL("https://www.dev.10mm.today"), LOCAL_DOMAIN_URL("http://localhost:3000"), + LOCAL_SECURE_DOMAIN_URL("https://localhost:3000"), + + NGROK_DOMAIN_URL("https://*.ngrok-free.app"), IMAGE_DOMAIN_URL("https://image.10mm.today"), ; diff --git a/src/main/java/com/depromeet/global/config/security/WebSecurityConfig.java b/src/main/java/com/depromeet/global/config/security/WebSecurityConfig.java index b149a7118..49aa28b35 100644 --- a/src/main/java/com/depromeet/global/config/security/WebSecurityConfig.java +++ b/src/main/java/com/depromeet/global/config/security/WebSecurityConfig.java @@ -126,6 +126,8 @@ public CorsConfigurationSource corsConfigurationSource() { if (springEnvironmentUtil.isDevProfile()) { configuration.addAllowedOriginPattern(UrlConstants.DEV_DOMAIN_URL.getValue()); configuration.addAllowedOriginPattern(UrlConstants.LOCAL_DOMAIN_URL.getValue()); + configuration.addAllowedOriginPattern(UrlConstants.LOCAL_SECURE_DOMAIN_URL.getValue()); + configuration.addAllowedOriginPattern(UrlConstants.NGROK_DOMAIN_URL.getValue()); } configuration.addAllowedHeader("*"); diff --git a/src/main/resources/application-local.yml b/src/main/resources/application-local.yml index 8ffb6c953..3ed416967 100644 --- a/src/main/resources/application-local.yml +++ b/src/main/resources/application-local.yml @@ -15,3 +15,4 @@ logging: level: org.springframework.orm.jpa: DEBUG org.springframework.transaction: DEBUG + diff --git a/src/test/java/com/depromeet/domain/follow/application/FollowServiceTest.java b/src/test/java/com/depromeet/domain/follow/application/FollowServiceTest.java index 0d8ffad4e..f5a05bafa 100644 --- a/src/test/java/com/depromeet/domain/follow/application/FollowServiceTest.java +++ b/src/test/java/com/depromeet/domain/follow/application/FollowServiceTest.java @@ -8,10 +8,7 @@ import com.depromeet.domain.follow.domain.MemberRelation; import com.depromeet.domain.follow.dto.request.FollowCreateRequest; import com.depromeet.domain.follow.dto.request.FollowDeleteRequest; -import com.depromeet.domain.follow.dto.response.FollowFindMeInfoResponse; -import com.depromeet.domain.follow.dto.response.FollowFindTargetInfoResponse; -import com.depromeet.domain.follow.dto.response.FollowStatus; -import com.depromeet.domain.follow.dto.response.MemberFollowedResponse; +import com.depromeet.domain.follow.dto.response.*; import com.depromeet.domain.member.dao.MemberRepository; import com.depromeet.domain.member.domain.Member; import com.depromeet.domain.member.domain.Profile; @@ -496,4 +493,90 @@ class 내가_팔로우한_유저_정보_리스트를_조회할_때 { assertEquals("targetMember2", response.get(1).nickname()); } } + + @Nested + class 나의_팔로워를_삭제할_때 { + @Test + void 로그인된_회원이_존재하지_않는다면_예외를_발생시킨다() { + // when, then + assertThatThrownBy(() -> followService.deleteFollower(224L)) + .isInstanceOf(CustomException.class) + .hasMessage(ErrorCode.MEMBER_NOT_FOUND.getMessage()); + } + + @Test + void 삭제하려는_회원이_존재하지_않는다면_예외를_발생시킨다() { + // given + Member currentMember = + memberRepository.save( + Member.createNormalMember( + Profile.createProfile("currentMember", "currentMember"))); + + // when, then + assertThatThrownBy(() -> followService.deleteFollower(224L)) + .isInstanceOf(CustomException.class) + .hasMessage(ErrorCode.FOLLOW_TARGET_MEMBER_NOT_FOUND.getMessage()); + } + + @Test + void 삭제하려는_유저가_나의_팔로워가_아니라면_예외가_발생한다() { + // given + Member currentMember = + memberRepository.save( + Member.createNormalMember( + Profile.createProfile("currentMember", "currentMember"))); + Member targetMember1 = + memberRepository.save( + Member.createNormalMember( + Profile.createProfile("targetMember1", "targetMember1"))); + // when, then + assertThatThrownBy(() -> followService.deleteFollower(targetMember1.getId())) + .isInstanceOf(CustomException.class) + .hasMessage(ErrorCode.FOLLOW_NOT_EXIST.getMessage()); + } + + @Test + void 내가_팔로우_하고_있다면_FOLLOWER_STATUS가_FOLLOWING로_응답한다() { + // given + Member currentMember = + memberRepository.save( + Member.createNormalMember( + Profile.createProfile("currentMember", "currentMember"))); + Member targetMember1 = + memberRepository.save( + Member.createNormalMember( + Profile.createProfile("targetMember1", "targetMember1"))); + memberRelationRepository.save( + MemberRelation.createMemberRelation(targetMember1, currentMember)); + memberRelationRepository.save( + MemberRelation.createMemberRelation(currentMember, targetMember1)); + + // when + FollowerDeletedResponse response = followService.deleteFollower(targetMember1.getId()); + + // then + assertEquals(FollowStatus.FOLLOWING, response.followStatus()); + } + + @Test + void 내가_팔로우_하고_있지_않다면_FOLLOWER_STATUS가_NOT_FOLLOWING로_응답한다() { + // given + Member currentMember = + memberRepository.save( + Member.createNormalMember( + Profile.createProfile("currentMember", "currentMember"))); + Member targetMember1 = + memberRepository.save( + Member.createNormalMember( + Profile.createProfile("targetMember1", "targetMember1"))); + memberRelationRepository.save( + MemberRelation.createMemberRelation(targetMember1, currentMember)); + + // when + FollowerDeletedResponse response = followService.deleteFollower(targetMember1.getId()); + + // then + assertEquals(FollowStatus.NOT_FOLLOWING, response.followStatus()); + } + } } diff --git a/src/test/java/com/depromeet/domain/member/application/MemberServiceTest.java b/src/test/java/com/depromeet/domain/member/application/MemberServiceTest.java index 319a4fa05..a3a12112c 100644 --- a/src/test/java/com/depromeet/domain/member/application/MemberServiceTest.java +++ b/src/test/java/com/depromeet/domain/member/application/MemberServiceTest.java @@ -200,7 +200,7 @@ void setUp() { } @Test - void 검색_키워드에_공백에_따른_처리만_허용한다() { + void 검색_키워드에_공백만_존재하는_경우_SIZE는_0이다() { // given memberRepository.save(Member.createNormalMember(Profile.createProfile("바보", "도모 얼굴"))); memberRepository.save( @@ -216,7 +216,7 @@ void setUp() { memberService.searchMemberNickname(searchNickname); // then - assertEquals(3, responses.size()); + assertEquals(0, responses.size()); } @Test