From 72dc028e4cbb0c132fcebac0eab90998683de990 Mon Sep 17 00:00:00 2001 From: Jaehyun Ahn <91878695+uwoobeat@users.noreply.github.com> Date: Sun, 11 Feb 2024 20:42:18 +0900 Subject: [PATCH 1/6] =?UTF-8?q?feat:=20=EB=A6=AC=EC=95=A1=EC=85=98=20?= =?UTF-8?q?=EA=B8=B0=EB=8A=A5=20=EA=B5=AC=ED=98=84=20(#312)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: 리액션 도메인 구현 * feat: 미션기록에 리액션 조회용 일대다 관계 추가 * feat: 리액션 레포지터리 추가 * feat: 리액션 추가 API 구현 * feat: 리액션 추가 서비스 로직 구현 * feat: 리액션 삭제 서비스 로직 구현 * feat: 리액션 삭제 API 구현 * test: 리액션 서비스 테스트 코드 작성 * fix: 다른 사람의 미션기록에도 리액션 남길 수 있도록 수정 * refactor: 단일 리액션 정책 검증 메서드 이름 수정 * feat: 자신의 미션에는 리액션 불가 정책 추가 * fix: 조건문 수정 * test: 셀프 리액션 정책 관련 테스트 수정 * test: 셀프 리액션 관련 정책 테스트에 반영 * refactor: dto 요청 / 응답 패키지 분리 * feat: 리액션 수정 API 구현 * feat: 리액션 수정 서비스 로직 구현 * test: 리액션 수정 테스트 작성 * style: spotless 적용 * feat: 리액션 조회 API 구현 * feat: 리액션 조회 관련 DTO 매핑 로직 구현 * feat: 리액션 조회 서비스 로직 구현 * feat: 리액션 조회 DAO 및 쿼리 구현 * feat: 리액션 조회 API 응답 DTO 수정 * refactor: QueryDSL을 통해 groupBy 하도록 변경 * feat: 조회 결과를 DTO로 변환하고 정렬하는 로직 추가 * fix: 부트 3.0 QueryDSL transform 이슈 수정 * refactor: fetchJoin으로 한번에 조회하도록 개선 * test: 리액션 조회 테스트 추가 * refactor: 필드명 수정 * refactor: 접근제어자 수정 * feat: 이모지 그룹 당 가장 최근 리액션부터 반환하도록 변경 * test: 리액션 생성순서 내림차순 정렬 테스트 추가 * refactor: 일대다 관계 제거 * refactor: 정책 예외는 409 리턴하도록 변경 * feat: 자신의 미션기록 리액션만 조회할 수 있도록 변경 * test: 미션기록 리액션의 소유권 정책 검증 테스트에 추가 * style: spotless 적용 * refactor: 사용하지 않는 DTO 삭제 * refactor: 정적 팩토리 메서드 컨벤션에 맞게 수정 * style: 개행 추가 * chore: 테스트 트리거 활성화 * refactor: 응답 DTO 필드명 수정 * feat: 자신의 미션기록이 아니어도 조회할 수 있도록 변경 * chore: 테스트 트리거 비활성화 --- .../domain/member/dto/MemberProfileDto.java | 13 + .../reaction/api/ReactionController.java | 57 +++ .../reaction/application/ReactionService.java | 100 +++++ .../reaction/dao/ReactionRepository.java | 11 + .../dao/ReactionRepositoryCustom.java | 11 + .../reaction/dao/ReactionRepositoryImpl.java | 32 ++ .../domain/reaction/domain/EmojiType.java | 12 + .../domain/reaction/domain/Reaction.java | 63 +++ .../dto/request/ReactionCreateRequest.java | 13 + .../dto/request/ReactionUpdateRequest.java | 10 + .../dto/response/ReactionCreateResponse.java | 20 + .../ReactionGroupByEmojiResponse.java | 33 ++ .../dto/response/ReactionUpdateResponse.java | 12 + .../config/querydsl/QueryDslConfig.java | 3 +- .../global/error/exception/ErrorCode.java | 6 + .../com/depromeet/TestQuerydslConfig.java | 3 +- .../application/ReactionServiceTest.java | 421 ++++++++++++++++++ 17 files changed, 818 insertions(+), 2 deletions(-) create mode 100644 src/main/java/com/depromeet/domain/member/dto/MemberProfileDto.java create mode 100644 src/main/java/com/depromeet/domain/reaction/api/ReactionController.java create mode 100644 src/main/java/com/depromeet/domain/reaction/application/ReactionService.java create mode 100644 src/main/java/com/depromeet/domain/reaction/dao/ReactionRepository.java create mode 100644 src/main/java/com/depromeet/domain/reaction/dao/ReactionRepositoryCustom.java create mode 100644 src/main/java/com/depromeet/domain/reaction/dao/ReactionRepositoryImpl.java create mode 100644 src/main/java/com/depromeet/domain/reaction/domain/EmojiType.java create mode 100644 src/main/java/com/depromeet/domain/reaction/domain/Reaction.java create mode 100644 src/main/java/com/depromeet/domain/reaction/dto/request/ReactionCreateRequest.java create mode 100644 src/main/java/com/depromeet/domain/reaction/dto/request/ReactionUpdateRequest.java create mode 100644 src/main/java/com/depromeet/domain/reaction/dto/response/ReactionCreateResponse.java create mode 100644 src/main/java/com/depromeet/domain/reaction/dto/response/ReactionGroupByEmojiResponse.java create mode 100644 src/main/java/com/depromeet/domain/reaction/dto/response/ReactionUpdateResponse.java create mode 100644 src/test/java/com/depromeet/domain/reaction/application/ReactionServiceTest.java diff --git a/src/main/java/com/depromeet/domain/member/dto/MemberProfileDto.java b/src/main/java/com/depromeet/domain/member/dto/MemberProfileDto.java new file mode 100644 index 000000000..9c97361f9 --- /dev/null +++ b/src/main/java/com/depromeet/domain/member/dto/MemberProfileDto.java @@ -0,0 +1,13 @@ +package com.depromeet.domain.member.dto; + +import com.depromeet.domain.member.domain.Member; + +public record MemberProfileDto(Long memberId, String nickname, String profileImageUrl) { + + public static MemberProfileDto from(Member member) { + return new MemberProfileDto( + member.getId(), + member.getProfile().getNickname(), + member.getProfile().getProfileImageUrl()); + } +} diff --git a/src/main/java/com/depromeet/domain/reaction/api/ReactionController.java b/src/main/java/com/depromeet/domain/reaction/api/ReactionController.java new file mode 100644 index 000000000..1a9d922ef --- /dev/null +++ b/src/main/java/com/depromeet/domain/reaction/api/ReactionController.java @@ -0,0 +1,57 @@ +package com.depromeet.domain.reaction.api; + +import com.depromeet.domain.reaction.application.ReactionService; +import com.depromeet.domain.reaction.dto.request.ReactionCreateRequest; +import com.depromeet.domain.reaction.dto.request.ReactionUpdateRequest; +import com.depromeet.domain.reaction.dto.response.ReactionCreateResponse; +import com.depromeet.domain.reaction.dto.response.ReactionGroupByEmojiResponse; +import com.depromeet.domain.reaction.dto.response.ReactionUpdateResponse; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.validation.Valid; +import java.util.List; +import lombok.RequiredArgsConstructor; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; + +@Tag(name = "6. [리액션]", description = "리액션 관련 API") +@RestController +@RequestMapping("/reactions") +@RequiredArgsConstructor +public class ReactionController { + + private final ReactionService reactionService; + + @Operation(summary = "리액션 조회", description = "미션 기록에 추가된 리액션을 조회합니다.") + @GetMapping + public ResponseEntity> reactionFindAll( + @RequestParam Long missionRecordId) { + List response = + reactionService.findAllReaction(missionRecordId); + return ResponseEntity.ok(response); + } + + @Operation(summary = "리액션 추가", description = "미션 기록에 리액션을 추가합니다.") + @PostMapping + public ResponseEntity reactionCreate( + @Valid @RequestBody ReactionCreateRequest request) { + ReactionCreateResponse response = reactionService.createReaction(request); + return ResponseEntity.status(HttpStatus.CREATED).body(response); + } + + @Operation(summary = "리액션 수정", description = "리액션에서 선택한 이모지를 수정합니다.") + @PutMapping("/{reactionId}") + public ResponseEntity reactionUpdate( + @PathVariable Long reactionId, @Valid @RequestBody ReactionUpdateRequest request) { + ReactionUpdateResponse response = reactionService.updateReaction(reactionId, request); + return ResponseEntity.ok(response); + } + + @Operation(summary = "리액션 삭제", description = "미션 기록에서 리액션을 삭제합니다.") + @DeleteMapping("/{reactionId}") + public ResponseEntity reactionDelete(@PathVariable Long reactionId) { + reactionService.deleteReaction(reactionId); + return ResponseEntity.noContent().build(); + } +} diff --git a/src/main/java/com/depromeet/domain/reaction/application/ReactionService.java b/src/main/java/com/depromeet/domain/reaction/application/ReactionService.java new file mode 100644 index 000000000..b92e6c235 --- /dev/null +++ b/src/main/java/com/depromeet/domain/reaction/application/ReactionService.java @@ -0,0 +1,100 @@ +package com.depromeet.domain.reaction.application; + +import static java.util.Comparator.*; + +import com.depromeet.domain.member.domain.Member; +import com.depromeet.domain.missionRecord.dao.MissionRecordRepository; +import com.depromeet.domain.missionRecord.domain.MissionRecord; +import com.depromeet.domain.reaction.dao.ReactionRepository; +import com.depromeet.domain.reaction.domain.EmojiType; +import com.depromeet.domain.reaction.domain.Reaction; +import com.depromeet.domain.reaction.dto.request.ReactionCreateRequest; +import com.depromeet.domain.reaction.dto.request.ReactionUpdateRequest; +import com.depromeet.domain.reaction.dto.response.ReactionCreateResponse; +import com.depromeet.domain.reaction.dto.response.ReactionGroupByEmojiResponse; +import com.depromeet.domain.reaction.dto.response.ReactionUpdateResponse; +import com.depromeet.global.error.exception.CustomException; +import com.depromeet.global.error.exception.ErrorCode; +import com.depromeet.global.util.MemberUtil; +import java.util.List; +import java.util.Map; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +@RequiredArgsConstructor +@Transactional +public class ReactionService { + + private final MemberUtil memberUtil; + private final MissionRecordRepository missionRecordRepository; + private final ReactionRepository reactionRepository; + + public List findAllReaction(Long missionRecordId) { + Map> reactionMap = + reactionRepository.findAllGroupByEmoji(missionRecordId); + + return reactionMap.entrySet().stream() + .map(ReactionGroupByEmojiResponse::from) + .sorted(comparing(ReactionGroupByEmojiResponse::count).reversed()) + .toList(); + } + + public ReactionCreateResponse createReaction(ReactionCreateRequest request) { + final Member member = memberUtil.getCurrentMember(); + MissionRecord missionRecord = + missionRecordRepository + .findById(request.missionRecordId()) + .orElseThrow(() -> new CustomException(ErrorCode.MISSION_RECORD_NOT_FOUND)); + + validateNotMyMissionRecord(member, missionRecord); + validateMyReactionAlreadyExists(member, missionRecord); + + Reaction reaction = Reaction.createReaction(request.emojiType(), member, missionRecord); + return ReactionCreateResponse.from(reactionRepository.save(reaction)); + } + + private void validateNotMyMissionRecord(Member member, MissionRecord missionRecord) { + if (missionRecord.getMission().getMember().equals(member)) { + throw new CustomException(ErrorCode.REACTION_SELF_NOT_ALLOWED); + } + } + + private void validateMyReactionAlreadyExists(Member member, MissionRecord missionRecord) { + if (reactionRepository.existsByMemberAndMissionRecord(member, missionRecord)) { + throw new CustomException(ErrorCode.REACTION_ALREADY_EXISTS); + } + } + + public ReactionUpdateResponse updateReaction(Long reactionId, ReactionUpdateRequest request) { + final Member member = memberUtil.getCurrentMember(); + Reaction reaction = + reactionRepository + .findById(reactionId) + .orElseThrow(() -> new CustomException(ErrorCode.REACTION_NOT_FOUND)); + + validateReactionMemberMismatch(member, reaction); + + reaction.updateEmojiType(request.emojiType()); + return ReactionUpdateResponse.from(reaction); + } + + public void deleteReaction(Long reactionId) { + final Member member = memberUtil.getCurrentMember(); + Reaction reaction = + reactionRepository + .findById(reactionId) + .orElseThrow(() -> new CustomException(ErrorCode.REACTION_NOT_FOUND)); + + validateReactionMemberMismatch(member, reaction); + + reactionRepository.delete(reaction); + } + + private void validateReactionMemberMismatch(Member member, Reaction reaction) { + if (!reaction.getMember().equals(member)) { + throw new CustomException(ErrorCode.REACTION_MEMBER_MISMATCH); + } + } +} diff --git a/src/main/java/com/depromeet/domain/reaction/dao/ReactionRepository.java b/src/main/java/com/depromeet/domain/reaction/dao/ReactionRepository.java new file mode 100644 index 000000000..40e70394f --- /dev/null +++ b/src/main/java/com/depromeet/domain/reaction/dao/ReactionRepository.java @@ -0,0 +1,11 @@ +package com.depromeet.domain.reaction.dao; + +import com.depromeet.domain.member.domain.Member; +import com.depromeet.domain.missionRecord.domain.MissionRecord; +import com.depromeet.domain.reaction.domain.Reaction; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface ReactionRepository + extends JpaRepository, ReactionRepositoryCustom { + boolean existsByMemberAndMissionRecord(Member member, MissionRecord missionRecord); +} diff --git a/src/main/java/com/depromeet/domain/reaction/dao/ReactionRepositoryCustom.java b/src/main/java/com/depromeet/domain/reaction/dao/ReactionRepositoryCustom.java new file mode 100644 index 000000000..97703364a --- /dev/null +++ b/src/main/java/com/depromeet/domain/reaction/dao/ReactionRepositoryCustom.java @@ -0,0 +1,11 @@ +package com.depromeet.domain.reaction.dao; + +import com.depromeet.domain.reaction.domain.EmojiType; +import com.depromeet.domain.reaction.domain.Reaction; +import java.util.List; +import java.util.Map; + +public interface ReactionRepositoryCustom { + + Map> findAllGroupByEmoji(Long missionRecordId); +} diff --git a/src/main/java/com/depromeet/domain/reaction/dao/ReactionRepositoryImpl.java b/src/main/java/com/depromeet/domain/reaction/dao/ReactionRepositoryImpl.java new file mode 100644 index 000000000..14d804025 --- /dev/null +++ b/src/main/java/com/depromeet/domain/reaction/dao/ReactionRepositoryImpl.java @@ -0,0 +1,32 @@ +package com.depromeet.domain.reaction.dao; + +import static com.depromeet.domain.reaction.domain.QReaction.reaction; +import static com.querydsl.core.group.GroupBy.groupBy; +import static com.querydsl.core.group.GroupBy.list; +import static com.querydsl.core.types.Projections.*; + +import com.depromeet.domain.reaction.domain.EmojiType; +import com.depromeet.domain.reaction.domain.Reaction; +import com.querydsl.jpa.impl.JPAQueryFactory; +import java.util.List; +import java.util.Map; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Repository; + +@Repository +@RequiredArgsConstructor +public class ReactionRepositoryImpl implements ReactionRepositoryCustom { + + private final JPAQueryFactory jpaQueryFactory; + + @Override + public Map> findAllGroupByEmoji(Long missionRecordId) { + return jpaQueryFactory + .selectFrom(reaction) + .innerJoin(reaction.member) + .where(reaction.missionRecord.id.eq(missionRecordId)) + .orderBy(reaction.createdAt.desc()) + .fetchJoin() + .transform(groupBy(reaction.emojiType).as(list(reaction))); + } +} diff --git a/src/main/java/com/depromeet/domain/reaction/domain/EmojiType.java b/src/main/java/com/depromeet/domain/reaction/domain/EmojiType.java new file mode 100644 index 000000000..2b9af2775 --- /dev/null +++ b/src/main/java/com/depromeet/domain/reaction/domain/EmojiType.java @@ -0,0 +1,12 @@ +package com.depromeet.domain.reaction.domain; + +public enum EmojiType { + PURPLE_HEART, + UNICORN, + GLOWING_STAR, + SPARKLING_HEART, + EYES, + FIRE, + PARTY_POPPER, + ; +} diff --git a/src/main/java/com/depromeet/domain/reaction/domain/Reaction.java b/src/main/java/com/depromeet/domain/reaction/domain/Reaction.java new file mode 100644 index 000000000..e3a676ee9 --- /dev/null +++ b/src/main/java/com/depromeet/domain/reaction/domain/Reaction.java @@ -0,0 +1,63 @@ +package com.depromeet.domain.reaction.domain; + +import com.depromeet.domain.common.model.BaseTimeEntity; +import com.depromeet.domain.member.domain.Member; +import com.depromeet.domain.missionRecord.domain.MissionRecord; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.EnumType; +import jakarta.persistence.Enumerated; +import jakarta.persistence.FetchType; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.ManyToOne; +import lombok.AccessLevel; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; +import org.hibernate.annotations.Comment; + +@Getter +@Entity +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class Reaction extends BaseTimeEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + @Column(name = "reaction_id") + private Long id; + + @Enumerated(EnumType.STRING) + private EmojiType emojiType; + + @Comment("리액션을 추가한 회원") + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "member_id") + private Member member; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "mission_record_id") + private MissionRecord missionRecord; + + @Builder(access = AccessLevel.PRIVATE) + private Reaction(EmojiType emojiType, Member member, MissionRecord missionRecord) { + this.emojiType = emojiType; + this.member = member; + this.missionRecord = missionRecord; + } + + public static Reaction createReaction( + EmojiType emojiType, Member member, MissionRecord missionRecord) { + return Reaction.builder() + .emojiType(emojiType) + .member(member) + .missionRecord(missionRecord) + .build(); + } + + public void updateEmojiType(EmojiType emojiType) { + this.emojiType = emojiType; + } +} diff --git a/src/main/java/com/depromeet/domain/reaction/dto/request/ReactionCreateRequest.java b/src/main/java/com/depromeet/domain/reaction/dto/request/ReactionCreateRequest.java new file mode 100644 index 000000000..d457f3fcc --- /dev/null +++ b/src/main/java/com/depromeet/domain/reaction/dto/request/ReactionCreateRequest.java @@ -0,0 +1,13 @@ +package com.depromeet.domain.reaction.dto.request; + +import com.depromeet.domain.reaction.domain.EmojiType; +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotNull; + +public record ReactionCreateRequest( + @NotNull(message = "미션 기록 아이디는 비워둘 수 없습니다.") + @Schema(description = "미션 기록 아이디", defaultValue = "1") + Long missionRecordId, + @NotNull(message = "이모지 타입은 비워둘 수 없습니다.") + @Schema(description = "이모지 타입", defaultValue = "PURPLE_HEART") + EmojiType emojiType) {} diff --git a/src/main/java/com/depromeet/domain/reaction/dto/request/ReactionUpdateRequest.java b/src/main/java/com/depromeet/domain/reaction/dto/request/ReactionUpdateRequest.java new file mode 100644 index 000000000..215914765 --- /dev/null +++ b/src/main/java/com/depromeet/domain/reaction/dto/request/ReactionUpdateRequest.java @@ -0,0 +1,10 @@ +package com.depromeet.domain.reaction.dto.request; + +import com.depromeet.domain.reaction.domain.EmojiType; +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotNull; + +public record ReactionUpdateRequest( + @NotNull(message = "이모지 타입은 비워둘 수 없습니다.") + @Schema(description = "이모지 타입", defaultValue = "PURPLE_HEART") + EmojiType emojiType) {} diff --git a/src/main/java/com/depromeet/domain/reaction/dto/response/ReactionCreateResponse.java b/src/main/java/com/depromeet/domain/reaction/dto/response/ReactionCreateResponse.java new file mode 100644 index 000000000..30ecb0f5d --- /dev/null +++ b/src/main/java/com/depromeet/domain/reaction/dto/response/ReactionCreateResponse.java @@ -0,0 +1,20 @@ +package com.depromeet.domain.reaction.dto.response; + +import com.depromeet.domain.reaction.domain.EmojiType; +import com.depromeet.domain.reaction.domain.Reaction; +import io.swagger.v3.oas.annotations.media.Schema; + +public record ReactionCreateResponse( + @Schema(description = "리액션 ID", defaultValue = "1") Long reactionId, + @Schema(description = "이모지 타입", defaultValue = "PURPLE_HEART") EmojiType emojiType, + @Schema(description = "멤버 ID", defaultValue = "1") Long memberId, + @Schema(description = "미션기록 ID", defaultValue = "1") Long missionRecordId) { + + public static ReactionCreateResponse from(Reaction reaction) { + return new ReactionCreateResponse( + reaction.getId(), + reaction.getEmojiType(), + reaction.getMember().getId(), + reaction.getMissionRecord().getId()); + } +} diff --git a/src/main/java/com/depromeet/domain/reaction/dto/response/ReactionGroupByEmojiResponse.java b/src/main/java/com/depromeet/domain/reaction/dto/response/ReactionGroupByEmojiResponse.java new file mode 100644 index 000000000..e313218fb --- /dev/null +++ b/src/main/java/com/depromeet/domain/reaction/dto/response/ReactionGroupByEmojiResponse.java @@ -0,0 +1,33 @@ +package com.depromeet.domain.reaction.dto.response; + +import com.depromeet.domain.member.dto.MemberProfileDto; +import com.depromeet.domain.reaction.domain.EmojiType; +import com.depromeet.domain.reaction.domain.Reaction; +import java.time.LocalDateTime; +import java.util.List; +import java.util.Map; + +public record ReactionGroupByEmojiResponse( + EmojiType emojiType, Integer count, List reactions) { + + public static ReactionGroupByEmojiResponse from(Map.Entry> entry) { + return new ReactionGroupByEmojiResponse( + entry.getKey(), + entry.getValue().size(), + entry.getValue().stream().map(ReactionDetailDto::from).toList()); + } + + public record ReactionDetailDto( + Long reactionId, + LocalDateTime createdAt, + LocalDateTime updatedAt, + MemberProfileDto memberProfile) { + public static ReactionDetailDto from(Reaction reaction) { + return new ReactionDetailDto( + reaction.getId(), + reaction.getCreatedAt(), + reaction.getUpdatedAt(), + MemberProfileDto.from(reaction.getMember())); + } + } +} diff --git a/src/main/java/com/depromeet/domain/reaction/dto/response/ReactionUpdateResponse.java b/src/main/java/com/depromeet/domain/reaction/dto/response/ReactionUpdateResponse.java new file mode 100644 index 000000000..9b8f456b6 --- /dev/null +++ b/src/main/java/com/depromeet/domain/reaction/dto/response/ReactionUpdateResponse.java @@ -0,0 +1,12 @@ +package com.depromeet.domain.reaction.dto.response; + +import com.depromeet.domain.reaction.domain.EmojiType; +import com.depromeet.domain.reaction.domain.Reaction; +import io.swagger.v3.oas.annotations.media.Schema; + +public record ReactionUpdateResponse( + @Schema(description = "이모지 타입", defaultValue = "PURPLE_HEART") EmojiType emojiType) { + public static ReactionUpdateResponse from(Reaction reaction) { + return new ReactionUpdateResponse(reaction.getEmojiType()); + } +} diff --git a/src/main/java/com/depromeet/global/config/querydsl/QueryDslConfig.java b/src/main/java/com/depromeet/global/config/querydsl/QueryDslConfig.java index fadafcac1..414a0e426 100644 --- a/src/main/java/com/depromeet/global/config/querydsl/QueryDslConfig.java +++ b/src/main/java/com/depromeet/global/config/querydsl/QueryDslConfig.java @@ -1,5 +1,6 @@ package com.depromeet.global.config.querydsl; +import com.querydsl.jpa.JPQLTemplates; import com.querydsl.jpa.impl.JPAQueryFactory; import jakarta.persistence.EntityManager; import jakarta.persistence.PersistenceContext; @@ -13,6 +14,6 @@ public class QueryDslConfig { @Bean public JPAQueryFactory jpaQueryFactory() { - return new JPAQueryFactory(entityManager); + return new JPAQueryFactory(JPQLTemplates.DEFAULT, entityManager); } } diff --git a/src/main/java/com/depromeet/global/error/exception/ErrorCode.java b/src/main/java/com/depromeet/global/error/exception/ErrorCode.java index f61e1ddbd..caa8f2b9f 100644 --- a/src/main/java/com/depromeet/global/error/exception/ErrorCode.java +++ b/src/main/java/com/depromeet/global/error/exception/ErrorCode.java @@ -53,6 +53,12 @@ public enum ErrorCode { // Image IMAGE_KEY_NOT_FOUND(HttpStatus.NOT_FOUND, "이미지 키를 찾을 수 없습니다."), IMAGE_FILE_EXTENSION_NOT_FOUND(HttpStatus.NOT_FOUND, "이미지 파일 형식을 찾을 수 없습니다."), + + // Reaction + REACTION_NOT_FOUND(HttpStatus.NOT_FOUND, "해당 리액션을 찾을 수 없습니다."), + REACTION_ALREADY_EXISTS(HttpStatus.CONFLICT, "리액션은 미션기록 당 한번만 가능합니다."), + REACTION_MEMBER_MISMATCH(HttpStatus.CONFLICT, "리액션을 생성한 유저와 로그인된 계정이 일치하지 않습니다."), + REACTION_SELF_NOT_ALLOWED(HttpStatus.CONFLICT, "자신의 미션 기록에는 리액션을 추가할 수 없습니다."), ; private final HttpStatus status; diff --git a/src/test/java/com/depromeet/TestQuerydslConfig.java b/src/test/java/com/depromeet/TestQuerydslConfig.java index 61e92e51d..864d2ed6f 100644 --- a/src/test/java/com/depromeet/TestQuerydslConfig.java +++ b/src/test/java/com/depromeet/TestQuerydslConfig.java @@ -1,5 +1,6 @@ package com.depromeet; +import com.querydsl.jpa.JPQLTemplates; import com.querydsl.jpa.impl.JPAQueryFactory; import jakarta.persistence.EntityManager; import jakarta.persistence.PersistenceContext; @@ -12,6 +13,6 @@ public class TestQuerydslConfig { @Bean public JPAQueryFactory jpaQueryFactory() { - return new JPAQueryFactory(entityManager); + return new JPAQueryFactory(JPQLTemplates.DEFAULT, entityManager); } } diff --git a/src/test/java/com/depromeet/domain/reaction/application/ReactionServiceTest.java b/src/test/java/com/depromeet/domain/reaction/application/ReactionServiceTest.java new file mode 100644 index 000000000..9d2305279 --- /dev/null +++ b/src/test/java/com/depromeet/domain/reaction/application/ReactionServiceTest.java @@ -0,0 +1,421 @@ +package com.depromeet.domain.reaction.application; + +import static org.junit.jupiter.api.Assertions.*; + +import com.depromeet.DatabaseCleaner; +import com.depromeet.domain.member.dao.MemberRepository; +import com.depromeet.domain.member.domain.Member; +import com.depromeet.domain.member.domain.OauthInfo; +import com.depromeet.domain.mission.dao.MissionRepository; +import com.depromeet.domain.mission.domain.Mission; +import com.depromeet.domain.mission.domain.MissionCategory; +import com.depromeet.domain.mission.domain.MissionVisibility; +import com.depromeet.domain.missionRecord.dao.MissionRecordRepository; +import com.depromeet.domain.missionRecord.domain.MissionRecord; +import com.depromeet.domain.reaction.dao.ReactionRepository; +import com.depromeet.domain.reaction.domain.EmojiType; +import com.depromeet.domain.reaction.dto.request.ReactionCreateRequest; +import com.depromeet.domain.reaction.dto.request.ReactionUpdateRequest; +import com.depromeet.domain.reaction.dto.response.ReactionCreateResponse; +import com.depromeet.domain.reaction.dto.response.ReactionGroupByEmojiResponse; +import com.depromeet.domain.reaction.dto.response.ReactionUpdateResponse; +import com.depromeet.global.error.exception.CustomException; +import com.depromeet.global.error.exception.ErrorCode; +import com.depromeet.global.security.PrincipalDetails; +import java.time.Duration; +import java.time.LocalDateTime; +import java.util.List; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.test.context.ActiveProfiles; + +@SpringBootTest +@ActiveProfiles("test") +class ReactionServiceTest { + + private static final LocalDateTime NOW = LocalDateTime.of(2024, 2, 7, 5, 0, 0); + + @Autowired private ReactionService reactionService; + @Autowired private ReactionRepository reactionRepository; + @Autowired private DatabaseCleaner databaseCleaner; + @Autowired private MemberRepository memberRepository; + @Autowired private MissionRepository missionRepository; + @Autowired private MissionRecordRepository missionRecordRepository; + + @BeforeEach + void setUp() { + databaseCleaner.execute(); + } + + private Member saveAndRegisterMember() { + OauthInfo oauthInfo = + OauthInfo.createOauthInfo("testOauthId", "testOauthProvider", "testOauthEmail"); + Member member = Member.createNormalMember(oauthInfo, "testNickname"); + memberRepository.save(member); + PrincipalDetails principalDetails = new PrincipalDetails(member.getId(), "USER"); + Authentication authentication = + new UsernamePasswordAuthenticationToken( + principalDetails, null, principalDetails.getAuthorities()); + SecurityContextHolder.getContext().setAuthentication(authentication); + return member; + } + + private void createMissionAndMissionRecord(Member member) { + Mission mission = + Mission.createMission( + "testMission", + "testDescription", + 1, + MissionCategory.PROJECT, + MissionVisibility.ALL, + NOW.minusDays(5), + NOW.plusDays(5), + member); + missionRepository.save(mission); + MissionRecord missionRecord = + MissionRecord.createMissionRecord( + Duration.ofMinutes(30), NOW.minusMinutes(25), NOW.minusMinutes(5), mission); + missionRecordRepository.save(missionRecord); + } + + @Nested + class 리액션_조회시 { + + private void switchUserAndAddReaction(Long missionRecordId, EmojiType emojiType) { + SecurityContextHolder.clearContext(); // 현재 회원 로그아웃 + + saveAndRegisterMember(); // 다른 회원 생성 및 로그인 + ReactionCreateRequest request = new ReactionCreateRequest(missionRecordId, emojiType); + reactionService.createReaction(request); + } + + private void logoutAndReloginAs(Long memberId) { + SecurityContextHolder.clearContext(); // 현재 회원 로그아웃 + PrincipalDetails principalDetails = new PrincipalDetails(memberId, "USER"); + Authentication authentication = + new UsernamePasswordAuthenticationToken( + principalDetails, null, principalDetails.getAuthorities()); + SecurityContextHolder.getContext().setAuthentication(authentication); + } + + @Test + void 자신의_미션기록이면_성공한다() { + // given + Member member = saveAndRegisterMember(); // 1번 멤버 생성 및 로그인 + createMissionAndMissionRecord(member); + + switchUserAndAddReaction(1L, EmojiType.PURPLE_HEART); // 2번 멤버 생성 및 로그인 후 리액션 추가 + + // when + logoutAndReloginAs(1L); // 1번 멤버로 다시 로그인 + List response = reactionService.findAllReaction(1L); + + // then + assertNotNull(response); + assertEquals(1, response.size()); + } + + @Test + void 타인의_미션기록이면_성공한다() { + // given + Member member = saveAndRegisterMember(); // 1번 멤버 생성 및 로그인 + createMissionAndMissionRecord(member); + + switchUserAndAddReaction(1L, EmojiType.PURPLE_HEART); // 2번 멤버 생성 및 로그인 후 리액션 추가 + + saveAndRegisterMember(); // 3번 멤버 생성 및 로그인 + + // when, then + assertNotNull(reactionService.findAllReaction(1L)); + } + + @Test + void 리액션_이모지_타입별_리액션_개수를_확인할수있다() { + // given + Member member = saveAndRegisterMember(); // 1번 멤버 생성 및 로그인 + createMissionAndMissionRecord(member); + + // 2번 ~ 7번까지 멤버 생성 및 로그인 후 리액션 추가 + switchUserAndAddReaction(1L, EmojiType.PURPLE_HEART); + switchUserAndAddReaction(1L, EmojiType.PURPLE_HEART); + switchUserAndAddReaction(1L, EmojiType.FIRE); + switchUserAndAddReaction(1L, EmojiType.UNICORN); + switchUserAndAddReaction(1L, EmojiType.UNICORN); + switchUserAndAddReaction(1L, EmojiType.UNICORN); + + // when + logoutAndReloginAs(1L); // 1번 멤버로 다시 로그인 + List response = reactionService.findAllReaction(1L); + + // then + assertEquals(3, response.size()); + } + + @Test + void 이모지_타입별_리액션_개수를_확인할수있다() { + // given + Member member = saveAndRegisterMember(); // 1번 멤버 생성 및 로그인 + createMissionAndMissionRecord(member); + + // 2번 ~ 7번까지 멤버 생성 및 로그인 후 리액션 추가 + switchUserAndAddReaction(1L, EmojiType.PURPLE_HEART); + switchUserAndAddReaction(1L, EmojiType.PURPLE_HEART); + switchUserAndAddReaction(1L, EmojiType.FIRE); + switchUserAndAddReaction(1L, EmojiType.UNICORN); + switchUserAndAddReaction(1L, EmojiType.UNICORN); + switchUserAndAddReaction(1L, EmojiType.UNICORN); + + // when + logoutAndReloginAs(1L); // 1번 멤버로 다시 로그인 + List response = reactionService.findAllReaction(1L); + + // then + assertEquals( + 2, + response.stream() + .filter(r -> r.emojiType() == EmojiType.PURPLE_HEART) + .findFirst() + .get() + .count()); + assertEquals( + 1, + response.stream() + .filter(r -> r.emojiType() == EmojiType.FIRE) + .findFirst() + .get() + .count()); + assertEquals( + 3, + response.stream() + .filter(r -> r.emojiType() == EmojiType.UNICORN) + .findFirst() + .get() + .count()); + } + + @Test + void 리액션_개수가_많은_순으로_정렬된다() { + // given + Member member = saveAndRegisterMember(); // 1번 멤버 생성 및 로그인 + createMissionAndMissionRecord(member); + + // 2번 ~ 7번까지 멤버 생성 및 로그인 후 리액션 추가 + switchUserAndAddReaction(1L, EmojiType.PURPLE_HEART); + switchUserAndAddReaction(1L, EmojiType.PURPLE_HEART); + switchUserAndAddReaction(1L, EmojiType.FIRE); + switchUserAndAddReaction(1L, EmojiType.UNICORN); + switchUserAndAddReaction(1L, EmojiType.UNICORN); + switchUserAndAddReaction(1L, EmojiType.UNICORN); + + // when + logoutAndReloginAs(1L); // 1번 멤버로 다시 로그인 + List response = reactionService.findAllReaction(1L); + + // then + assertEquals(EmojiType.UNICORN, response.get(0).emojiType()); + assertEquals(EmojiType.PURPLE_HEART, response.get(1).emojiType()); + assertEquals(EmojiType.FIRE, response.get(2).emojiType()); + } + + @Test + void 리액션_상세정보_리스트가_리액션_생성시간_내림차순으로_정렬된다() { + // given + Member member = saveAndRegisterMember(); // 1번 멤버 생성 및 로그인 + createMissionAndMissionRecord(member); + + // 2번 ~ 8번까지 멤버 생성 및 로그인 후 리액션 추가 + switchUserAndAddReaction(1L, EmojiType.UNICORN); + switchUserAndAddReaction(1L, EmojiType.PURPLE_HEART); + switchUserAndAddReaction(1L, EmojiType.UNICORN); + switchUserAndAddReaction(1L, EmojiType.GLOWING_STAR); + switchUserAndAddReaction(1L, EmojiType.UNICORN); + switchUserAndAddReaction(1L, EmojiType.SPARKLING_HEART); + switchUserAndAddReaction(1L, EmojiType.UNICORN); + + // when + logoutAndReloginAs(1L); // 1번 멤버로 다시 로그인 + List response = reactionService.findAllReaction(1L); + List reactions = + response.get(0).reactions(); + + // then + assertEquals(EmojiType.UNICORN, response.get(0).emojiType()); + assertEquals(4, reactions.size()); // UNICORN 이모지 리액션 개수는 4개 + // reactions 리스트는 생성순서로 내림차순 정렬되어 있어야 한다. + assertTrue(reactions.get(0).createdAt().isAfter(reactions.get(1).createdAt())); + assertTrue(reactions.get(1).createdAt().isAfter(reactions.get(2).createdAt())); + assertTrue(reactions.get(2).createdAt().isAfter(reactions.get(3).createdAt())); + } + } + + @Nested + class 리액션_생성시 { + + @Test + void 내_미션기록에_추가하면_실패한다() { + // given + Member member = saveAndRegisterMember(); + createMissionAndMissionRecord(member); + + ReactionCreateRequest request = new ReactionCreateRequest(1L, EmojiType.PURPLE_HEART); + + // when & then + assertThrows( + CustomException.class, + () -> reactionService.createReaction(request), + ErrorCode.REACTION_SELF_NOT_ALLOWED.getMessage()); + } + + @Test + void 타인의_미션기록에_추가하면_성공한다() { + // given + Member member = saveAndRegisterMember(); + createMissionAndMissionRecord(member); + + SecurityContextHolder.clearContext(); // 현재 회원 로그아웃 + + Member otherMember = saveAndRegisterMember(); // 다른 회원 로그인 + createMissionAndMissionRecord(otherMember); + + ReactionCreateRequest request = new ReactionCreateRequest(1L, EmojiType.PURPLE_HEART); + + // when + ReactionCreateResponse response = reactionService.createReaction(request); + + // then + assertNotNull(response); + assertEquals(1L, response.reactionId()); + } + + @Test + void 이미_리액션을_남긴_미션기록에_리액션을_추가하면_실패한다() { + // given + Member member = saveAndRegisterMember(); + createMissionAndMissionRecord(member); + + SecurityContextHolder.clearContext(); // 현재 회원 로그아웃 + + Member otherMember = saveAndRegisterMember(); // 다른 회원 로그인 + createMissionAndMissionRecord(otherMember); + ReactionCreateRequest request = new ReactionCreateRequest(1L, EmojiType.PURPLE_HEART); + reactionService.createReaction(request); // 첫번째 리액션 추가 + + // when, then + assertThrows( + CustomException.class, + () -> reactionService.createReaction(request), + ErrorCode.REACTION_ALREADY_EXISTS.getMessage()); + } + } + + @Nested + class 리액션_이모지_수정시 { + @Test + void 성공한다() { + // given + Member member = saveAndRegisterMember(); + createMissionAndMissionRecord(member); + + SecurityContextHolder.clearContext(); // 현재 회원 로그아웃 + + Member otherMember = saveAndRegisterMember(); // 다른 회원 로그인 + createMissionAndMissionRecord(otherMember); + + ReactionCreateRequest request = new ReactionCreateRequest(1L, EmojiType.PURPLE_HEART); + ReactionCreateResponse response = reactionService.createReaction(request); + + // when + ReactionUpdateResponse updateResponse = + reactionService.updateReaction( + response.reactionId(), new ReactionUpdateRequest(EmojiType.FIRE)); + + // then + assertEquals(EmojiType.FIRE, updateResponse.emojiType()); + } + + @Test + void 자신의_리액션이_아니면_실패한다() { + // given + Member member1 = saveAndRegisterMember(); + createMissionAndMissionRecord(member1); + + SecurityContextHolder.clearContext(); // 1번 멤버 로그아웃 + + // 2번 멤버 로그인 및 1번 멤버의 미션기록에 리액션 추가 + Member member2 = saveAndRegisterMember(); + createMissionAndMissionRecord(member2); + ReactionCreateRequest request2 = new ReactionCreateRequest(1L, EmojiType.PURPLE_HEART); + reactionService.createReaction(request2); + + // 2번 멤버 로그아웃 및 3번 멤버 로그인 + SecurityContextHolder.clearContext(); + saveAndRegisterMember(); + + // when, then + assertThrows( + CustomException.class, + () -> + reactionService.updateReaction( + 1L, new ReactionUpdateRequest(EmojiType.PURPLE_HEART)), + ErrorCode.REACTION_MEMBER_MISMATCH.getMessage()); + } + } + + @Nested + class 리액션_삭제시 { + + @Test + void 성공한다() { + // given + Member member = saveAndRegisterMember(); + createMissionAndMissionRecord(member); + + SecurityContextHolder.clearContext(); // 현재 회원 로그아웃 + + Member otherMember = saveAndRegisterMember(); // 다른 회원 로그인 + createMissionAndMissionRecord(otherMember); + + ReactionCreateRequest request = new ReactionCreateRequest(1L, EmojiType.PURPLE_HEART); + ReactionCreateResponse response = reactionService.createReaction(request); + + // when + reactionService.deleteReaction(response.reactionId()); + + // then + assertTrue(reactionRepository.findById(response.reactionId()).isEmpty()); + } + + @Test + void 자신의_리액션이_아니면_실패한다() { + // given + Member member1 = saveAndRegisterMember(); + createMissionAndMissionRecord(member1); + + SecurityContextHolder.clearContext(); // 1번 멤버 로그아웃 + + // 2번 멤버 로그인 및 1번 멤버의 미션기록에 리액션 추가 + Member member2 = saveAndRegisterMember(); + createMissionAndMissionRecord(member2); + ReactionCreateRequest request = new ReactionCreateRequest(1L, EmojiType.PURPLE_HEART); + ReactionCreateResponse response = reactionService.createReaction(request); + + Long reactionId = response.reactionId(); + + // 2번 멤버 로그아웃 및 3번 멤버 로그인 + SecurityContextHolder.clearContext(); + saveAndRegisterMember(); + + // when, then + assertThrows( + CustomException.class, + () -> reactionService.deleteReaction(reactionId), + ErrorCode.REACTION_MEMBER_MISMATCH.getMessage()); + } + } +} From 67cf0b4a438dd832721d727d3b5106271839a960 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EB=8F=84=EB=AA=A8?= Date: Mon, 12 Feb 2024 14:54:27 +0900 Subject: [PATCH 2/6] =?UTF-8?q?feat:=20=EC=B9=9C=EA=B5=AC=EA=B0=80=20?= =?UTF-8?q?=EB=8B=B9=EC=9D=BC=20=EB=AF=B8=EC=85=98=EC=9D=84=20=EC=99=84?= =?UTF-8?q?=EB=A3=8C=ED=95=98=EC=A7=80=20=EC=95=8A=EC=9D=80=20=EA=B2=BD?= =?UTF-8?q?=EC=9A=B0=20=EC=9E=AC=EC=B4=89=ED=95=98=EA=B8=B0=20(#309)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: 미션 캘린더 조회 시 오늘 미션 완료했는지 여부 UrgingStatus 필드 추가 * move: 패키지 이동 * move: PushNotificationConstants 패키지 이동 * feat: 친구가 당일 미션을 완료하지 않은 경우 재촉하기 기능 구현 * test: 미션 재촉하기 테스트 코드 작성 * style: spotless * refactor: memberId 비교 equals로 변경 * style: 출력문 제거 * fix: Long 비교 equals로 변경 * style: spotless --- .../follow/application/FollowService.java | 4 +- .../member/application/MemberService.java | 4 +- .../domain/mission/domain/Mission.java | 11 + .../application/MissionRecordService.java | 17 +- .../MissionRecordCalendarResponse.java | 8 +- .../dto/response/UrgingStatus.java | 12 + .../notification/api/PushController.java | 23 ++ .../notification/application}/FcmService.java | 2 +- .../notification/application/PushService.java | 64 +++++ .../notification/domain/NotificationType.java | 1 + .../dto/request/PushUrgingSendRequest.java | 6 + .../constants/PushNotificationConstants.java | 4 +- .../global/error/exception/ErrorCode.java | 5 + .../config/fcm/FcmConfig.java | 2 +- .../application/PushServiceTest.java | 219 ++++++++++++++++++ 15 files changed, 371 insertions(+), 11 deletions(-) create mode 100644 src/main/java/com/depromeet/domain/missionRecord/dto/response/UrgingStatus.java create mode 100644 src/main/java/com/depromeet/domain/notification/api/PushController.java rename src/main/java/com/depromeet/{global/config/fcm => domain/notification/application}/FcmService.java (96%) create mode 100644 src/main/java/com/depromeet/domain/notification/application/PushService.java create mode 100644 src/main/java/com/depromeet/domain/notification/dto/request/PushUrgingSendRequest.java rename src/main/java/com/depromeet/{domain => global}/common/constants/PushNotificationConstants.java (67%) rename src/main/java/com/depromeet/{global => infra}/config/fcm/FcmConfig.java (97%) create mode 100644 src/test/java/com/depromeet/domain/notification/application/PushServiceTest.java 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 85c22a86e..791383809 100644 --- a/src/main/java/com/depromeet/domain/follow/application/FollowService.java +++ b/src/main/java/com/depromeet/domain/follow/application/FollowService.java @@ -1,6 +1,6 @@ package com.depromeet.domain.follow.application; -import static com.depromeet.domain.common.constants.PushNotificationConstants.*; +import static com.depromeet.global.common.constants.PushNotificationConstants.*; import com.depromeet.domain.follow.dao.MemberRelationRepository; import com.depromeet.domain.follow.domain.MemberRelation; @@ -13,10 +13,10 @@ 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.FcmService; 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; import com.depromeet.global.error.exception.ErrorCode; import com.depromeet.global.util.MemberUtil; 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 98b6b9018..8793b8cff 100644 --- a/src/main/java/com/depromeet/domain/member/application/MemberService.java +++ b/src/main/java/com/depromeet/domain/member/application/MemberService.java @@ -1,6 +1,6 @@ package com.depromeet.domain.member.application; -import static com.depromeet.domain.common.constants.PushNotificationConstants.*; +import static com.depromeet.global.common.constants.PushNotificationConstants.*; import com.depromeet.domain.auth.dao.RefreshTokenRepository; import com.depromeet.domain.auth.dto.request.UsernameCheckRequest; @@ -16,7 +16,7 @@ import com.depromeet.domain.member.dto.response.MemberFindOneResponse; import com.depromeet.domain.member.dto.response.MemberSearchResponse; import com.depromeet.domain.member.dto.response.MemberSocialInfoResponse; -import com.depromeet.global.config.fcm.FcmService; +import com.depromeet.domain.notification.application.FcmService; import com.depromeet.global.error.exception.CustomException; import com.depromeet.global.error.exception.ErrorCode; import com.depromeet.global.util.MemberUtil; diff --git a/src/main/java/com/depromeet/domain/mission/domain/Mission.java b/src/main/java/com/depromeet/domain/mission/domain/Mission.java index e27430c37..157384410 100644 --- a/src/main/java/com/depromeet/domain/mission/domain/Mission.java +++ b/src/main/java/com/depromeet/domain/mission/domain/Mission.java @@ -121,4 +121,15 @@ public void updateMission(String name, String content, MissionVisibility visibil this.content = content; this.visibility = visibility; } + + public boolean isCompletedMissionToday() { + return this.getMissionRecords().stream() + .filter( + record -> + record.getStartedAt() + .toLocalDate() + .equals(LocalDateTime.now().toLocalDate())) + .findFirst() + .isPresent(); + } } diff --git a/src/main/java/com/depromeet/domain/missionRecord/application/MissionRecordService.java b/src/main/java/com/depromeet/domain/missionRecord/application/MissionRecordService.java index 745a2c417..b75e62211 100644 --- a/src/main/java/com/depromeet/domain/missionRecord/application/MissionRecordService.java +++ b/src/main/java/com/depromeet/domain/missionRecord/application/MissionRecordService.java @@ -91,13 +91,28 @@ public MissionRecordFindOneResponse findOneMissionRecord(Long recordId) { @Transactional(readOnly = true) public MissionRecordCalendarResponse findAllMissionRecord(Long missionId, YearMonth yearMonth) { + final Member member = memberUtil.getCurrentMember(); List missionRecords = missionRecordRepository.findAllByMissionIdAndYearMonth(missionId, yearMonth); List missionRecordFindResponses = missionRecords.stream().map(MissionRecordFindResponse::from).toList(); Mission mission = findMissionById(missionId); + + UrgingStatus urgingStatus = getUrgingStatus(mission, member); + return MissionRecordCalendarResponse.of( - mission.getStartedAt(), mission.getFinishedAt(), missionRecordFindResponses); + mission.getStartedAt(), + mission.getFinishedAt(), + missionRecordFindResponses, + urgingStatus); + } + + private UrgingStatus getUrgingStatus(Mission mission, Member member) { + if (member.getId().equals(mission.getMember().getId()) + || mission.isCompletedMissionToday()) { + return UrgingStatus.NONE; + } + return UrgingStatus.URGING; } public MissionRecordUpdateResponse updateMissionRecord( diff --git a/src/main/java/com/depromeet/domain/missionRecord/dto/response/MissionRecordCalendarResponse.java b/src/main/java/com/depromeet/domain/missionRecord/dto/response/MissionRecordCalendarResponse.java index 76ebba389..206abdcf4 100644 --- a/src/main/java/com/depromeet/domain/missionRecord/dto/response/MissionRecordCalendarResponse.java +++ b/src/main/java/com/depromeet/domain/missionRecord/dto/response/MissionRecordCalendarResponse.java @@ -24,12 +24,14 @@ public record MissionRecordCalendarResponse( defaultValue = "2024-01-15 00:34:00", type = "string") LocalDateTime missionFinishedAt, - @Schema(description = "미션 기록들") List missionRecords) { + @Schema(description = "미션 기록들") List missionRecords, + @Schema(description = "재촉하기 여부") UrgingStatus urgingStatus) { public static MissionRecordCalendarResponse of( LocalDateTime missionStartedAt, LocalDateTime missionFinishedAt, - List missionRecords) { + List missionRecords, + UrgingStatus urgingStatus) { return new MissionRecordCalendarResponse( - missionStartedAt, missionFinishedAt, missionRecords); + missionStartedAt, missionFinishedAt, missionRecords, urgingStatus); } } diff --git a/src/main/java/com/depromeet/domain/missionRecord/dto/response/UrgingStatus.java b/src/main/java/com/depromeet/domain/missionRecord/dto/response/UrgingStatus.java new file mode 100644 index 000000000..a128a7eb0 --- /dev/null +++ b/src/main/java/com/depromeet/domain/missionRecord/dto/response/UrgingStatus.java @@ -0,0 +1,12 @@ +package com.depromeet.domain.missionRecord.dto.response; + +import lombok.AllArgsConstructor; +import lombok.Getter; + +@Getter +@AllArgsConstructor +public enum UrgingStatus { + URGING, + NONE, + ; +} diff --git a/src/main/java/com/depromeet/domain/notification/api/PushController.java b/src/main/java/com/depromeet/domain/notification/api/PushController.java new file mode 100644 index 000000000..c6b5fd381 --- /dev/null +++ b/src/main/java/com/depromeet/domain/notification/api/PushController.java @@ -0,0 +1,23 @@ +package com.depromeet.domain.notification.api; + +import com.depromeet.domain.notification.application.PushService; +import com.depromeet.domain.notification.dto.request.PushUrgingSendRequest; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import org.springframework.web.bind.annotation.*; + +@Tag(name = "7. [알림]", description = "알림 관련 API") +@RestController +@RequestMapping("/notifications") +@RequiredArgsConstructor +public class PushController { + private final PushService pushService; + + @Operation(summary = "재촉하기", description = "당일 미션을 완료하지 않은 친구에게 재촉하기 Push Message를 발송합니다.") + @PostMapping("/urging") + public void urgingSend(@Valid @RequestBody PushUrgingSendRequest request) { + pushService.sendUrgingPush(request); + } +} diff --git a/src/main/java/com/depromeet/global/config/fcm/FcmService.java b/src/main/java/com/depromeet/domain/notification/application/FcmService.java similarity index 96% rename from src/main/java/com/depromeet/global/config/fcm/FcmService.java rename to src/main/java/com/depromeet/domain/notification/application/FcmService.java index 27d5e81ad..12a405aa4 100644 --- a/src/main/java/com/depromeet/global/config/fcm/FcmService.java +++ b/src/main/java/com/depromeet/domain/notification/application/FcmService.java @@ -1,4 +1,4 @@ -package com.depromeet.global.config.fcm; +package com.depromeet.domain.notification.application; import com.google.api.core.ApiFuture; import com.google.firebase.messaging.*; diff --git a/src/main/java/com/depromeet/domain/notification/application/PushService.java b/src/main/java/com/depromeet/domain/notification/application/PushService.java new file mode 100644 index 000000000..3a4d5eaf6 --- /dev/null +++ b/src/main/java/com/depromeet/domain/notification/application/PushService.java @@ -0,0 +1,64 @@ +package com.depromeet.domain.notification.application; + +import static com.depromeet.global.common.constants.PushNotificationConstants.PUSH_URGING_TITLE; + +import com.depromeet.domain.member.domain.Member; +import com.depromeet.domain.mission.dao.MissionRepository; +import com.depromeet.domain.mission.domain.Mission; +import com.depromeet.domain.notification.dao.NotificationRepository; +import com.depromeet.domain.notification.domain.Notification; +import com.depromeet.domain.notification.domain.NotificationType; +import com.depromeet.domain.notification.dto.request.PushUrgingSendRequest; +import com.depromeet.global.common.constants.PushNotificationConstants; +import com.depromeet.global.error.exception.CustomException; +import com.depromeet.global.error.exception.ErrorCode; +import com.depromeet.global.util.MemberUtil; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +@RequiredArgsConstructor +@Transactional +public class PushService { + private final MemberUtil memberUtil; + private final FcmService fcmService; + private final MissionRepository missionRepository; + private final NotificationRepository notificationRepository; + + public void sendUrgingPush(PushUrgingSendRequest request) { + final Member currentMember = memberUtil.getCurrentMember(); + final Mission mission = + missionRepository + .findById(request.missionId()) + .orElseThrow(() -> new CustomException(ErrorCode.MISSION_NOT_FOUND)); + final Member targetMember = mission.getMember(); + + validateSelfSending(currentMember.getId(), targetMember.getId()); + validateMissionNotCompletedToday(mission); + + fcmService.sendMessageSync( + targetMember.getFcmInfo().getFcmToken(), + PUSH_URGING_TITLE, + String.format( + PushNotificationConstants.PUSH_URGING_CONTENT, + currentMember.getProfile().getNickname(), + mission.getName())); + Notification notification = + Notification.createNotification( + NotificationType.MISSION_URGING, currentMember, targetMember); + notificationRepository.save(notification); + } + + private void validateMissionNotCompletedToday(Mission mission) { + if (mission.isCompletedMissionToday()) { + throw new CustomException(ErrorCode.TODAY_COMPLETED_MISSION_SENDING_NOT_ALLOWED); + } + } + + private void validateSelfSending(Long currentMemberId, Long targetMemberId) { + if (currentMemberId.equals(targetMemberId)) { + throw new CustomException(ErrorCode.SELF_SENDING_NOT_ALLOWED); + } + } +} diff --git a/src/main/java/com/depromeet/domain/notification/domain/NotificationType.java b/src/main/java/com/depromeet/domain/notification/domain/NotificationType.java index 04a53dc20..41980b239 100644 --- a/src/main/java/com/depromeet/domain/notification/domain/NotificationType.java +++ b/src/main/java/com/depromeet/domain/notification/domain/NotificationType.java @@ -7,6 +7,7 @@ @AllArgsConstructor public enum NotificationType { FOLLOW("팔로우"), + MISSION_URGING("재촉하기"), ; private final String value; diff --git a/src/main/java/com/depromeet/domain/notification/dto/request/PushUrgingSendRequest.java b/src/main/java/com/depromeet/domain/notification/dto/request/PushUrgingSendRequest.java new file mode 100644 index 000000000..70bfd1caa --- /dev/null +++ b/src/main/java/com/depromeet/domain/notification/dto/request/PushUrgingSendRequest.java @@ -0,0 +1,6 @@ +package com.depromeet.domain.notification.dto.request; + +import jakarta.validation.constraints.NotNull; + +public record PushUrgingSendRequest( + @NotNull(message = "missionId는 null일 수 없습니다.") Long missionId) {} diff --git a/src/main/java/com/depromeet/domain/common/constants/PushNotificationConstants.java b/src/main/java/com/depromeet/global/common/constants/PushNotificationConstants.java similarity index 67% rename from src/main/java/com/depromeet/domain/common/constants/PushNotificationConstants.java rename to src/main/java/com/depromeet/global/common/constants/PushNotificationConstants.java index 0f74ce743..b90d712a8 100644 --- a/src/main/java/com/depromeet/domain/common/constants/PushNotificationConstants.java +++ b/src/main/java/com/depromeet/global/common/constants/PushNotificationConstants.java @@ -1,4 +1,4 @@ -package com.depromeet.domain.common.constants; +package com.depromeet.global.common.constants; import lombok.AccessLevel; import lombok.NoArgsConstructor; @@ -9,4 +9,6 @@ public class PushNotificationConstants { public static final String PUSH_SERVICE_CONTENT = "%s님이 회원님을 팔로우하기 시작했습니다🥳"; public static final String PUSH_NON_COMPLETE_MISSION_SERVICE_CONTENT = "아직 오늘 미션을 완료하지 않았어요! 10분 동안 빠르게 완료해볼까요?"; + public static final String PUSH_URGING_TITLE = "누가 내 미션을 기다린대요"; + public static final String PUSH_URGING_CONTENT = "%s님이 %s 미션을 기다리고 있어요 🥺"; } diff --git a/src/main/java/com/depromeet/global/error/exception/ErrorCode.java b/src/main/java/com/depromeet/global/error/exception/ErrorCode.java index caa8f2b9f..f4a49109f 100644 --- a/src/main/java/com/depromeet/global/error/exception/ErrorCode.java +++ b/src/main/java/com/depromeet/global/error/exception/ErrorCode.java @@ -54,6 +54,11 @@ public enum ErrorCode { IMAGE_KEY_NOT_FOUND(HttpStatus.NOT_FOUND, "이미지 키를 찾을 수 없습니다."), IMAGE_FILE_EXTENSION_NOT_FOUND(HttpStatus.NOT_FOUND, "이미지 파일 형식을 찾을 수 없습니다."), + // Notification + SELF_SENDING_NOT_ALLOWED(HttpStatus.BAD_REQUEST, "본인에게 메세지를 전송할 수 없습니다."), + TODAY_COMPLETED_MISSION_SENDING_NOT_ALLOWED( + HttpStatus.BAD_REQUEST, "오늘 미션을 완료한 미션에는 메세지를 전송할 수 없습니다."), + // Reaction REACTION_NOT_FOUND(HttpStatus.NOT_FOUND, "해당 리액션을 찾을 수 없습니다."), REACTION_ALREADY_EXISTS(HttpStatus.CONFLICT, "리액션은 미션기록 당 한번만 가능합니다."), diff --git a/src/main/java/com/depromeet/global/config/fcm/FcmConfig.java b/src/main/java/com/depromeet/infra/config/fcm/FcmConfig.java similarity index 97% rename from src/main/java/com/depromeet/global/config/fcm/FcmConfig.java rename to src/main/java/com/depromeet/infra/config/fcm/FcmConfig.java index d9010fd86..da2f66d75 100644 --- a/src/main/java/com/depromeet/global/config/fcm/FcmConfig.java +++ b/src/main/java/com/depromeet/infra/config/fcm/FcmConfig.java @@ -1,4 +1,4 @@ -package com.depromeet.global.config.fcm; +package com.depromeet.infra.config.fcm; import com.google.auth.oauth2.GoogleCredentials; import com.google.firebase.FirebaseApp; diff --git a/src/test/java/com/depromeet/domain/notification/application/PushServiceTest.java b/src/test/java/com/depromeet/domain/notification/application/PushServiceTest.java new file mode 100644 index 000000000..385f79c7c --- /dev/null +++ b/src/test/java/com/depromeet/domain/notification/application/PushServiceTest.java @@ -0,0 +1,219 @@ +package com.depromeet.domain.notification.application; + +import static org.assertj.core.api.AssertionsForClassTypes.assertThatThrownBy; +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.*; + +import com.depromeet.DatabaseCleaner; +import com.depromeet.domain.member.dao.MemberRepository; +import com.depromeet.domain.member.domain.FcmInfo; +import com.depromeet.domain.member.domain.Member; +import com.depromeet.domain.member.domain.Profile; +import com.depromeet.domain.mission.dao.MissionRepository; +import com.depromeet.domain.mission.domain.Mission; +import com.depromeet.domain.mission.domain.MissionCategory; +import com.depromeet.domain.mission.domain.MissionVisibility; +import com.depromeet.domain.missionRecord.dao.MissionRecordRepository; +import com.depromeet.domain.missionRecord.domain.MissionRecord; +import com.depromeet.domain.notification.dao.NotificationRepository; +import com.depromeet.domain.notification.domain.Notification; +import com.depromeet.domain.notification.domain.NotificationType; +import com.depromeet.domain.notification.dto.request.PushUrgingSendRequest; +import com.depromeet.global.error.exception.CustomException; +import com.depromeet.global.error.exception.ErrorCode; +import com.depromeet.global.security.PrincipalDetails; +import com.depromeet.global.util.MemberUtil; +import java.time.Duration; +import java.time.LocalDateTime; +import java.util.Optional; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.test.context.ActiveProfiles; + +@SpringBootTest +@ActiveProfiles("test") +class PushServiceTest { + @Autowired private DatabaseCleaner databaseCleaner; + + @Autowired private MemberUtil memberUtil; + + @MockBean private FcmService fcmService; + + @Autowired private MissionRepository missionRepository; + + @Autowired private NotificationRepository notificationRepository; + + @Autowired private MemberRepository memberRepository; + + @Autowired private MissionRecordRepository missionRecordRepository; + + @Autowired private PushService pushService; + + @BeforeEach + void setUp() { + databaseCleaner.execute(); + PrincipalDetails principal = new PrincipalDetails(1L, "USER"); + Authentication authentication = + new UsernamePasswordAuthenticationToken( + principal, "password", principal.getAuthorities()); + SecurityContextHolder.getContext().setAuthentication(authentication); + } + + @Nested + class 친구에게_미션을_재촉할_때 { + @Test + void 로그인된_회원이_존재하지_않는다면_예외를_발생시킨다() { + // given + PushUrgingSendRequest request = new PushUrgingSendRequest(1L); + + // when, then + assertThatThrownBy(() -> pushService.sendUrgingPush(request)) + .isInstanceOf(CustomException.class) + .hasMessage(ErrorCode.MEMBER_NOT_FOUND.getMessage()); + } + + @Test + void 미션이_존재하지_않는다면_예외를_발생시킨다() { + // given + PushUrgingSendRequest request = new PushUrgingSendRequest(1L); + memberRepository.save( + Member.createNormalMember( + Profile.createProfile("testNickname1", "testImageUrl1"))); + + // when, then + assertThatThrownBy(() -> pushService.sendUrgingPush(request)) + .isInstanceOf(CustomException.class) + .hasMessage(ErrorCode.MISSION_NOT_FOUND.getMessage()); + } + + @Test + void 본인에게_재촉할_경우_예외를_발생시킨다() { + // given + PushUrgingSendRequest request = new PushUrgingSendRequest(1L); + + // when, then + Member currentMember = + memberRepository.save( + Member.createNormalMember( + Profile.createProfile("testNickname1", "testImageUrl1"))); + LocalDateTime today = LocalDateTime.now(); + LocalDateTime missionStartedAt = today; + LocalDateTime missionFinishedAt = today.plusWeeks(2); + missionRepository.save( + Mission.createMission( + "testMissionName", + "testMissionContent", + 1, + MissionCategory.ETC, + MissionVisibility.ALL, + missionStartedAt, + missionFinishedAt, + currentMember)); + + // when, then + assertThatThrownBy(() -> pushService.sendUrgingPush(request)) + .isInstanceOf(CustomException.class) + .hasMessage(ErrorCode.SELF_SENDING_NOT_ALLOWED.getMessage()); + } + + @Test + void 미션이_당일_완료된_경우_예외를_발생시킨다() { + // given + PushUrgingSendRequest request = new PushUrgingSendRequest(1L); + Member currentMember = + memberRepository.save( + Member.createNormalMember( + Profile.createProfile("testNickname1", "testImageUrl1"))); + Member targetMember = + memberRepository.save( + Member.createNormalMember( + Profile.createProfile("testNickname2", "testImageUrl2"))); + + LocalDateTime today = LocalDateTime.now(); + LocalDateTime missionStartedAt = today; + LocalDateTime missionFinishedAt = today.plusWeeks(2); + Mission mission = + missionRepository.save( + Mission.createMission( + "testMissionName", + "testMissionContent", + 1, + MissionCategory.ETC, + MissionVisibility.ALL, + missionStartedAt, + missionFinishedAt, + targetMember)); + + LocalDateTime missionRecordStartedAt = today; + LocalDateTime missionRecordFinishedAt = + missionRecordStartedAt.plusMinutes(32).plusSeconds(14); + Duration duration = Duration.ofMinutes(32).plusSeconds(14); + MissionRecord missionRecord = + missionRecordRepository.save( + MissionRecord.createMissionRecord( + duration, + missionRecordStartedAt, + missionRecordFinishedAt, + mission)); + missionRecord.updateUploadStatusPending(); + missionRecord.updateUploadStatusComplete("remark", "imageUrl"); + missionRecordRepository.save(missionRecord); + + // when, then + assertThatThrownBy(() -> pushService.sendUrgingPush(request)) + .isInstanceOf(CustomException.class) + .hasMessage(ErrorCode.TODAY_COMPLETED_MISSION_SENDING_NOT_ALLOWED.getMessage()); + } + + @Test + void 정상적이라면_재촉하기_푸시메세지가_발송되고_히스토리가_저장된다() { + // given + when(fcmService.sendMessageSync(any(), any(), any())).thenReturn(null); + + PushUrgingSendRequest request = new PushUrgingSendRequest(1L); + Member currentMember = + memberRepository.save( + Member.createNormalMember( + Profile.createProfile("testNickname1", "testImageUrl1"))); + Member targetMember = + memberRepository.save( + Member.createNormalMember( + Profile.createProfile("testNickname2", "testImageUrl2"))); + targetMember.updateFcmToken(FcmInfo.createFcmInfo(), "testFcmToken"); + memberRepository.save(targetMember); + + LocalDateTime today = LocalDateTime.now(); + LocalDateTime missionStartedAt = today.minusDays(1); + LocalDateTime missionFinishedAt = today.plusWeeks(2); + missionRepository.save( + Mission.createMission( + "testMissionName", + "testMissionContent", + 1, + MissionCategory.ETC, + MissionVisibility.ALL, + missionStartedAt, + missionFinishedAt, + targetMember)); + + // when + pushService.sendUrgingPush(request); + + // then + Optional optionalNotification = notificationRepository.findById(1L); + assertTrue(optionalNotification.isPresent()); + assertEquals(1, notificationRepository.findAll().size()); + assertEquals( + NotificationType.MISSION_URGING, + optionalNotification.get().getNotificationType()); + } + } +} From 1e380360f969e93dbc5425831e8d50b80c6378cd Mon Sep 17 00:00:00 2001 From: yb__char <68099546+uiurihappy@users.noreply.github.com> Date: Sun, 11 Feb 2024 22:16:41 -0800 Subject: [PATCH 3/6] =?UTF-8?q?fix:=20=EB=AF=B8=EC=85=98=20n=EC=9D=BC?= =?UTF-8?q?=EC=B0=A8=20=ED=91=9C=EA=B8=B0=20=EC=88=98=EC=A0=95=20(#313)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix: 미션 n일차 표기 수정 * fix: spotlessApply * fix: n일차 표기 수정 --- .../missionRecord/dao/MissionRecordRepositoryImpl.java | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) 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 63d5d7338..0fa3c802e 100644 --- a/src/main/java/com/depromeet/domain/missionRecord/dao/MissionRecordRepositoryImpl.java +++ b/src/main/java/com/depromeet/domain/missionRecord/dao/MissionRecordRepositoryImpl.java @@ -67,8 +67,8 @@ public List findFeedAll(List members) { missionRecord.remark, missionRecord.imageUrl, missionRecord.duration, - missionRecord.startedAt, - missionRecord.finishedAt)) + mission.startedAt, + mission.finishedAt)) .from(missionRecord) .leftJoin(missionRecord.mission, mission) .on(mission.id.eq(missionRecord.mission.id)) @@ -79,7 +79,7 @@ public List findFeedAll(List members) { missionRecord.mission.visibility.in( MissionVisibility.FOLLOWER, MissionVisibility.ALL), missionRecord.uploadStatus.eq(ImageUploadStatus.COMPLETE)) - .orderBy(missionRecord.startedAt.desc()) + .orderBy(missionRecord.finishedAt.desc()) .limit(FEED_TAB_LIMIT) .fetch(); } From 0a973c8d43c908a19399b33b671186fd8b61355f Mon Sep 17 00:00:00 2001 From: Jaehyun Ahn <91878695+uwoobeat@users.noreply.github.com> Date: Mon, 12 Feb 2024 19:56:47 +0900 Subject: [PATCH 4/6] =?UTF-8?q?feat:=20=EB=A6=AC=EC=95=A1=EC=85=98=20?= =?UTF-8?q?=EC=9D=B4=EB=AA=A8=EC=A7=80=20Enum=20=EA=B0=92=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80=20=EB=B0=8F=20=EB=B3=80=EA=B2=BD=20(#317)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit feat: 이모지 타입 변경 및 추가 --- .../domain/reaction/domain/EmojiType.java | 9 +++-- .../application/ReactionServiceTest.java | 38 +++++++++---------- 2 files changed, 25 insertions(+), 22 deletions(-) diff --git a/src/main/java/com/depromeet/domain/reaction/domain/EmojiType.java b/src/main/java/com/depromeet/domain/reaction/domain/EmojiType.java index 2b9af2775..5cb321721 100644 --- a/src/main/java/com/depromeet/domain/reaction/domain/EmojiType.java +++ b/src/main/java/com/depromeet/domain/reaction/domain/EmojiType.java @@ -1,12 +1,15 @@ package com.depromeet.domain.reaction.domain; public enum EmojiType { - PURPLE_HEART, + BLUE_HEART, + THUMBS_UP, + FIRE, + PARTY_POPPER, UNICORN, + PARTYING_FACE, + SOMETHING_SPECIAL, GLOWING_STAR, SPARKLING_HEART, EYES, - FIRE, - PARTY_POPPER, ; } diff --git a/src/test/java/com/depromeet/domain/reaction/application/ReactionServiceTest.java b/src/test/java/com/depromeet/domain/reaction/application/ReactionServiceTest.java index 9d2305279..56c4af197 100644 --- a/src/test/java/com/depromeet/domain/reaction/application/ReactionServiceTest.java +++ b/src/test/java/com/depromeet/domain/reaction/application/ReactionServiceTest.java @@ -110,7 +110,7 @@ private void logoutAndReloginAs(Long memberId) { Member member = saveAndRegisterMember(); // 1번 멤버 생성 및 로그인 createMissionAndMissionRecord(member); - switchUserAndAddReaction(1L, EmojiType.PURPLE_HEART); // 2번 멤버 생성 및 로그인 후 리액션 추가 + switchUserAndAddReaction(1L, EmojiType.BLUE_HEART); // 2번 멤버 생성 및 로그인 후 리액션 추가 // when logoutAndReloginAs(1L); // 1번 멤버로 다시 로그인 @@ -127,7 +127,7 @@ private void logoutAndReloginAs(Long memberId) { Member member = saveAndRegisterMember(); // 1번 멤버 생성 및 로그인 createMissionAndMissionRecord(member); - switchUserAndAddReaction(1L, EmojiType.PURPLE_HEART); // 2번 멤버 생성 및 로그인 후 리액션 추가 + switchUserAndAddReaction(1L, EmojiType.BLUE_HEART); // 2번 멤버 생성 및 로그인 후 리액션 추가 saveAndRegisterMember(); // 3번 멤버 생성 및 로그인 @@ -142,8 +142,8 @@ private void logoutAndReloginAs(Long memberId) { createMissionAndMissionRecord(member); // 2번 ~ 7번까지 멤버 생성 및 로그인 후 리액션 추가 - switchUserAndAddReaction(1L, EmojiType.PURPLE_HEART); - switchUserAndAddReaction(1L, EmojiType.PURPLE_HEART); + switchUserAndAddReaction(1L, EmojiType.BLUE_HEART); + switchUserAndAddReaction(1L, EmojiType.BLUE_HEART); switchUserAndAddReaction(1L, EmojiType.FIRE); switchUserAndAddReaction(1L, EmojiType.UNICORN); switchUserAndAddReaction(1L, EmojiType.UNICORN); @@ -164,8 +164,8 @@ private void logoutAndReloginAs(Long memberId) { createMissionAndMissionRecord(member); // 2번 ~ 7번까지 멤버 생성 및 로그인 후 리액션 추가 - switchUserAndAddReaction(1L, EmojiType.PURPLE_HEART); - switchUserAndAddReaction(1L, EmojiType.PURPLE_HEART); + switchUserAndAddReaction(1L, EmojiType.BLUE_HEART); + switchUserAndAddReaction(1L, EmojiType.BLUE_HEART); switchUserAndAddReaction(1L, EmojiType.FIRE); switchUserAndAddReaction(1L, EmojiType.UNICORN); switchUserAndAddReaction(1L, EmojiType.UNICORN); @@ -179,7 +179,7 @@ private void logoutAndReloginAs(Long memberId) { assertEquals( 2, response.stream() - .filter(r -> r.emojiType() == EmojiType.PURPLE_HEART) + .filter(r -> r.emojiType() == EmojiType.BLUE_HEART) .findFirst() .get() .count()); @@ -206,8 +206,8 @@ private void logoutAndReloginAs(Long memberId) { createMissionAndMissionRecord(member); // 2번 ~ 7번까지 멤버 생성 및 로그인 후 리액션 추가 - switchUserAndAddReaction(1L, EmojiType.PURPLE_HEART); - switchUserAndAddReaction(1L, EmojiType.PURPLE_HEART); + switchUserAndAddReaction(1L, EmojiType.BLUE_HEART); + switchUserAndAddReaction(1L, EmojiType.BLUE_HEART); switchUserAndAddReaction(1L, EmojiType.FIRE); switchUserAndAddReaction(1L, EmojiType.UNICORN); switchUserAndAddReaction(1L, EmojiType.UNICORN); @@ -219,7 +219,7 @@ private void logoutAndReloginAs(Long memberId) { // then assertEquals(EmojiType.UNICORN, response.get(0).emojiType()); - assertEquals(EmojiType.PURPLE_HEART, response.get(1).emojiType()); + assertEquals(EmojiType.BLUE_HEART, response.get(1).emojiType()); assertEquals(EmojiType.FIRE, response.get(2).emojiType()); } @@ -231,7 +231,7 @@ private void logoutAndReloginAs(Long memberId) { // 2번 ~ 8번까지 멤버 생성 및 로그인 후 리액션 추가 switchUserAndAddReaction(1L, EmojiType.UNICORN); - switchUserAndAddReaction(1L, EmojiType.PURPLE_HEART); + switchUserAndAddReaction(1L, EmojiType.BLUE_HEART); switchUserAndAddReaction(1L, EmojiType.UNICORN); switchUserAndAddReaction(1L, EmojiType.GLOWING_STAR); switchUserAndAddReaction(1L, EmojiType.UNICORN); @@ -263,7 +263,7 @@ class 리액션_생성시 { Member member = saveAndRegisterMember(); createMissionAndMissionRecord(member); - ReactionCreateRequest request = new ReactionCreateRequest(1L, EmojiType.PURPLE_HEART); + ReactionCreateRequest request = new ReactionCreateRequest(1L, EmojiType.BLUE_HEART); // when & then assertThrows( @@ -283,7 +283,7 @@ class 리액션_생성시 { Member otherMember = saveAndRegisterMember(); // 다른 회원 로그인 createMissionAndMissionRecord(otherMember); - ReactionCreateRequest request = new ReactionCreateRequest(1L, EmojiType.PURPLE_HEART); + ReactionCreateRequest request = new ReactionCreateRequest(1L, EmojiType.BLUE_HEART); // when ReactionCreateResponse response = reactionService.createReaction(request); @@ -303,7 +303,7 @@ class 리액션_생성시 { Member otherMember = saveAndRegisterMember(); // 다른 회원 로그인 createMissionAndMissionRecord(otherMember); - ReactionCreateRequest request = new ReactionCreateRequest(1L, EmojiType.PURPLE_HEART); + ReactionCreateRequest request = new ReactionCreateRequest(1L, EmojiType.BLUE_HEART); reactionService.createReaction(request); // 첫번째 리액션 추가 // when, then @@ -327,7 +327,7 @@ class 리액션_이모지_수정시 { Member otherMember = saveAndRegisterMember(); // 다른 회원 로그인 createMissionAndMissionRecord(otherMember); - ReactionCreateRequest request = new ReactionCreateRequest(1L, EmojiType.PURPLE_HEART); + ReactionCreateRequest request = new ReactionCreateRequest(1L, EmojiType.BLUE_HEART); ReactionCreateResponse response = reactionService.createReaction(request); // when @@ -350,7 +350,7 @@ class 리액션_이모지_수정시 { // 2번 멤버 로그인 및 1번 멤버의 미션기록에 리액션 추가 Member member2 = saveAndRegisterMember(); createMissionAndMissionRecord(member2); - ReactionCreateRequest request2 = new ReactionCreateRequest(1L, EmojiType.PURPLE_HEART); + ReactionCreateRequest request2 = new ReactionCreateRequest(1L, EmojiType.BLUE_HEART); reactionService.createReaction(request2); // 2번 멤버 로그아웃 및 3번 멤버 로그인 @@ -362,7 +362,7 @@ class 리액션_이모지_수정시 { CustomException.class, () -> reactionService.updateReaction( - 1L, new ReactionUpdateRequest(EmojiType.PURPLE_HEART)), + 1L, new ReactionUpdateRequest(EmojiType.BLUE_HEART)), ErrorCode.REACTION_MEMBER_MISMATCH.getMessage()); } } @@ -381,7 +381,7 @@ class 리액션_삭제시 { Member otherMember = saveAndRegisterMember(); // 다른 회원 로그인 createMissionAndMissionRecord(otherMember); - ReactionCreateRequest request = new ReactionCreateRequest(1L, EmojiType.PURPLE_HEART); + ReactionCreateRequest request = new ReactionCreateRequest(1L, EmojiType.BLUE_HEART); ReactionCreateResponse response = reactionService.createReaction(request); // when @@ -402,7 +402,7 @@ class 리액션_삭제시 { // 2번 멤버 로그인 및 1번 멤버의 미션기록에 리액션 추가 Member member2 = saveAndRegisterMember(); createMissionAndMissionRecord(member2); - ReactionCreateRequest request = new ReactionCreateRequest(1L, EmojiType.PURPLE_HEART); + ReactionCreateRequest request = new ReactionCreateRequest(1L, EmojiType.BLUE_HEART); ReactionCreateResponse response = reactionService.createReaction(request); Long reactionId = response.reactionId(); From e8f0a821d34a0e8c2ee0657be93a83384e8b3f7e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EB=8F=84=EB=AA=A8?= Date: Mon, 12 Feb 2024 20:15:14 +0900 Subject: [PATCH 5/6] =?UTF-8?q?fix:=20=EB=B3=B8=EC=9D=B8=EC=9D=B4=20?= =?UTF-8?q?=EB=B3=B8=EC=9D=B8=EC=9D=84=20=ED=8C=94=EB=A1=9C=EC=9A=B0?= =?UTF-8?q?=ED=95=98=EB=8A=94=20=EA=B2=BD=EC=9A=B0=20Validation=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80=20(#319)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix: 본인을 팔로우하는 경우 validation 추가 * 본인 팔로우 validation 테스트 코드 추가 --- .../domain/follow/application/FollowService.java | 7 +++++++ .../depromeet/global/error/exception/ErrorCode.java | 1 + .../follow/application/FollowServiceTest.java | 13 +++++++++++++ 3 files changed, 21 insertions(+) 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 791383809..ecb6eef42 100644 --- a/src/main/java/com/depromeet/domain/follow/application/FollowService.java +++ b/src/main/java/com/depromeet/domain/follow/application/FollowService.java @@ -38,6 +38,7 @@ public class FollowService { public void createFollow(FollowCreateRequest request) { final Member currentMember = memberUtil.getCurrentMember(); + validateSelfFollow(currentMember.getId(), request.targetId()); Member targetMember = getTargetMember(request.targetId()); boolean existMemberRelation = @@ -177,6 +178,12 @@ && isToday(record.getStartedAt())) return result; } + private void validateSelfFollow(Long expectedId, Long actualId) { + if (expectedId.equals(actualId)) { + throw new CustomException(ErrorCode.FOLLOW_SELF_NOT_ALLOWED); + } + } + private boolean isToday(LocalDateTime dateTime) { LocalDateTime today = LocalDateTime.now(); return dateTime.toLocalDate().isEqual(today.toLocalDate()); diff --git a/src/main/java/com/depromeet/global/error/exception/ErrorCode.java b/src/main/java/com/depromeet/global/error/exception/ErrorCode.java index f4a49109f..69d9f7906 100644 --- a/src/main/java/com/depromeet/global/error/exception/ErrorCode.java +++ b/src/main/java/com/depromeet/global/error/exception/ErrorCode.java @@ -49,6 +49,7 @@ public enum ErrorCode { FOLLOW_TARGET_MEMBER_NOT_FOUND(HttpStatus.NOT_FOUND, "타겟 유저을 찾을 수 없습니다."), FOLLOW_ALREADY_EXIST(HttpStatus.BAD_REQUEST, "이미 팔로우 중인 회원입니다."), FOLLOW_NOT_EXIST(HttpStatus.BAD_REQUEST, "팔로우 중인 회원만 팔로우 취소가 가능합니다."), + FOLLOW_SELF_NOT_ALLOWED(HttpStatus.CONFLICT, "본인을 팔로우 할 수 없습니다."), // Image IMAGE_KEY_NOT_FOUND(HttpStatus.NOT_FOUND, "이미지 키를 찾을 수 없습니다."), 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 c64bbb0df..7fb5a3cf1 100644 --- a/src/test/java/com/depromeet/domain/follow/application/FollowServiceTest.java +++ b/src/test/java/com/depromeet/domain/follow/application/FollowServiceTest.java @@ -68,6 +68,19 @@ class 팔로우를_추가할_때 { .hasMessage(ErrorCode.MEMBER_NOT_FOUND.getMessage()); } + @Test + void 본인을_팔로우_할_경우_예외를_발생시킨다() { + FollowCreateRequest request = new FollowCreateRequest(1L); + memberRepository.save( + Member.createNormalMember( + Profile.createProfile("testNickname1", "testImageUrl1"))); + + // when, then + assertThatThrownBy(() -> followService.createFollow(request)) + .isInstanceOf(CustomException.class) + .hasMessage(ErrorCode.FOLLOW_SELF_NOT_ALLOWED.getMessage()); + } + @Test void 타겟회원이_존재하지_않는다면_예외를_발생시킨다() { // given From c4bad254cdc68ca6a3471db96d9a9f7646724fa5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EB=8F=84=EB=AA=A8?= Date: Mon, 12 Feb 2024 20:21:42 +0900 Subject: [PATCH 6/6] =?UTF-8?q?feat:=20=EB=AF=B8=EC=85=98=20=EC=A0=84?= =?UTF-8?q?=EC=B2=B4=20=ED=98=84=ED=99=A9=20-=20=EB=82=A0=EC=A7=9C?= =?UTF-8?q?=EB=A1=9C=20=EB=AF=B8=EC=85=98=20=EB=AA=A9=EB=A1=9D=20=EC=A1=B0?= =?UTF-8?q?=ED=9A=8C=20(#315)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: 미션 전체 현황 - 날짜로 미션 목록 조회 * fix: Transactional readOnly 추가 * fix: isCompletedMissionToday에 COMPLETE 비교 추가 * fix: 로직 stream으로 변경 * fix: findMissionsWithRecordsByDate 날짜 비교 00으로 변경 * style: spotless * fix: sorted stream과 합치고 missionSummaryItem메소드 추출 --- .../domain/mission/api/MissionController.java | 24 ++++----- .../mission/application/MissionService.java | 49 +++++++++++++++++++ .../mission/dao/MissionRepositoryCustom.java | 3 ++ .../mission/dao/MissionRepositoryImpl.java | 17 +++++++ .../domain/mission/domain/Mission.java | 6 ++- .../dto/response/MissionSummaryItem.java | 33 +++++++++++++ .../response/MissionSummaryListResponse.java | 18 +++++++ 7 files changed, 133 insertions(+), 17 deletions(-) create mode 100644 src/main/java/com/depromeet/domain/mission/dto/response/MissionSummaryItem.java create mode 100644 src/main/java/com/depromeet/domain/mission/dto/response/MissionSummaryListResponse.java 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 caa807264..500645aac 100644 --- a/src/main/java/com/depromeet/domain/mission/api/MissionController.java +++ b/src/main/java/com/depromeet/domain/mission/api/MissionController.java @@ -3,29 +3,17 @@ 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; -import com.depromeet.domain.mission.dto.response.MissionFindResponse; -import com.depromeet.domain.mission.dto.response.MissionSymbolStackResponse; -import com.depromeet.domain.mission.dto.response.MissionUpdateResponse; +import com.depromeet.domain.mission.dto.response.*; import com.depromeet.domain.missionRecord.dto.response.MissionRecordSummaryResponse; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.tags.Tag; import jakarta.validation.Valid; +import java.time.LocalDate; import java.util.List; import lombok.RequiredArgsConstructor; import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; -import org.springframework.web.bind.annotation.DeleteMapping; -import org.springframework.web.bind.annotation.GetMapping; -import org.springframework.web.bind.annotation.PathVariable; -import org.springframework.web.bind.annotation.PostMapping; -import org.springframework.web.bind.annotation.PutMapping; -import org.springframework.web.bind.annotation.RequestBody; -import org.springframework.web.bind.annotation.RequestMapping; -import org.springframework.web.bind.annotation.RestController; +import org.springframework.web.bind.annotation.*; @Tag(name = "2. [미션]", description = "미션 관련 API입니다.") @RestController @@ -67,6 +55,12 @@ public MissionRecordSummaryResponse missionRecordFindSummary() { return missionService.findSummaryMissionRecord(); } + @Operation(summary = "미션 전체 현황 - 리스트", description = "년, 월, 일을 입력받아 해당 날짜의 미션 리스트를 조회합니다.") + @GetMapping("/summary-list") + public MissionSummaryListResponse missionSummaryList(@RequestParam LocalDate date) { + return missionService.findSummaryList(date); + } + @Operation(summary = "종료미션 보관함", description = "종료된 미션 리스트를 조회합니다.") @GetMapping("/finished") public List missionFindAllFinished() { 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 fd0ef7f8b..ede4dfca8 100644 --- a/src/main/java/com/depromeet/domain/mission/application/MissionService.java +++ b/src/main/java/com/depromeet/domain/mission/application/MissionService.java @@ -23,6 +23,7 @@ import java.util.Comparator; import java.util.List; import java.util.Optional; +import java.util.stream.Collectors; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @@ -303,4 +304,52 @@ public List findCompletedMissionRecords(List missions) { missionRecord.getUploadStatus() == ImageUploadStatus.COMPLETE) .toList(); } + + @Transactional(readOnly = true) + public MissionSummaryListResponse findSummaryList(LocalDate date) { + final Member currentMember = memberUtil.getCurrentMember(); + List missions = + missionRepository.findMissionsWithRecordsByDate(date, currentMember.getId()); + + List result = + missions.stream() + .map(mission -> getMissionSummaryItem(mission)) + .sorted( + Comparator.comparing(MissionSummaryItem::missionStatus) + .reversed() + .thenComparing( + Comparator.comparing(MissionSummaryItem::finishedAt) + .reversed())) + .collect(Collectors.toList()); + + result.sort( + Comparator.comparing(MissionSummaryItem::missionStatus) + .reversed() + .thenComparing( + Comparator.comparing(MissionSummaryItem::finishedAt).reversed())); + + long missionAllCount = missions.size(); + long missionCompleteCount = + result.stream() + .filter( + missionSummaryItem -> + missionSummaryItem.missionStatus() + == MissionStatus.COMPLETED) + .count(); + long missionNoneCount = missionAllCount - missionCompleteCount; + return MissionSummaryListResponse.of( + missionAllCount, missionCompleteCount, missionNoneCount, result); + } + + private static MissionSummaryItem getMissionSummaryItem(Mission mission) { + boolean isCompleted = + mission.getMissionRecords().stream() + .anyMatch( + missionRecord -> + missionRecord.getUploadStatus() + == ImageUploadStatus.COMPLETE); + return isCompleted + ? MissionSummaryItem.of(mission, MissionStatus.COMPLETED) + : MissionSummaryItem.of(mission, MissionStatus.NONE); + } } 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 085b4e967..81ee397d2 100644 --- a/src/main/java/com/depromeet/domain/mission/dao/MissionRepositoryCustom.java +++ b/src/main/java/com/depromeet/domain/mission/dao/MissionRepositoryCustom.java @@ -1,6 +1,7 @@ package com.depromeet.domain.mission.dao; import com.depromeet.domain.mission.domain.Mission; +import java.time.LocalDate; import java.time.LocalDateTime; import java.util.List; @@ -15,4 +16,6 @@ public interface MissionRepositoryCustom { void updateFinishedDurationStatus(LocalDateTime today); List findAllFinishedMission(Long memberId); + + List findMissionsWithRecordsByDate(LocalDate date, 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 6ce7a6f29..ab7726c72 100644 --- a/src/main/java/com/depromeet/domain/mission/dao/MissionRepositoryImpl.java +++ b/src/main/java/com/depromeet/domain/mission/dao/MissionRepositoryImpl.java @@ -10,6 +10,7 @@ import com.querydsl.core.types.dsl.BooleanExpression; import com.querydsl.jpa.impl.JPAQuery; import com.querydsl.jpa.impl.JPAQueryFactory; +import java.time.LocalDate; import java.time.LocalDateTime; import java.util.List; import lombok.RequiredArgsConstructor; @@ -84,6 +85,22 @@ public List findAllFinishedMission(Long memberId) { .fetch(); } + @Override + public List findMissionsWithRecordsByDate(LocalDate date, Long memberId) { + LocalDateTime startedAt = date.atTime(0, 0, 0); + LocalDateTime finishedAt = startedAt.plusDays(1); + JPAQuery query = + jpaQueryFactory + .selectFrom(mission) + .leftJoin(mission.missionRecords, missionRecord) + .where( + memberIdEq(memberId), + mission.startedAt.loe(startedAt), + mission.finishedAt.goe(finishedAt)) + .fetchJoin(); + return query.fetch(); + } + // 미션의 사용자 id 조건 검증 메서드 private BooleanExpression memberIdEq(Long memberId) { return memberId == null ? null : mission.member.id.eq(memberId); diff --git a/src/main/java/com/depromeet/domain/mission/domain/Mission.java b/src/main/java/com/depromeet/domain/mission/domain/Mission.java index 157384410..78be2614b 100644 --- a/src/main/java/com/depromeet/domain/mission/domain/Mission.java +++ b/src/main/java/com/depromeet/domain/mission/domain/Mission.java @@ -2,6 +2,7 @@ import com.depromeet.domain.common.model.BaseTimeEntity; import com.depromeet.domain.member.domain.Member; +import com.depromeet.domain.missionRecord.domain.ImageUploadStatus; import com.depromeet.domain.missionRecord.domain.MissionRecord; import jakarta.persistence.CascadeType; import jakarta.persistence.Column; @@ -127,8 +128,9 @@ public boolean isCompletedMissionToday() { .filter( record -> record.getStartedAt() - .toLocalDate() - .equals(LocalDateTime.now().toLocalDate())) + .toLocalDate() + .equals(LocalDateTime.now().toLocalDate()) + && record.getUploadStatus() == ImageUploadStatus.COMPLETE) .findFirst() .isPresent(); } diff --git a/src/main/java/com/depromeet/domain/mission/dto/response/MissionSummaryItem.java b/src/main/java/com/depromeet/domain/mission/dto/response/MissionSummaryItem.java new file mode 100644 index 000000000..18f3c8695 --- /dev/null +++ b/src/main/java/com/depromeet/domain/mission/dto/response/MissionSummaryItem.java @@ -0,0 +1,33 @@ +package com.depromeet.domain.mission.dto.response; + +import com.depromeet.domain.mission.domain.*; +import com.fasterxml.jackson.annotation.JsonFormat; +import io.swagger.v3.oas.annotations.media.Schema; +import java.time.LocalDateTime; + +public record MissionSummaryItem( + @Schema(description = "미션 ID", defaultValue = "1") Long missionId, + @Schema(description = "미션 이름", defaultValue = "default name") String name, + @Schema(description = "미션 카테고리", defaultValue = "STUDY") MissionCategory category, + @Schema(description = "미션 공개여부", defaultValue = "ALL") MissionVisibility visibility, + @Schema(description = "미션 상태", defaultValue = "1") MissionStatus missionStatus, + @JsonFormat( + shape = JsonFormat.Shape.STRING, + pattern = "yyyy-MM-dd HH:mm:ss", + timezone = "Asia/Seoul") + @Schema( + description = "미션 종료 시간", + defaultValue = "2024-01-03 00:34:00", + type = "string") + LocalDateTime finishedAt) { + + public static MissionSummaryItem of(Mission mission, MissionStatus missionStatus) { + return new MissionSummaryItem( + mission.getId(), + mission.getName(), + mission.getCategory(), + mission.getVisibility(), + missionStatus, + mission.getFinishedAt()); + } +} diff --git a/src/main/java/com/depromeet/domain/mission/dto/response/MissionSummaryListResponse.java b/src/main/java/com/depromeet/domain/mission/dto/response/MissionSummaryListResponse.java new file mode 100644 index 000000000..a79adb85e --- /dev/null +++ b/src/main/java/com/depromeet/domain/mission/dto/response/MissionSummaryListResponse.java @@ -0,0 +1,18 @@ +package com.depromeet.domain.mission.dto.response; + +import java.util.List; + +public record MissionSummaryListResponse( + long missionAllCount, + long missionCompleteCount, + long missionNoneCount, + List missionSummaryItems) { + public static MissionSummaryListResponse of( + long missionAllCount, + long missionCompleteCount, + long missionNoneCount, + List missionSummaryItems) { + return new MissionSummaryListResponse( + missionAllCount, missionCompleteCount, missionNoneCount, missionSummaryItems); + } +}