diff --git a/src/docs/asciidoc/notifications.adoc b/src/docs/asciidoc/notifications.adoc index a9fedb0c..29ab52dd 100644 --- a/src/docs/asciidoc/notifications.adoc +++ b/src/docs/asciidoc/notifications.adoc @@ -4,4 +4,14 @@ [[알림-조회]] === `GET` 알림 조회 -operation::notification-controller-test/find-notifications[snippets='http-request,curl-request,path-parameters,request-headers,query-parameters,http-response,response-fields'] \ No newline at end of file +operation::notification-controller-test/find-notifications[snippets='http-request,curl-request,path-parameters,request-headers,query-parameters,http-response,response-fields'] + +[[알림-읽기]] +=== `PATCH` 알림 읽기 + +operation::notification-controller-test/mark-read[snippets='http-request,curl-request,path-parameters,request-headers'] + +[[알림-상태-확인]] +=== `GET` 알림 상태 확인 + +operation::notification-controller-test/find-notifications[snippets='http-request,curl-request,path-parameters,request-headers,http-response,response-fields'] diff --git a/src/main/java/com/chooz/commentLike/application/CommentLikeCommandService.java b/src/main/java/com/chooz/commentLike/application/CommentLikeCommandService.java index 0c885221..b65c4295 100644 --- a/src/main/java/com/chooz/commentLike/application/CommentLikeCommandService.java +++ b/src/main/java/com/chooz/commentLike/application/CommentLikeCommandService.java @@ -6,7 +6,7 @@ import com.chooz.common.event.EventPublisher; import com.chooz.common.exception.BadRequestException; import com.chooz.common.exception.ErrorCode; -import com.chooz.notification.domain.event.CommentLikedEvent; +import com.chooz.commentLike.domain.event.CommentLikedNotificationEvent; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.stereotype.Service; @@ -29,7 +29,7 @@ public CommentLikeIdResponse createCommentLike(Long commentId, Long userId) { } CommentLike commentLike = commentLikeRepository.save(CommentLike.create(commentId, userId)); - eventPublisher.publish(new CommentLikedEvent( + eventPublisher.publish(new CommentLikedNotificationEvent( commentId, commentLike.getId(), userId, diff --git a/src/main/java/com/chooz/notification/domain/event/CommentLikedEvent.java b/src/main/java/com/chooz/commentLike/domain/event/CommentLikedNotificationEvent.java similarity index 61% rename from src/main/java/com/chooz/notification/domain/event/CommentLikedEvent.java rename to src/main/java/com/chooz/commentLike/domain/event/CommentLikedNotificationEvent.java index 857de502..e6a4d7e3 100644 --- a/src/main/java/com/chooz/notification/domain/event/CommentLikedEvent.java +++ b/src/main/java/com/chooz/commentLike/domain/event/CommentLikedNotificationEvent.java @@ -1,8 +1,8 @@ -package com.chooz.notification.domain.event; +package com.chooz.commentLike.domain.event; import java.time.LocalDateTime; -public record CommentLikedEvent( +public record CommentLikedNotificationEvent( Long commentId, Long commentLikeId, Long likerId, diff --git a/src/main/java/com/chooz/common/exception/ErrorCode.java b/src/main/java/com/chooz/common/exception/ErrorCode.java index 868c7e72..72dad149 100644 --- a/src/main/java/com/chooz/common/exception/ErrorCode.java +++ b/src/main/java/com/chooz/common/exception/ErrorCode.java @@ -48,6 +48,7 @@ public enum ErrorCode { ONLY_SELF_CAN_CLOSE("작성자 마감의 경우, SELF 마감 방식만이 마감 가능합니다."), INVALID_ONBOARDING_STEP("유효하지 않은 온보딩 단계."), NICKNAME_LENGTH_EXCEEDED("닉네임 길이 초과"), + NOTIFICATION_NOT_FOUND("존재하지 않는 알림 입니다."), //401 EXPIRED_TOKEN("토큰이 만료됐습니다."), diff --git a/src/main/java/com/chooz/notification/application/CommentLikeNotificationListener.java b/src/main/java/com/chooz/notification/application/CommentLikeNotificationListener.java deleted file mode 100644 index a940cbf3..00000000 --- a/src/main/java/com/chooz/notification/application/CommentLikeNotificationListener.java +++ /dev/null @@ -1,34 +0,0 @@ -package com.chooz.notification.application; - -import com.chooz.notification.application.dto.CommentLikedContent; -import com.chooz.notification.domain.Notification; -import com.chooz.notification.domain.TargetType; -import com.chooz.notification.domain.event.CommentLikedEvent; -import lombok.RequiredArgsConstructor; -import org.springframework.stereotype.Component; -import org.springframework.transaction.event.TransactionPhase; -import org.springframework.transaction.event.TransactionalEventListener; - -@Component -@RequiredArgsConstructor -public class CommentLikeNotificationListener { - - private final NotificationCommandService notificationCommandService; - private final NotificationContentAssembler notificationContentAssembler; - - @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT) - public void onCommentLiked(CommentLikedEvent e) { - CommentLikedContent commentLikedContent = notificationContentAssembler.forCommentLiked(e.commentId(), e.likerId()); - Notification.create( - commentLikedContent.getCommentAuthorId(), - commentLikedContent.getCommentAuthorName(), - e.likerId(), - commentLikedContent.getActorName(), - commentLikedContent.getActorProfileImageUrl(), - e.commentId(), - TargetType.COMMENT, - commentLikedContent.getTargetThumbnailUrl(), - e.eventAt() - ).ifPresent(notificationCommandService::create); - } -} diff --git a/src/main/java/com/chooz/notification/application/NotificationCommandService.java b/src/main/java/com/chooz/notification/application/NotificationCommandService.java deleted file mode 100644 index 6818adec..00000000 --- a/src/main/java/com/chooz/notification/application/NotificationCommandService.java +++ /dev/null @@ -1,20 +0,0 @@ -package com.chooz.notification.application; - -import com.chooz.notification.domain.Notification; -import com.chooz.notification.domain.NotificationRepository; -import lombok.RequiredArgsConstructor; -import org.springframework.stereotype.Service; -import org.springframework.transaction.annotation.Propagation; -import org.springframework.transaction.annotation.Transactional; - -@Service -@RequiredArgsConstructor -public class NotificationCommandService { - - private final NotificationRepository notificationRepository; - - @Transactional(propagation = Propagation.REQUIRES_NEW) - public Notification create(Notification notification) { - return notificationRepository.save(notification); - } -} diff --git a/src/main/java/com/chooz/notification/application/NotificationContentAssembler.java b/src/main/java/com/chooz/notification/application/NotificationContentAssembler.java index ab49dff9..9069fe8a 100644 --- a/src/main/java/com/chooz/notification/application/NotificationContentAssembler.java +++ b/src/main/java/com/chooz/notification/application/NotificationContentAssembler.java @@ -1,49 +1,96 @@ package com.chooz.notification.application; -import com.chooz.common.exception.BadRequestException; -import com.chooz.common.exception.ErrorCode; -import com.chooz.notification.application.dto.CommentLikedContent; +import com.chooz.notification.application.dto.NotificationContent; import com.chooz.notification.application.dto.TargetPostDto; import com.chooz.notification.application.dto.TargetUserDto; -import com.chooz.notification.domain.NotificationQueryRepository; +import com.chooz.notification.domain.NotificationType; +import com.chooz.notification.domain.Target; +import com.chooz.notification.domain.TargetType; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Service; +import java.util.List; +import java.util.Map; +import java.util.stream.Collectors; + @Service @RequiredArgsConstructor public class NotificationContentAssembler { - private final NotificationQueryRepository notificationQueryDslRepository; - - public CommentLikedContent forCommentLiked(Long commentId, Long likerId) { - TargetUserDto targetUserDto = notificationQueryDslRepository.getUser(likerId) - .orElseThrow(() -> new BadRequestException(ErrorCode.USER_NOT_FOUND)); - TargetUserDto commentAuthorDto = notificationQueryDslRepository.getUserByCommentId(commentId) - .orElseThrow(() -> new BadRequestException(ErrorCode.USER_NOT_FOUND)); - TargetPostDto targetPostDto = notificationQueryDslRepository.getPost(commentId) - .orElseThrow(() -> new BadRequestException(ErrorCode.POST_NOT_FOUND)); + private final NotificationService notificationService; + private final NotificationMessageRenderer renderer; - return new CommentLikedContent( - targetUserDto.nickname(), + public NotificationContent forCommentLiked(Long commentId, Long likerId) { + TargetUserDto commentAuthorDto = notificationService.findUserByCommentId(commentId); + TargetUserDto targetUserDto = notificationService.findUserById(likerId); + TargetPostDto targetPostDto = notificationService.findPostByCommentId(commentId); + var vars = Map.of("actorName", targetUserDto.nickname()); + var renderedMessage = renderer.render(NotificationType.COMMENT_LIKED.code(), vars); + return new NotificationContent( + commentAuthorDto.id(), + targetUserDto.id(), + renderedMessage.title(), + renderedMessage.content(), targetUserDto.profileUrl(), targetPostDto.imageUrl(), - commentAuthorDto.id(), - commentAuthorDto.nickname() + List.of(Target.of(targetPostDto.id(), TargetType.POST), + Target.of(commentId, TargetType.COMMENT) + ) ); } - -// public NotificationContent forVoteClosed(Long postId) { -// String title = postPort.getPostTitle(postId).orElse("투표 마감"); -// String body = "참여한 투표가 마감되었어요."; -// String thumbnail = postPort.getPostThumbnailUrl(postId).orElse(null); -// return new NotificationContent(title, body, thumbnail); -// } -// -// public NotificationContent forPostParticipated(Long postId, Long voterId) { -// String title = postPort.getPostTitle(postId).orElse("새로운 참여"); -// String voter = userPort.getDisplayName(voterId).orElse("누군가"); -// String body = voter + "님이 내 투표에 참여했어요."; -// String thumbnail = userPort.getAvatarUrl(voterId).orElse(null); -// return new NotificationContent(title, body, thumbnail); -// } + public NotificationContent forVoted(Long postId, Long voterId) { + TargetUserDto postAuthorDto = notificationService.findUserByPostId(postId); + TargetUserDto targetUserDto = notificationService.findUserById(voterId); + TargetPostDto targetPostDto = notificationService.findPostById(postId); + var vars = Map.of( + "actorName", targetUserDto.nickname(), + "postTitle", targetPostDto.title() + ); + var renderedMessage = renderer.render(NotificationType.POST_VOTED.code(), vars); + return new NotificationContent( + postAuthorDto.id(), + targetUserDto.id(), + renderedMessage.title(), + renderedMessage.content(), + targetUserDto.profileUrl(), + targetPostDto.imageUrl(), + List.of(Target.of(targetPostDto.id(), TargetType.POST)) + ); + } + public NotificationContent forMyPostClosed(Long postId, Long userId) { + TargetUserDto postAuthorDto = notificationService.findUserById(userId); + TargetPostDto targetPostDto = notificationService.findPostById(postId); + var vars = Map.of( + "postTitle", targetPostDto.title() + ); + var renderedMessage = renderer.render(NotificationType.MY_POST_CLOSED.code(), vars); + return new NotificationContent( + postAuthorDto.id(), + postAuthorDto.id(), + renderedMessage.title(), + renderedMessage.content(), + postAuthorDto.profileUrl(), + targetPostDto.imageUrl(), + List.of(Target.of(targetPostDto.id(), TargetType.POST)) + ); + } + public List forPostClosed(Long postId, Long userId) { + TargetUserDto postAuthorDto = notificationService.findUserById(userId); + List receiverUserDtos = notificationService.findVoteUsersByPostId(postId); + TargetPostDto targetPostDto = notificationService.findPostById(postId); + var vars = Map.of( + "postTitle", targetPostDto.title() + ); + var renderedMessage = renderer.render(NotificationType.POST_CLOSED.code(), vars); + return receiverUserDtos.stream().map(receiver -> + new NotificationContent( + receiver.id(), + postAuthorDto.id(), + renderedMessage.title(), + renderedMessage.content(), + postAuthorDto.profileUrl(), + targetPostDto.imageUrl(), + List.of(Target.of(targetPostDto.id(), TargetType.POST)) + )).collect(Collectors.toList()); + } } diff --git a/src/main/java/com/chooz/notification/application/NotificationMessageRenderer.java b/src/main/java/com/chooz/notification/application/NotificationMessageRenderer.java new file mode 100644 index 00000000..18378f13 --- /dev/null +++ b/src/main/java/com/chooz/notification/application/NotificationMessageRenderer.java @@ -0,0 +1,9 @@ +package com.chooz.notification.application; + +import com.chooz.notification.application.dto.RenderedMessage; + +import java.util.Map; + +public interface NotificationMessageRenderer { + RenderedMessage render(String type, Map vars); +} diff --git a/src/main/java/com/chooz/notification/application/NotificationQueryService.java b/src/main/java/com/chooz/notification/application/NotificationQueryService.java deleted file mode 100644 index e4c0b70c..00000000 --- a/src/main/java/com/chooz/notification/application/NotificationQueryService.java +++ /dev/null @@ -1,29 +0,0 @@ -package com.chooz.notification.application; - -import com.chooz.common.dto.CursorBasePaginatedResponse; -import com.chooz.notification.application.dto.NotificationDto; -import com.chooz.notification.domain.Notification; -import com.chooz.notification.domain.NotificationQueryRepository; -import com.chooz.notification.presentation.dto.NotificationResponse; -import lombok.RequiredArgsConstructor; -import org.springframework.data.domain.PageRequest; -import org.springframework.data.domain.Slice; -import org.springframework.stereotype.Service; -import org.springframework.transaction.annotation.Transactional; - -@Service -@Transactional(readOnly = true) -@RequiredArgsConstructor -public class NotificationQueryService { - - private final NotificationQueryRepository notificationQueryRepository; - -// public CursorBasePaginatedResponse findNotifications(Long userId, Long cursor, int size) { -// Slice notificationSlice = notificationQueryRepository.findNotifications(userId, cursor, PageRequest.ofSize(size)); -// return CursorBasePaginatedResponse.of(notificationSlice.map(NotificationResponse::of)); -// } - public CursorBasePaginatedResponse findNotifications(Long userId, Long cursor, int size) { - Slice notificationSlice = notificationQueryRepository.findNotifications(userId, cursor, PageRequest.ofSize(size)); - return CursorBasePaginatedResponse.of(notificationSlice.map(NotificationResponse::of)); - } -} diff --git a/src/main/java/com/chooz/notification/application/NotificationService.java b/src/main/java/com/chooz/notification/application/NotificationService.java new file mode 100644 index 00000000..bd8d2c12 --- /dev/null +++ b/src/main/java/com/chooz/notification/application/NotificationService.java @@ -0,0 +1,56 @@ +package com.chooz.notification.application; + +import com.chooz.common.dto.CursorBasePaginatedResponse; +import com.chooz.notification.application.dto.TargetPostDto; +import com.chooz.notification.application.dto.TargetUserDto; +import com.chooz.notification.application.service.NotificationCommandService; +import com.chooz.notification.application.service.NotificationQueryService; +import com.chooz.notification.domain.Notification; +import com.chooz.notification.presentation.dto.NotificationPresentResponse; +import com.chooz.notification.presentation.dto.NotificationResponse; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; + +import java.util.List; + +@Service +@RequiredArgsConstructor +public class NotificationService { + + private final NotificationQueryService notificationQueryService; + private final NotificationCommandService notificationCommandService; + + public CursorBasePaginatedResponse findNotifications(Long userId, Long cursor, int size) { + return notificationQueryService.findNotifications(userId, cursor, size); + } + public TargetUserDto findUserByCommentId(Long commentId) { + return notificationQueryService.findUserByCommentId(commentId); + } + public TargetUserDto findUserById(Long userId) { + return notificationQueryService.findUserById(userId); + } + public TargetPostDto findPostByCommentId(Long commentId) { + return notificationQueryService.findPostByCommentId(commentId); + } + public TargetUserDto findUserByPostId(Long postId) { + return notificationQueryService.findUserByPostId(postId); + } + public TargetPostDto findPostById(Long postId) { + return notificationQueryService.findPostById(postId); + } + public Notification create(Notification notification) { + return notificationCommandService.create(notification); + } + public void createAll(List notifications) { + notificationCommandService.createAll(notifications); + } + public List findVoteUsersByPostId(Long postId) { + return notificationQueryService.findVoteUsersByPostId(postId); + } + public void markRead(Long notificationId) { + notificationCommandService.markRead(notificationId); + } + public NotificationPresentResponse present(Long userId) { + return notificationQueryService.present(userId); + } +} diff --git a/src/main/java/com/chooz/notification/application/dto/CommentLikedContent.java b/src/main/java/com/chooz/notification/application/dto/CommentLikedContent.java deleted file mode 100644 index 082009ec..00000000 --- a/src/main/java/com/chooz/notification/application/dto/CommentLikedContent.java +++ /dev/null @@ -1,22 +0,0 @@ -package com.chooz.notification.application.dto; - -import lombok.Getter; - -@Getter -public class CommentLikedContent extends NotificationContent { - - private final Long commentAuthorId; - private final String commentAuthorName; - - public CommentLikedContent( - String actorName, - String actorProfileImageUrl, - String targetThumbnailUrl, - Long commentAuthorId, - String commentAuthorName - ) { - super(actorName, targetThumbnailUrl, actorProfileImageUrl); - this.commentAuthorId = commentAuthorId; - this.commentAuthorName = commentAuthorName; - } -} diff --git a/src/main/java/com/chooz/notification/application/dto/NotificationContent.java b/src/main/java/com/chooz/notification/application/dto/NotificationContent.java index f4015206..bf494560 100644 --- a/src/main/java/com/chooz/notification/application/dto/NotificationContent.java +++ b/src/main/java/com/chooz/notification/application/dto/NotificationContent.java @@ -1,13 +1,35 @@ package com.chooz.notification.application.dto; -import lombok.Getter; -import lombok.RequiredArgsConstructor; +import com.chooz.notification.domain.Target; -@Getter -@RequiredArgsConstructor -public abstract class NotificationContent { - private final String actorName; - private final String actorProfileImageUrl; - private final String targetThumbnailUrl; +import java.util.List; +public record NotificationContent ( + Long receiverId, + Long actorId, + String title, + String content, + String profileUrl, + String imageUrl, + List targets +){ + public static NotificationContent of( + Long receiverId, + Long actorId, + String title, + String content, + String profileUrl, + String imageUrl, + List targets + ) { + return new NotificationContent( + receiverId, + actorId, + title, + content, + profileUrl, + imageUrl, + targets + ); + } } diff --git a/src/main/java/com/chooz/notification/application/dto/NotificationDto.java b/src/main/java/com/chooz/notification/application/dto/NotificationDto.java deleted file mode 100644 index b0ab1628..00000000 --- a/src/main/java/com/chooz/notification/application/dto/NotificationDto.java +++ /dev/null @@ -1,23 +0,0 @@ -package com.chooz.notification.application.dto; - - -import com.chooz.notification.domain.TargetType; -import com.querydsl.core.annotations.QueryProjection; - -import java.time.LocalDateTime; - -@QueryProjection -public record NotificationDto( - Long id, - Long postId, - Long receiverId, - String receiverNickname, - Long actorId, - String actorNickname, - String actorProfileUrl, - Long targetId, - TargetType targetType, - String targetImageUrl, - boolean isRead, - LocalDateTime eventAt -) {} diff --git a/src/main/java/com/chooz/notification/application/dto/RenderedMessage.java b/src/main/java/com/chooz/notification/application/dto/RenderedMessage.java new file mode 100644 index 00000000..91019993 --- /dev/null +++ b/src/main/java/com/chooz/notification/application/dto/RenderedMessage.java @@ -0,0 +1,4 @@ +package com.chooz.notification.application.dto; + +public record RenderedMessage(String title, String content) { +} diff --git a/src/main/java/com/chooz/notification/application/dto/TargetPostDto.java b/src/main/java/com/chooz/notification/application/dto/TargetPostDto.java index 17283472..fb4b256b 100644 --- a/src/main/java/com/chooz/notification/application/dto/TargetPostDto.java +++ b/src/main/java/com/chooz/notification/application/dto/TargetPostDto.java @@ -6,5 +6,6 @@ @QueryProjection public record TargetPostDto( Long id, + String title, String imageUrl ) {} diff --git a/src/main/java/com/chooz/notification/application/listener/CommentLikeNotificationListener.java b/src/main/java/com/chooz/notification/application/listener/CommentLikeNotificationListener.java new file mode 100644 index 00000000..f3ab5ecf --- /dev/null +++ b/src/main/java/com/chooz/notification/application/listener/CommentLikeNotificationListener.java @@ -0,0 +1,33 @@ +package com.chooz.notification.application.listener; + +import com.chooz.commentLike.domain.event.CommentLikedNotificationEvent; +import com.chooz.notification.application.NotificationContentAssembler; +import com.chooz.notification.application.NotificationService; +import com.chooz.notification.application.dto.NotificationContent; +import com.chooz.notification.domain.Notification; +import com.chooz.notification.domain.NotificationType; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; +import org.springframework.transaction.event.TransactionPhase; +import org.springframework.transaction.event.TransactionalEventListener; + +@Component +@RequiredArgsConstructor +public class CommentLikeNotificationListener { + + private final NotificationService notificationService; + private final NotificationContentAssembler notificationContentAssembler; + + @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT) + public void onCommentLiked(CommentLikedNotificationEvent commentLikedNotificationEvent) { + NotificationContent notificationContent = notificationContentAssembler.forCommentLiked( + commentLikedNotificationEvent.commentId(), + commentLikedNotificationEvent.likerId() + ); + Notification.create( + NotificationType.COMMENT_LIKED, + commentLikedNotificationEvent.eventAt(), + notificationContent + ).ifPresent(notificationService::create); + } +} diff --git a/src/main/java/com/chooz/notification/application/listener/MyPostClosedNotificationListener.java b/src/main/java/com/chooz/notification/application/listener/MyPostClosedNotificationListener.java new file mode 100644 index 00000000..cd2068f9 --- /dev/null +++ b/src/main/java/com/chooz/notification/application/listener/MyPostClosedNotificationListener.java @@ -0,0 +1,36 @@ +package com.chooz.notification.application.listener; + +import com.chooz.notification.application.NotificationContentAssembler; +import com.chooz.notification.application.NotificationService; +import com.chooz.notification.application.dto.NotificationContent; +import com.chooz.notification.domain.Notification; +import com.chooz.notification.domain.NotificationType; +import com.chooz.post.application.dto.PostClosedNotificationEvent; +import com.chooz.post.domain.CloseType; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; +import org.springframework.transaction.event.TransactionPhase; +import org.springframework.transaction.event.TransactionalEventListener; + +@Component +@RequiredArgsConstructor +public class MyPostClosedNotificationListener { + + private final NotificationService notificationService; + private final NotificationContentAssembler notificationContentAssembler; + + @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT) + public void onMyPostClosed(PostClosedNotificationEvent postClosedNotificationEvent) { + NotificationContent notificationContent = notificationContentAssembler.forMyPostClosed( + postClosedNotificationEvent.postId(), + postClosedNotificationEvent.userId() + ); + if(!postClosedNotificationEvent.closeType().equals(CloseType.SELF)){ + Notification.create( + NotificationType.MY_POST_CLOSED, + postClosedNotificationEvent.eventAt(), + notificationContent + ).ifPresent(notificationService::create); + } + } +} diff --git a/src/main/java/com/chooz/notification/application/listener/PostClosedNotificationListener.java b/src/main/java/com/chooz/notification/application/listener/PostClosedNotificationListener.java new file mode 100644 index 00000000..bff57ea8 --- /dev/null +++ b/src/main/java/com/chooz/notification/application/listener/PostClosedNotificationListener.java @@ -0,0 +1,39 @@ +package com.chooz.notification.application.listener; + +import com.chooz.notification.application.NotificationContentAssembler; +import com.chooz.notification.application.NotificationService; +import com.chooz.notification.application.dto.NotificationContent; +import com.chooz.notification.domain.Notification; +import com.chooz.notification.domain.NotificationType; +import com.chooz.post.application.dto.PostClosedNotificationEvent; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; +import org.springframework.transaction.event.TransactionPhase; +import org.springframework.transaction.event.TransactionalEventListener; + +import java.util.ArrayList; +import java.util.List; + +@Component +@RequiredArgsConstructor +public class PostClosedNotificationListener { + + private final NotificationService notificationService; + private final NotificationContentAssembler notificationContentAssembler; + + @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT) + public void onPostClosed(PostClosedNotificationEvent postClosedNotificationEvent) { + List notificationContents = notificationContentAssembler.forPostClosed( + postClosedNotificationEvent.postId(), + postClosedNotificationEvent.userId() + ); + List notifications = new ArrayList<>(); + notificationContents.forEach(notificationContent -> + Notification.create( + NotificationType.POST_CLOSED, + postClosedNotificationEvent.eventAt(), + notificationContent + ).ifPresent(notifications::add)); + notificationService.createAll(notifications); + } +} diff --git a/src/main/java/com/chooz/notification/application/listener/VotedNotificationListener.java b/src/main/java/com/chooz/notification/application/listener/VotedNotificationListener.java new file mode 100644 index 00000000..8e9757f8 --- /dev/null +++ b/src/main/java/com/chooz/notification/application/listener/VotedNotificationListener.java @@ -0,0 +1,33 @@ +package com.chooz.notification.application.listener; + +import com.chooz.notification.application.NotificationContentAssembler; +import com.chooz.notification.application.NotificationService; +import com.chooz.notification.application.dto.NotificationContent; +import com.chooz.notification.domain.Notification; +import com.chooz.notification.domain.NotificationType; +import com.chooz.vote.application.VotedNotificationEvent; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; +import org.springframework.transaction.event.TransactionPhase; +import org.springframework.transaction.event.TransactionalEventListener; + +@Component +@RequiredArgsConstructor +public class VotedNotificationListener { + + private final NotificationService notificationService; + private final NotificationContentAssembler notificationContentAssembler; + + @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT) + public void onVoted(VotedNotificationEvent votedNotificationEvent) { + NotificationContent notificationContent = notificationContentAssembler.forVoted( + votedNotificationEvent.postId(), + votedNotificationEvent.voterId() + ); + Notification.create( + NotificationType.POST_VOTED, + votedNotificationEvent.eventAt(), + notificationContent + ).ifPresent(notificationService::create); + } +} diff --git a/src/main/java/com/chooz/notification/application/service/NotificationCommandService.java b/src/main/java/com/chooz/notification/application/service/NotificationCommandService.java new file mode 100644 index 00000000..9edf5ad0 --- /dev/null +++ b/src/main/java/com/chooz/notification/application/service/NotificationCommandService.java @@ -0,0 +1,55 @@ +package com.chooz.notification.application.service; + +import com.chooz.common.exception.BadRequestException; +import com.chooz.common.exception.ErrorCode; +import com.chooz.notification.domain.Notification; +import com.chooz.notification.domain.NotificationQueryRepository; +import com.chooz.notification.domain.NotificationRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Propagation; +import org.springframework.transaction.annotation.Transactional; + +import java.util.List; +import java.util.Set; +import java.util.stream.Collectors; + +@Service +@RequiredArgsConstructor +public class NotificationCommandService { + + private final NotificationRepository notificationRepository; + private final NotificationQueryRepository notificationQueryRepository; + + @Transactional(propagation = Propagation.REQUIRES_NEW) + public Notification create(Notification notification) { + return notificationQueryRepository.existsByDedupKey(notification.getReceiverId(), notification.getDedupKey()) + ? null + : notificationRepository.save(notification); + } + @Transactional(propagation = Propagation.REQUIRES_NEW) + public void createAll(List notifications) { + List existsNotifications = notificationQueryRepository.existsDedupKeyByNotifications(notifications); + Set existingPairs = getExistingPairs(existsNotifications); + List toSave = getNotificationsNotDuplicated(notifications, existingPairs); + if (!toSave.isEmpty()) { + notificationRepository.saveAll(toSave); + } + } + private Set getExistingPairs(List existsNotifications) { + return existsNotifications.stream() + .map(n -> n.getReceiverId() + "|" + n.getDedupKey()) + .collect(Collectors.toSet()); + } + private List getNotificationsNotDuplicated(List notifications, Set existingPairs) { + return notifications.stream() + .filter(n -> !existingPairs.contains(n.getReceiverId() + "|" + n.getDedupKey())) + .toList(); + } + @Transactional + public void markRead(Long notificationId){ + Notification notification = notificationRepository.findNotificationById(notificationId) + .orElseThrow(() -> new BadRequestException(ErrorCode.NOTIFICATION_NOT_FOUND)); + notification.markRead(); + } +} diff --git a/src/main/java/com/chooz/notification/application/service/NotificationQueryService.java b/src/main/java/com/chooz/notification/application/service/NotificationQueryService.java new file mode 100644 index 00000000..ad54d88e --- /dev/null +++ b/src/main/java/com/chooz/notification/application/service/NotificationQueryService.java @@ -0,0 +1,60 @@ +package com.chooz.notification.application.service; + +import com.chooz.common.dto.CursorBasePaginatedResponse; +import com.chooz.common.exception.BadRequestException; +import com.chooz.common.exception.ErrorCode; +import com.chooz.notification.application.dto.TargetPostDto; +import com.chooz.notification.application.dto.TargetUserDto; +import com.chooz.notification.application.web.dto.NotificationDto; +import com.chooz.notification.domain.NotificationQueryRepository; +import com.chooz.notification.domain.NotificationRepository; +import com.chooz.notification.presentation.dto.NotificationPresentResponse; +import com.chooz.notification.presentation.dto.NotificationResponse; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Slice; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.List; + +@Service +@Transactional(readOnly = true) +@RequiredArgsConstructor +public class NotificationQueryService { + + private final NotificationQueryRepository notificationQueryRepository; + private final NotificationRepository notificationRepository; + + public CursorBasePaginatedResponse findNotifications(Long userId, Long cursor, int size) { + Slice notifications = notificationQueryRepository.findNotifications(userId, cursor, PageRequest.ofSize(size)); + return CursorBasePaginatedResponse.of(notifications.map(NotificationResponse::of)); + } + public TargetUserDto findUserByCommentId(Long commentId) { + return notificationQueryRepository.findUserByCommentId(commentId) + .orElseThrow(() -> new BadRequestException(ErrorCode.USER_NOT_FOUND)); + } + public TargetUserDto findUserById(Long userId) { + return notificationQueryRepository.findUserById(userId) + .orElseThrow(() -> new BadRequestException(ErrorCode.USER_NOT_FOUND)); + } + public TargetPostDto findPostByCommentId(Long commentId) { + return notificationQueryRepository.findPostByCommentId(commentId) + .orElseThrow(() -> new BadRequestException(ErrorCode.POST_NOT_FOUND)); + } + public TargetUserDto findUserByPostId(Long postId) { + return notificationQueryRepository.findUserByPostId(postId) + .orElseThrow(() -> new BadRequestException(ErrorCode.USER_NOT_FOUND)); + } + public TargetPostDto findPostById(Long postId) { + return notificationQueryRepository.findPostById(postId) + .orElseThrow(() -> new BadRequestException(ErrorCode.POST_NOT_FOUND)); + } + public List findVoteUsersByPostId(Long postId) { + return notificationQueryRepository.findVoteUsersByPostId(postId); + } + public NotificationPresentResponse present(Long userId) { + return NotificationPresentResponse.of(notificationRepository.existsByReceiverIdAndIsReadFalseAndDeletedFalse(userId)); + } + +} diff --git a/src/main/java/com/chooz/notification/application/web/dto/NotificationDto.java b/src/main/java/com/chooz/notification/application/web/dto/NotificationDto.java new file mode 100644 index 00000000..25f01696 --- /dev/null +++ b/src/main/java/com/chooz/notification/application/web/dto/NotificationDto.java @@ -0,0 +1,12 @@ +package com.chooz.notification.application.web.dto; + + +import com.querydsl.core.annotations.QueryProjection; + +import java.util.List; + +@QueryProjection +public record NotificationDto( + NotificationRowDto notificationRowDto, + List targets +) {} diff --git a/src/main/java/com/chooz/notification/application/web/dto/NotificationRowDto.java b/src/main/java/com/chooz/notification/application/web/dto/NotificationRowDto.java new file mode 100644 index 00000000..ffc5634d --- /dev/null +++ b/src/main/java/com/chooz/notification/application/web/dto/NotificationRowDto.java @@ -0,0 +1,19 @@ +package com.chooz.notification.application.web.dto; + + +import com.chooz.notification.domain.NotificationType; +import com.querydsl.core.annotations.QueryProjection; + +import java.time.LocalDateTime; + +@QueryProjection +public record NotificationRowDto( + Long id, + NotificationType notificationType, + String profileUrl, + String title, + String content, + String imageUrl, + boolean isRead, + LocalDateTime eventAt +) {} diff --git a/src/main/java/com/chooz/notification/application/web/dto/TargetDto.java b/src/main/java/com/chooz/notification/application/web/dto/TargetDto.java new file mode 100644 index 00000000..7d4d2181 --- /dev/null +++ b/src/main/java/com/chooz/notification/application/web/dto/TargetDto.java @@ -0,0 +1,11 @@ +package com.chooz.notification.application.web.dto; + + +import com.chooz.notification.domain.TargetType; +import com.querydsl.core.annotations.QueryProjection; + +@QueryProjection +public record TargetDto( + Long id, + TargetType type +) {} diff --git a/src/main/java/com/chooz/notification/domain/Actor.java b/src/main/java/com/chooz/notification/domain/Actor.java deleted file mode 100644 index 746ebc91..00000000 --- a/src/main/java/com/chooz/notification/domain/Actor.java +++ /dev/null @@ -1,25 +0,0 @@ -package com.chooz.notification.domain; - - -import jakarta.persistence.Column; -import jakarta.persistence.Embeddable; -import jakarta.persistence.EnumType; -import jakarta.persistence.Enumerated; -import lombok.AllArgsConstructor; -import lombok.Getter; -import lombok.NoArgsConstructor; - -@Embeddable -@Getter -@NoArgsConstructor -@AllArgsConstructor -public class Actor { - @Column(name = "actor_id", nullable = false) - private Long id; - - @Column(name = "actor_nickname", nullable = false) - private String nickname; - - @Column(name = "actor_profile_url", nullable = false) - private String profileUrl; -} diff --git a/src/main/java/com/chooz/notification/domain/Notification.java b/src/main/java/com/chooz/notification/domain/Notification.java index b9820f32..47d96ca7 100644 --- a/src/main/java/com/chooz/notification/domain/Notification.java +++ b/src/main/java/com/chooz/notification/domain/Notification.java @@ -1,12 +1,18 @@ package com.chooz.notification.domain; import com.chooz.common.domain.BaseEntity; +import com.chooz.notification.application.dto.NotificationContent; +import jakarta.persistence.CollectionTable; import jakarta.persistence.Column; -import jakarta.persistence.Embedded; +import jakarta.persistence.ElementCollection; 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.Table; import lombok.AccessLevel; import lombok.AllArgsConstructor; @@ -15,6 +21,9 @@ import lombok.NoArgsConstructor; import java.time.LocalDateTime; +import java.util.ArrayList; +import java.util.Comparator; +import java.util.List; import java.util.Optional; @Getter @@ -29,14 +38,38 @@ public class Notification extends BaseEntity { @GeneratedValue(strategy = GenerationType.IDENTITY) private Long id; - @Embedded - private Receiver receiver; + @Column(name = "receiver_id", nullable = false) + private Long receiverId; - @Embedded - private Actor actor; + @Column(name = "profile_url", nullable = false) + private String profileUrl; - @Embedded - private Target target; + @Column(name = "title", nullable = false) + private String title; + + @Column(name = "content", nullable = false) + private String content; + + @Builder.Default + @ElementCollection(fetch = FetchType.LAZY) + @CollectionTable( + name = "notification_targets", + joinColumns = @JoinColumn(name = "notification_id") + ) + private List targets = new ArrayList<>(); + + @Enumerated(EnumType.STRING) + @Column(name = "notification_type", nullable = false, length = 50) + private NotificationType notificationType; + + @Column(name = "image_url", nullable = false) + private String imageUrl; + + @Column(name = "dedupKey", nullable = false) + private String dedupKey; + + @Column(name = "is_valid", nullable = false) + private boolean isValid; @Column(name = "is_read", nullable = false) private boolean isRead; @@ -45,34 +78,48 @@ public class Notification extends BaseEntity { private LocalDateTime eventAt; public static Optional create( - Long receiverId, - String receiverNickname, - Long actorId, - String actorNickname, - String actorProfileUrl, - Long targetId, - TargetType targetType, - String targetImageUrl, - LocalDateTime eventAt + NotificationType notificationType, + LocalDateTime eventAt, + NotificationContent notificationContent ) { - if (checkMine(actorId, receiverId)) { + if (checkMine(notificationContent.actorId(), notificationContent.receiverId(), notificationType)) { return Optional.empty(); } return Optional.of(Notification.builder() - .receiver(new Receiver(receiverId, receiverNickname)) - .actor(new Actor(actorId, actorNickname, actorProfileUrl)) - .target(new Target(targetId, targetType, targetImageUrl)) + .receiverId(notificationContent.receiverId()) + .profileUrl(notificationContent.profileUrl()) + .title(notificationContent.title()) + .content(notificationContent.content()) + .targets(List.copyOf(notificationContent.targets())) + .notificationType(notificationType) + .imageUrl(notificationContent.imageUrl()) + .dedupKey(makeDedupKey(notificationType, notificationContent.actorId(), notificationContent.targets())) + .isValid(true) .isRead(false) .eventAt(eventAt) .build()); } - private static boolean checkMine(Long actorId, Long receiverId) { - return actorId != null && actorId.equals(receiverId); + private static boolean checkMine(Long actorId, Long receiverId, NotificationType notificationType) { + return actorId != null && actorId.equals(receiverId) && !NotificationType.isMyPostClosed(notificationType); + } + public static String makeDedupKey(NotificationType notificationType, Long actorId, List targets) { + StringBuilder key = new StringBuilder(100) + .append(actorId).append('|') + .append(notificationType.name()); + targets = targets.stream().sorted(Comparator.comparing(Target::getType)).toList(); + for (Target target : targets) { + key.append('|').append(target.getType()).append(':').append(target.getId()); + } + return key.toString(); } - public void markRead() { if (!isRead) { this.isRead = true; } } + public void invalidate() { + if (isValid) { + this.isValid = false; + } + } } diff --git a/src/main/java/com/chooz/notification/domain/NotificationQueryRepository.java b/src/main/java/com/chooz/notification/domain/NotificationQueryRepository.java index c0091500..fea4a72e 100644 --- a/src/main/java/com/chooz/notification/domain/NotificationQueryRepository.java +++ b/src/main/java/com/chooz/notification/domain/NotificationQueryRepository.java @@ -1,16 +1,22 @@ package com.chooz.notification.domain; -import com.chooz.notification.application.dto.NotificationDto; import com.chooz.notification.application.dto.TargetPostDto; import com.chooz.notification.application.dto.TargetUserDto; +import com.chooz.notification.application.web.dto.NotificationDto; import org.springframework.data.domain.Pageable; import org.springframework.data.domain.Slice; +import java.util.List; import java.util.Optional; public interface NotificationQueryRepository { Slice findNotifications(Long userId, Long cursor, Pageable pageable); - Optional getPost(Long commentId); - Optional getUserByCommentId(Long commentId); - Optional getUser(Long userId); + Optional findPostByCommentId(Long commentId); + Optional findUserByCommentId(Long commentId); + Optional findUserById(Long userId); + Optional findUserByPostId(Long postId); + Optional findPostById(Long postId); + boolean existsByDedupKey(Long ReceiverId, String dedupKey); + List existsDedupKeyByNotifications(List notifications); + List findVoteUsersByPostId(Long postId); } diff --git a/src/main/java/com/chooz/notification/domain/NotificationRepository.java b/src/main/java/com/chooz/notification/domain/NotificationRepository.java index 859367f3..70388298 100644 --- a/src/main/java/com/chooz/notification/domain/NotificationRepository.java +++ b/src/main/java/com/chooz/notification/domain/NotificationRepository.java @@ -1,5 +1,11 @@ package com.chooz.notification.domain; +import java.util.List; +import java.util.Optional; + public interface NotificationRepository { Notification save(Notification notification); + void saveAll(List notifications); + Optional findNotificationById(Long id); + boolean existsByReceiverIdAndIsReadFalseAndDeletedFalse(Long userId); } diff --git a/src/main/java/com/chooz/notification/domain/NotificationType.java b/src/main/java/com/chooz/notification/domain/NotificationType.java new file mode 100644 index 00000000..8973349e --- /dev/null +++ b/src/main/java/com/chooz/notification/domain/NotificationType.java @@ -0,0 +1,16 @@ +package com.chooz.notification.domain; + +public enum NotificationType { + POST_CLOSED("NOTI.POST.CLOSED"), + MY_POST_CLOSED("NOTI.MY.POST.CLOSED"), + COMMENT_LIKED("NOTI.COMMENT.LIKED"), + POST_VOTED("NOTI.POST.VOTED"); + + private final String code; + NotificationType(String code) {this.code = code;} + public String code() {return code;} + + public static boolean isMyPostClosed(NotificationType notificationType) { + return NotificationType.valueOf(notificationType.name()).equals(MY_POST_CLOSED); + } +} diff --git a/src/main/java/com/chooz/notification/domain/Receiver.java b/src/main/java/com/chooz/notification/domain/Receiver.java index 6a02e8d7..94d8401a 100644 --- a/src/main/java/com/chooz/notification/domain/Receiver.java +++ b/src/main/java/com/chooz/notification/domain/Receiver.java @@ -3,8 +3,6 @@ import jakarta.persistence.Column; import jakarta.persistence.Embeddable; -import jakarta.persistence.EnumType; -import jakarta.persistence.Enumerated; import lombok.AllArgsConstructor; import lombok.Getter; import lombok.NoArgsConstructor; @@ -16,8 +14,7 @@ public class Receiver { @Column(name = "receiver_id", nullable = false) private Long id; - - @Column(name = "receiver_nickname", nullable = false) - private String nickname; - + public static Receiver of(Long id){ + return new Receiver(id); + } } diff --git a/src/main/java/com/chooz/notification/domain/Target.java b/src/main/java/com/chooz/notification/domain/Target.java index b4aae820..ff69e094 100644 --- a/src/main/java/com/chooz/notification/domain/Target.java +++ b/src/main/java/com/chooz/notification/domain/Target.java @@ -14,6 +14,7 @@ @NoArgsConstructor @AllArgsConstructor public class Target { + @Column(name = "target_id", nullable = false) private Long id; @@ -21,6 +22,7 @@ public class Target { @Column(name = "target_type", nullable = false) private TargetType type; - @Column(name = "target_image_url", nullable = false) - private String imageUrl; + public static Target of(Long id, TargetType type) { + return new Target(id, type); + } } diff --git a/src/main/java/com/chooz/notification/domain/TargetType.java b/src/main/java/com/chooz/notification/domain/TargetType.java index 87f5a684..d23b7bfd 100644 --- a/src/main/java/com/chooz/notification/domain/TargetType.java +++ b/src/main/java/com/chooz/notification/domain/TargetType.java @@ -3,5 +3,5 @@ public enum TargetType { POST, COMMENT, - VOTE, + COMMENT_LIKE } diff --git a/src/main/java/com/chooz/notification/infrastructure/MessageSourceConfig.java b/src/main/java/com/chooz/notification/infrastructure/MessageSourceConfig.java new file mode 100644 index 00000000..d2f15cab --- /dev/null +++ b/src/main/java/com/chooz/notification/infrastructure/MessageSourceConfig.java @@ -0,0 +1,18 @@ +package com.chooz.notification.infrastructure; + +import org.springframework.context.MessageSource; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.context.support.ReloadableResourceBundleMessageSource; + +@Configuration +public class MessageSourceConfig { + + @Bean(name = "messageSource") + public MessageSource notificationMessageSource() { + ReloadableResourceBundleMessageSource ms = new ReloadableResourceBundleMessageSource(); + ms.setBasenames("classpath:notification/messages"); + ms.setDefaultEncoding("UTF-8"); + return ms; + } +} diff --git a/src/main/java/com/chooz/notification/infrastructure/MessageSourceNotificationRenderer.java b/src/main/java/com/chooz/notification/infrastructure/MessageSourceNotificationRenderer.java new file mode 100644 index 00000000..092b45ff --- /dev/null +++ b/src/main/java/com/chooz/notification/infrastructure/MessageSourceNotificationRenderer.java @@ -0,0 +1,43 @@ +package com.chooz.notification.infrastructure; + +import com.chooz.common.util.Validator; +import com.chooz.notification.application.NotificationMessageRenderer; +import com.chooz.notification.application.dto.RenderedMessage; +import lombok.RequiredArgsConstructor; +import org.springframework.context.MessageSource; +import org.springframework.stereotype.Component; + +import java.util.Locale; +import java.util.Map; + +@Component +@RequiredArgsConstructor +public class MessageSourceNotificationRenderer implements NotificationMessageRenderer { + + private final MessageSource notificationMessageSource; + private static final Locale DEFAULT_LOCALE = Locale.KOREAN; + + @Override + public RenderedMessage render(String code, Map vars) { + String titleKey = code + ".title"; + String contentKey = code + ".content"; + + Object[] args = argsFrom(vars); + String title = getMessage(titleKey, args); + String content = getMessage(contentKey , args); + Validator.validateEmptyString(title); + Validator.validateEmptyString(content); + return new RenderedMessage(title, content); + } + + private String getMessage(String key, Object[] args) { + return notificationMessageSource.getMessage(key, args, DEFAULT_LOCALE); + } + + private Object[] argsFrom(Map vars) { + return new Object[] { + vars.get("actorName"), + vars.get("postTitle"), + }; + } +} diff --git a/src/main/java/com/chooz/notification/persistence/NotificationJpaRepository.java b/src/main/java/com/chooz/notification/persistence/NotificationJpaRepository.java index f30cdbe8..fe2b5544 100644 --- a/src/main/java/com/chooz/notification/persistence/NotificationJpaRepository.java +++ b/src/main/java/com/chooz/notification/persistence/NotificationJpaRepository.java @@ -1,27 +1,10 @@ package com.chooz.notification.persistence; import com.chooz.notification.domain.Notification; -import org.springframework.data.domain.Pageable; -import org.springframework.data.domain.Slice; import org.springframework.data.jpa.repository.JpaRepository; -import org.springframework.data.jpa.repository.Query; -import org.springframework.data.repository.query.Param; import org.springframework.stereotype.Repository; @Repository public interface NotificationJpaRepository extends JpaRepository { - - @Query(""" - SELECT n - FROM Notification n - WHERE n.receiver.id = :userId - AND (:cursor is null OR n.id < :cursor) - ORDER BY - n.id DESC - """) - Slice findByUserId( - @Param("userId") Long userId, - @Param("cursor") Long cursor, - Pageable pageable - ); + boolean existsByReceiverIdAndIsReadFalseAndDeletedFalse(Long userId); } diff --git a/src/main/java/com/chooz/notification/persistence/NotificationQueryDslRepository.java b/src/main/java/com/chooz/notification/persistence/NotificationQueryDslRepository.java index 11c02dfd..0f39d554 100644 --- a/src/main/java/com/chooz/notification/persistence/NotificationQueryDslRepository.java +++ b/src/main/java/com/chooz/notification/persistence/NotificationQueryDslRepository.java @@ -1,12 +1,17 @@ package com.chooz.notification.persistence; -import com.chooz.notification.application.dto.NotificationDto; -import com.chooz.notification.application.dto.QNotificationDto; import com.chooz.notification.application.dto.QTargetPostDto; import com.chooz.notification.application.dto.QTargetUserDto; import com.chooz.notification.application.dto.TargetPostDto; import com.chooz.notification.application.dto.TargetUserDto; -import com.chooz.notification.domain.TargetType; +import com.chooz.notification.application.web.dto.NotificationDto; +import com.chooz.notification.application.web.dto.NotificationRowDto; +import com.chooz.notification.application.web.dto.QNotificationRowDto; +import com.chooz.notification.application.web.dto.QTargetDto; +import com.chooz.notification.application.web.dto.TargetDto; +import com.chooz.notification.domain.Notification; +import com.chooz.notification.domain.QTarget; +import com.querydsl.core.BooleanBuilder; import com.querydsl.jpa.impl.JPAQueryFactory; import lombok.RequiredArgsConstructor; import org.springframework.data.domain.Pageable; @@ -15,12 +20,14 @@ import org.springframework.stereotype.Repository; import java.util.List; +import java.util.Map; import java.util.Optional; import static com.chooz.comment.domain.QComment.comment; import static com.chooz.notification.domain.QNotification.notification; import static com.chooz.post.domain.QPost.post; import static com.chooz.user.domain.QUser.user; +import static com.chooz.vote.domain.QVote.vote; @Repository @RequiredArgsConstructor @@ -29,51 +36,63 @@ public class NotificationQueryDslRepository { private final JPAQueryFactory queryFactory; public Slice findNotifications(Long userId, Long cursor, Pageable pageable) { - List notifications = queryFactory - .select(new QNotificationDto( + List notificationRows = queryFactory + .select(new QNotificationRowDto( notification.id, - post.id, - notification.receiver.id, - notification.receiver.nickname, - notification.actor.id, - notification.actor.nickname, - notification.actor.profileUrl, - notification.target.id, - notification.target.type, - notification.target.imageUrl, + notification.notificationType, + notification.profileUrl, + notification.title, + notification.content, + notification.imageUrl, notification.isRead, notification.eventAt ) ) .from(notification) - .leftJoin(comment) - .on(notification.target.type.eq(TargetType.COMMENT) - .and(comment.id.eq(notification.target.id))) - .leftJoin(post) - .on(post.id.eq(comment.postId)) .where( - notification.receiver.id.eq(userId), + notification.receiverId.eq(userId), + notification.isValid.eq(true), cursor != null ? notification.id.lt(cursor) : null ) .orderBy(notification.id.desc()) .limit(pageable.getPageSize() + 1) .fetch(); + if(notificationRows.isEmpty()) { + return new SliceImpl<>(List.of(), pageable, false); + } + List notifications = findNotificationsWithTarget(notificationRows); boolean hasNext = notifications.size() > pageable.getPageSize(); if (hasNext) notifications.removeLast(); return new SliceImpl<>(notifications, pageable, hasNext); } + private List findNotificationsWithTarget(List notificationRows) { + QTarget target = QTarget.target; + List ids = notificationRows.stream().map(NotificationRowDto::id).toList(); + Map> targetsByNotificationId = queryFactory + .from(notification) + .join(notification.targets, target) + .where(notification.id.in(ids)) + .transform(com.querydsl.core.group.GroupBy.groupBy(notification.id).as( + com.querydsl.core.group.GroupBy.list(new QTargetDto(target.id, target.type)) + )); + return notificationRows.stream().map( + row -> new NotificationDto( + row, + targetsByNotificationId.getOrDefault(row.id(), List.of()) + )).toList(); + } - Optional getPost(Long commentId) { + public Optional findPostByCommentId(Long commentId) { return Optional.ofNullable( - queryFactory.select(new QTargetPostDto(post.id, post.imageUrl)) + queryFactory.select(new QTargetPostDto(post.id, post.title, post.imageUrl)) .from(comment) - .join(post).on(post.id.eq(comment.postId)) + .join(post).on(post.id.eq(comment.postId), post.deleted.eq(false)) .where(comment.id.eq(commentId)) .limit(1) .fetchFirst()); } - Optional getUserByCommentId(Long commentId) { + public Optional findUserByCommentId(Long commentId) { return Optional.ofNullable( queryFactory.select(new QTargetUserDto(user.id, user.nickname, user.profileUrl)) .from(comment) @@ -82,7 +101,7 @@ Optional getUserByCommentId(Long commentId) { .limit(1) .fetchFirst()); } - Optional getUser(Long userId) { + public Optional findUserById(Long userId) { return Optional.ofNullable( queryFactory.select(new QTargetUserDto(user.id, user.nickname, user.profileUrl)) .from(user) @@ -90,5 +109,53 @@ Optional getUser(Long userId) { .limit(1) .fetchFirst()); } + public Optional findUserByPostId(Long postId) { + return Optional.ofNullable( + queryFactory.select(new QTargetUserDto(user.id, user.nickname, user.profileUrl)) + .from(user) + .join(post).on(user.id.eq(post.userId), post.deleted.eq(false)) + .where(post.id.eq(postId)) + .limit(1) + .fetchFirst()); + } + public Optional findPostById(Long postId) { + return Optional.ofNullable( + queryFactory.select(new QTargetPostDto(post.id, post.title, post.imageUrl)) + .from(post) + .where( + post.id.eq(postId), + post.deleted.eq(false) + ) + .limit(1) + .fetchFirst()); + } + public boolean existsByDedupKey(Long receiverId, String dedupkey) { + Integer one = queryFactory.selectOne() + .from(notification) + .where( + notification.receiverId.eq(receiverId), + notification.dedupKey.eq(dedupkey) + ).fetchFirst(); + return one != null; + } + public List existsDedupKeyByNotifications(List notifications) { + BooleanBuilder builder = new BooleanBuilder(); + for (Notification n : notifications) { + builder.or( + notification.receiverId.eq(n.getReceiverId()) + .and(notification.dedupKey.eq(n.getDedupKey())) + ); + } + return queryFactory.selectFrom(notification) + .where(builder) + .fetch(); + } + public List findVoteUsersByPostId(Long postId) { + return queryFactory.select(new QTargetUserDto(user.id, user.nickname, user.profileUrl)) + .from(user) + .join(vote).on(user.id.eq(vote.userId), vote.deleted.eq(false)) + .join(post).on(post.id.eq(vote.postId), post.deleted.eq(false)) + .fetch(); + } } diff --git a/src/main/java/com/chooz/notification/persistence/NotificationQueryRepositoryImpl.java b/src/main/java/com/chooz/notification/persistence/NotificationQueryRepositoryImpl.java index 736e8817..fb3db099 100644 --- a/src/main/java/com/chooz/notification/persistence/NotificationQueryRepositoryImpl.java +++ b/src/main/java/com/chooz/notification/persistence/NotificationQueryRepositoryImpl.java @@ -1,47 +1,66 @@ package com.chooz.notification.persistence; -import com.chooz.notification.application.dto.NotificationDto; import com.chooz.notification.application.dto.TargetPostDto; import com.chooz.notification.application.dto.TargetUserDto; +import com.chooz.notification.application.web.dto.NotificationDto; import com.chooz.notification.domain.Notification; import com.chooz.notification.domain.NotificationQueryRepository; import lombok.RequiredArgsConstructor; -import org.springframework.data.domain.Page; import org.springframework.data.domain.Pageable; import org.springframework.data.domain.Slice; import org.springframework.stereotype.Repository; +import java.util.List; import java.util.Optional; @Repository @RequiredArgsConstructor public class NotificationQueryRepositoryImpl implements NotificationQueryRepository { - private final NotificationJpaRepository notificationJpaRepository; private final NotificationQueryDslRepository notificationQueryDslRepository; -// @Override -// public Slice findNotifications(Long userId, Long cursor, Pageable pageable) { -// return notificationJpaRepository.findByUserId(userId, cursor, pageable); -// } - @Override public Slice findNotifications(Long userId, Long cursor, Pageable pageable) { return notificationQueryDslRepository.findNotifications(userId, cursor, pageable); } @Override - public Optional getPost(Long commentId) { - return notificationQueryDslRepository.getPost(commentId); + public Optional findPostByCommentId(Long commentId) { + return notificationQueryDslRepository.findPostByCommentId(commentId); + } + + @Override + public Optional findUserByCommentId(Long commentId) { + return notificationQueryDslRepository.findUserByCommentId(commentId); + } + + @Override + public Optional findUserById(Long userId) { + return notificationQueryDslRepository.findUserById(userId); + } + + @Override + public Optional findUserByPostId(Long postId) { + return notificationQueryDslRepository.findUserByPostId(postId); + } + + @Override + public Optional findPostById(Long postId) { + return notificationQueryDslRepository.findPostById(postId); + } + + @Override + public boolean existsByDedupKey(Long ReceiverId, String dedupKey) { + return notificationQueryDslRepository.existsByDedupKey(ReceiverId, dedupKey); } @Override - public Optional getUserByCommentId(Long commentId) { - return notificationQueryDslRepository.getUserByCommentId(commentId); + public List existsDedupKeyByNotifications(List notifications) { + return notificationQueryDslRepository.existsDedupKeyByNotifications(notifications); } @Override - public Optional getUser(Long userId) { - return notificationQueryDslRepository.getUser(userId); + public List findVoteUsersByPostId(Long postId) { + return notificationQueryDslRepository.findVoteUsersByPostId(postId); } } \ No newline at end of file diff --git a/src/main/java/com/chooz/notification/persistence/NotificationRepositoryImpl.java b/src/main/java/com/chooz/notification/persistence/NotificationRepositoryImpl.java index fb55a132..ea066d2f 100644 --- a/src/main/java/com/chooz/notification/persistence/NotificationRepositoryImpl.java +++ b/src/main/java/com/chooz/notification/persistence/NotificationRepositoryImpl.java @@ -5,6 +5,9 @@ import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Repository; +import java.util.List; +import java.util.Optional; + @Repository @RequiredArgsConstructor @@ -17,4 +20,19 @@ public Notification save(Notification notification) { return notificationJpaRepository.save(notification); } + @Override + public void saveAll(List notifications) { + notificationJpaRepository.saveAll(notifications); + } + + @Override + public Optional findNotificationById(Long id) { + return notificationJpaRepository.findById(id); + } + + @Override + public boolean existsByReceiverIdAndIsReadFalseAndDeletedFalse(Long userId) { + return notificationJpaRepository.existsByReceiverIdAndIsReadFalseAndDeletedFalse(userId); + } + } \ No newline at end of file diff --git a/src/main/java/com/chooz/notification/presentation/NotificationController.java b/src/main/java/com/chooz/notification/presentation/NotificationController.java index 7a37f650..d821a1ab 100644 --- a/src/main/java/com/chooz/notification/presentation/NotificationController.java +++ b/src/main/java/com/chooz/notification/presentation/NotificationController.java @@ -2,13 +2,16 @@ import com.chooz.auth.domain.UserInfo; import com.chooz.common.dto.CursorBasePaginatedResponse; -import com.chooz.notification.application.NotificationQueryService; +import com.chooz.notification.application.NotificationService; +import com.chooz.notification.presentation.dto.NotificationPresentResponse; import com.chooz.notification.presentation.dto.NotificationResponse; import jakarta.validation.constraints.Min; import lombok.RequiredArgsConstructor; import org.springframework.http.ResponseEntity; import org.springframework.security.core.annotation.AuthenticationPrincipal; import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PatchMapping; +import org.springframework.web.bind.annotation.PathVariable; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.bind.annotation.RestController; @@ -17,7 +20,7 @@ @RequiredArgsConstructor @RequestMapping("/notifications") public class NotificationController { - private final NotificationQueryService notificationQueryService; + private final NotificationService notificationService; @GetMapping("") public ResponseEntity> findNotifications( @@ -25,6 +28,19 @@ public ResponseEntity> findNot @RequestParam(name = "size", required = false, defaultValue = "10") @Min(1) int size, @AuthenticationPrincipal UserInfo userInfo ) { - return ResponseEntity.ok(notificationQueryService.findNotifications(userInfo.userId(), cursor, size)); + return ResponseEntity.ok(notificationService.findNotifications(userInfo.userId(), cursor, size)); + } + @PatchMapping("/{notificationId}") + public void markRead( + @PathVariable("notificationId") Long notificationId + ) { + notificationService.markRead(notificationId); + ResponseEntity.ok().build(); + } + @GetMapping("/present") + public ResponseEntity present( + @AuthenticationPrincipal UserInfo userInfo + ) { + return ResponseEntity.ok(notificationService.present(userInfo.userId())); } } diff --git a/src/main/java/com/chooz/notification/presentation/dto/NotificationPresentResponse.java b/src/main/java/com/chooz/notification/presentation/dto/NotificationPresentResponse.java new file mode 100644 index 00000000..1e5cc816 --- /dev/null +++ b/src/main/java/com/chooz/notification/presentation/dto/NotificationPresentResponse.java @@ -0,0 +1,7 @@ +package com.chooz.notification.presentation.dto; + +public record NotificationPresentResponse(boolean present){ + public static NotificationPresentResponse of(boolean present) { + return new NotificationPresentResponse(present); + } +} diff --git a/src/main/java/com/chooz/notification/presentation/dto/NotificationResponse.java b/src/main/java/com/chooz/notification/presentation/dto/NotificationResponse.java index 64b7706e..6169b9f1 100644 --- a/src/main/java/com/chooz/notification/presentation/dto/NotificationResponse.java +++ b/src/main/java/com/chooz/notification/presentation/dto/NotificationResponse.java @@ -1,44 +1,37 @@ package com.chooz.notification.presentation.dto; import com.chooz.common.dto.CursorDto; -import com.chooz.notification.application.dto.NotificationDto; -import com.chooz.notification.domain.Actor; -import com.chooz.notification.domain.Notification; -import com.chooz.notification.domain.Receiver; +import com.chooz.notification.application.web.dto.NotificationDto; +import com.chooz.notification.domain.NotificationType; import com.chooz.notification.domain.Target; import java.time.LocalDateTime; +import java.util.List; public record NotificationResponse ( Long id, - Long postId, - Receiver receiver, - Actor actor, - Target target, + NotificationType notificationType, + String profileUrl, + String title, + String content, + String imageUrl, + List targets, boolean isRead, LocalDateTime eventAt )implements CursorDto{ - public static NotificationResponse of (NotificationDto notificationDto){ return new NotificationResponse( - notificationDto.id(), - notificationDto.postId(), - new Receiver(notificationDto.receiverId(), notificationDto.receiverNickname()), - new Actor( - notificationDto.actorId(), - notificationDto.actorNickname(), - notificationDto.actorProfileUrl() - ), - new Target( - notificationDto.targetId(), - notificationDto.targetType(), - notificationDto.targetImageUrl() - ), - notificationDto.isRead(), - notificationDto.eventAt() + notificationDto.notificationRowDto().id(), + notificationDto.notificationRowDto().notificationType(), + notificationDto.notificationRowDto().profileUrl(), + notificationDto.notificationRowDto().title(), + notificationDto.notificationRowDto().content(), + notificationDto.notificationRowDto().imageUrl(), + List.copyOf(notificationDto.targets().stream().map(t -> Target.of(t.id(), t.type())).toList()), + notificationDto.notificationRowDto().isRead(), + notificationDto.notificationRowDto().eventAt() ); } - @Override public long getId() { return this.id; } } diff --git a/src/main/java/com/chooz/post/application/DateCloseScheduler.java b/src/main/java/com/chooz/post/application/DateCloseScheduler.java index 5d85ed01..e3c88c88 100644 --- a/src/main/java/com/chooz/post/application/DateCloseScheduler.java +++ b/src/main/java/com/chooz/post/application/DateCloseScheduler.java @@ -1,5 +1,8 @@ package com.chooz.post.application; +import com.chooz.common.event.EventPublisher; +import com.chooz.post.application.dto.PostClosedNotificationEvent; +import com.chooz.post.domain.CloseType; import com.chooz.post.domain.Post; import com.chooz.post.domain.PostRepository; import lombok.RequiredArgsConstructor; @@ -17,6 +20,7 @@ public class DateCloseScheduler { private final PostRepository postRepository; + private final EventPublisher eventPublisher; @Transactional @Scheduled(fixedDelay = 1000) @@ -24,6 +28,15 @@ public void closePostsByDate() { log.info("마감 스케줄링 시작 | 서버 시간: {}", LocalDateTime.now()); List postsNeedToClose = postRepository.findPostsNeedToClose(); postsNeedToClose.forEach(Post::close); + postsNeedToClose.forEach( + post -> eventPublisher.publish(new PostClosedNotificationEvent( + post.getId(), + post.getUserId(), + post.getCloseOption().getCloseType(), + LocalDateTime.now() + ) + ) + ); log.info("총 {}개 게시글 마감", postsNeedToClose.size()); } } diff --git a/src/main/java/com/chooz/post/application/PostCommandService.java b/src/main/java/com/chooz/post/application/PostCommandService.java index 51dff0d8..3fceb3b9 100644 --- a/src/main/java/com/chooz/post/application/PostCommandService.java +++ b/src/main/java/com/chooz/post/application/PostCommandService.java @@ -1,7 +1,9 @@ package com.chooz.post.application; +import com.chooz.common.event.EventPublisher; import com.chooz.common.exception.BadRequestException; import com.chooz.common.exception.ErrorCode; +import com.chooz.post.application.dto.PostClosedNotificationEvent; import com.chooz.post.domain.CloseOption; import com.chooz.post.domain.PollOption; import com.chooz.post.domain.Post; @@ -16,6 +18,7 @@ import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; +import java.time.LocalDateTime; import java.util.List; import java.util.stream.Collectors; @@ -28,6 +31,7 @@ public class PostCommandService { private final ShareUrlService shareUrlService; private final ThumbnailRepository thumbnailRepository; private final PostValidator postValidator; + private final EventPublisher eventPublisher; public CreatePostResponse create(Long userId, CreatePostRequest request) { Post post = createPost(userId, request); @@ -87,6 +91,13 @@ public void close(Long userId, Long postId) { Post post = postRepository.findById(postId) .orElseThrow(() -> new BadRequestException(ErrorCode.POST_NOT_FOUND)); post.closeByAuthor(userId); + eventPublisher.publish(new PostClosedNotificationEvent( + post.getId(), + post.getUserId(), + post.getCloseOption().getCloseType(), + LocalDateTime.now() + ) + ); } @Transactional diff --git a/src/main/java/com/chooz/post/application/PostVotedEventListener.java b/src/main/java/com/chooz/post/application/PostVotedEventListener.java index e1d3e7bd..b755b453 100644 --- a/src/main/java/com/chooz/post/application/PostVotedEventListener.java +++ b/src/main/java/com/chooz/post/application/PostVotedEventListener.java @@ -1,7 +1,9 @@ package com.chooz.post.application; +import com.chooz.common.event.EventPublisher; import com.chooz.common.exception.BadRequestException; import com.chooz.common.exception.ErrorCode; +import com.chooz.post.application.dto.PostClosedNotificationEvent; import com.chooz.post.domain.Post; import com.chooz.post.domain.PostRepository; import com.chooz.vote.application.VotedEvent; @@ -11,18 +13,20 @@ import org.springframework.transaction.event.TransactionPhase; import org.springframework.transaction.event.TransactionalEventListener; +import java.time.LocalDateTime; + @Component @RequiredArgsConstructor public class PostVotedEventListener { private final PostRepository postRepository; private final VoteRepository voteRepository; + private final EventPublisher eventPublisher; @TransactionalEventListener(phase = TransactionPhase.BEFORE_COMMIT) public void handle(VotedEvent event) { Post post = postRepository.findById(event.postId()) .orElseThrow(() -> new BadRequestException(ErrorCode.POST_NOT_FOUND)); - handleClosePost(post); } @@ -30,6 +34,13 @@ private void handleClosePost(Post post) { long voterCount = voteRepository.countVoterByPostId(post.getId()); if (post.isClosableByVoterCount(voterCount)) { post.close(); + eventPublisher.publish(new PostClosedNotificationEvent( + post.getId(), + post.getUserId(), + post.getCloseOption().getCloseType(), + LocalDateTime.now() + ) + ); } } } diff --git a/src/main/java/com/chooz/post/application/dto/PostClosedNotificationEvent.java b/src/main/java/com/chooz/post/application/dto/PostClosedNotificationEvent.java new file mode 100644 index 00000000..035e8f5c --- /dev/null +++ b/src/main/java/com/chooz/post/application/dto/PostClosedNotificationEvent.java @@ -0,0 +1,13 @@ +package com.chooz.post.application.dto; + +import com.chooz.post.domain.CloseType; + +import java.time.LocalDateTime; + +public record PostClosedNotificationEvent( + Long postId, + Long userId, + CloseType closeType, + LocalDateTime eventAt +) {} + diff --git a/src/main/java/com/chooz/vote/application/VoteService.java b/src/main/java/com/chooz/vote/application/VoteService.java index f32e393e..06fb4984 100644 --- a/src/main/java/com/chooz/vote/application/VoteService.java +++ b/src/main/java/com/chooz/vote/application/VoteService.java @@ -12,6 +12,7 @@ import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; +import java.time.LocalDateTime; import java.util.List; @Service @@ -36,7 +37,7 @@ public List vote(Long voterId, Long postId, List pollChoiceIds) { List voteIds = voteWriter.vote(voterId, postId, pollChoiceIds); eventPublisher.publish(new VotedEvent(post.getId(), pollChoiceIds, voterId)); - + eventPublisher.publish(new VotedNotificationEvent(postId, voterId, LocalDateTime.now())); return voteIds; } diff --git a/src/main/java/com/chooz/vote/application/VotedNotificationEvent.java b/src/main/java/com/chooz/vote/application/VotedNotificationEvent.java new file mode 100644 index 00000000..ee209d46 --- /dev/null +++ b/src/main/java/com/chooz/vote/application/VotedNotificationEvent.java @@ -0,0 +1,10 @@ +package com.chooz.vote.application; + +import java.time.LocalDateTime; + +public record VotedNotificationEvent( + Long postId, + Long voterId, + LocalDateTime eventAt +) {} + diff --git a/src/main/resources/notification/messages.properties b/src/main/resources/notification/messages.properties new file mode 100644 index 00000000..af6e6c91 --- /dev/null +++ b/src/main/resources/notification/messages.properties @@ -0,0 +1,15 @@ +# COMMENT_LIKED +NOTI.COMMENT.LIKED.title={0} 님이 댓글에 좋아요를 눌렀어요! +NOTI.COMMENT.LIKED.content=지금 바로 확인해보세요. + +# POST_VOTED +NOTI.POST.VOTED.title={0} 님이 "{1}" 투표에 참여했어요! +NOTI.POST.VOTED.content=지금 바로 결과를 확인해보세요. + +# MY_POST_CLOSED +NOTI.MY.POST.CLOSED.title=당신이 만든 "{1}" 투표가 마감됐습니다! +NOTI.MY.POST.CLOSED.content=지금 바로 결과를 확인해보세요. + +# POST_CLOSED +NOTI.POST.CLOSED.title=당신이 참여한 "{1}" 투표가 마감됐습니다! +NOTI.POST.CLOSED.content=지금 바로 결과를 확인해보세요. \ No newline at end of file diff --git a/src/test/java/com/chooz/notification/application/CommentLikeNotificationListenerTest.java b/src/test/java/com/chooz/notification/application/CommentLikeNotificationListenerTest.java index 52d5e47b..02947488 100644 --- a/src/test/java/com/chooz/notification/application/CommentLikeNotificationListenerTest.java +++ b/src/test/java/com/chooz/notification/application/CommentLikeNotificationListenerTest.java @@ -3,8 +3,7 @@ import com.chooz.comment.domain.Comment; import com.chooz.comment.domain.CommentRepository; import com.chooz.commentLike.application.CommentLikeService; -import com.chooz.notification.application.dto.NotificationDto; -import com.chooz.notification.domain.Notification; +import com.chooz.notification.application.web.dto.NotificationDto; import com.chooz.notification.domain.NotificationQueryRepository; import com.chooz.notification.domain.TargetType; import com.chooz.post.domain.Post; @@ -19,7 +18,6 @@ import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.data.domain.PageRequest; -import org.springframework.data.domain.Slice; import org.springframework.test.context.transaction.TestTransaction; import static org.assertj.core.api.Assertions.assertThat; @@ -50,8 +48,8 @@ void onCommentLiked() throws Exception { User actor = userRepository.save(UserFixture.createDefaultUser()); Post post = postRepository.save(PostFixture.createPostBuilder().userId(receiver.getId()).build()); Comment comment = commentRepository.save(CommentFixture.createCommentBuilder() - .userId(receiver.getId()) .postId(post.getId()) + .userId(receiver.getId()) .build()); //when @@ -60,19 +58,24 @@ void onCommentLiked() throws Exception { TestTransaction.end(); //then - Slice notificationSlice = notificationQueryRepository.findNotifications( + NotificationDto notification = notificationQueryRepository.findNotifications( receiver.getId(), null, PageRequest.ofSize(10) - ); + ).getContent().getFirst(); assertAll( - () -> assertThat(notificationSlice.getContent().size()).isEqualTo(1), - () -> assertThat(notificationSlice.getContent().getFirst().receiverId()).isEqualTo(receiver.getId()), - () -> assertThat(notificationSlice.getContent().getFirst().actorId()).isEqualTo(actor.getId()), - () -> assertThat(notificationSlice.getContent().getFirst().targetType()).isEqualTo(TargetType.COMMENT), - () -> assertThat(notificationSlice.getContent().getFirst().targetId()).isEqualTo(comment.getId()), - () -> assertThat(notificationSlice.getContent().getFirst().postId()).isEqualTo(post.getId()) + () -> assertThat(notification.notificationRowDto().title()).contains("좋아요를 눌렀어요!"), + () -> assertThat(notification.notificationRowDto().content()).contains("확인해보세요."), + () -> assertThat(notification.notificationRowDto().profileUrl()).isEqualTo(actor.getProfileUrl()), + () -> assertThat(notification.targets()) + .hasSize(2) + .anySatisfy(target -> { + assertThat(target.type()).isEqualTo(TargetType.POST); + } + ), + () -> assertThat(notification.notificationRowDto().imageUrl()).isEqualTo(post.getImageUrl()), + () -> assertThat(notification.notificationRowDto().isRead()).isEqualTo(false) ); } } diff --git a/src/test/java/com/chooz/notification/application/MyPostClosedNotificationListenerTest.java b/src/test/java/com/chooz/notification/application/MyPostClosedNotificationListenerTest.java new file mode 100644 index 00000000..561516db --- /dev/null +++ b/src/test/java/com/chooz/notification/application/MyPostClosedNotificationListenerTest.java @@ -0,0 +1,167 @@ +package com.chooz.notification.application; + +import com.chooz.notification.application.web.dto.NotificationDto; +import com.chooz.notification.domain.NotificationQueryRepository; +import com.chooz.notification.domain.NotificationType; +import com.chooz.notification.domain.TargetType; +import com.chooz.post.application.DateCloseScheduler; +import com.chooz.post.application.PostCommandService; +import com.chooz.post.application.PostVotedEventListener; +import com.chooz.post.domain.CloseType; +import com.chooz.post.domain.Post; +import com.chooz.post.domain.PostRepository; +import com.chooz.support.IntegrationTest; +import com.chooz.support.fixture.PostFixture; +import com.chooz.support.fixture.UserFixture; +import com.chooz.support.fixture.VoteFixture; +import com.chooz.user.domain.User; +import com.chooz.user.domain.UserRepository; +import com.chooz.vote.application.VotedEvent; +import com.chooz.vote.domain.VoteRepository; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.data.domain.PageRequest; +import org.springframework.test.context.transaction.TestTransaction; + +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertAll; + +class MyPostClosedNotificationListenerTest extends IntegrationTest { + + @Autowired + UserRepository userRepository; + + @Autowired + PostRepository postRepository; + + @Autowired + VoteRepository voteRepository; + + @Autowired + NotificationQueryRepository notificationQueryRepository; + + @Autowired + PostVotedEventListener postVotedEventListener; + + @Autowired + DateCloseScheduler dateCloseScheduler; + + @Autowired + PostCommandService postCommandService; + + @Test + @DisplayName("내 투표 마감 알림(참여자 수 마감)") + void onMyPostClosedByVoter() throws Exception { + //given + User user1 = userRepository.save(UserFixture.createDefaultUser()); + Post post = postRepository.save(PostFixture.createPostBuilder() + .userId(user1.getId()) + .closeOption( + PostFixture.createCloseOptionBuilder() + .closeType(CloseType.VOTER) + .maxVoterCount(5) + .build()) + .build()); + + //when + int voterCount = 5; + for (int i = 0; i < voterCount; i++) { + User user = userRepository.save(UserFixture.createDefaultUser()); + voteRepository.save(VoteFixture.createDefaultVote(user.getId(), post.getId(), post.getPollChoices().get(0).getId())); + } + postVotedEventListener.handle(new VotedEvent(post.getId(), List.of(post.getPollChoices().get(0).getId()), user1.getId())); + TestTransaction.flagForCommit(); + TestTransaction.end(); + + //then + NotificationDto notification = notificationQueryRepository.findNotifications( + user1.getId(), + null, + PageRequest.ofSize(10) + ).getContent().stream().filter(notificationDto -> + notificationDto.notificationRowDto().notificationType().equals(NotificationType.MY_POST_CLOSED)) + .toList().getFirst(); + + assertAll( + () -> assertThat(notification.notificationRowDto().title()).contains("투표가 마감됐습니다!"), + () -> assertThat(notification.notificationRowDto().content()).contains("확인해보세요."), + () -> assertThat(notification.notificationRowDto().profileUrl()).isEqualTo(user1.getProfileUrl()), + () -> assertThat(notification.targets()) + .hasSize(1) + .anySatisfy(target -> { + assertThat(target.type()).isEqualTo(TargetType.POST); + } + ), + () -> assertThat(notification.notificationRowDto().imageUrl()).isEqualTo(post.getImageUrl()), + () -> assertThat(notification.notificationRowDto().isRead()).isEqualTo(false) + ); + } + @Test + @DisplayName("내 투표 마감 알림(시간 마감)") + void onMyPostClosedByDate() throws Exception { + // given + User user = userRepository.save(UserFixture.createDefaultUser()); + Post post = postRepository.save(PostFixture.createPostBuilder() + .userId(user.getId()) + .closeOption(PostFixture.createCloseOptionOverDate()) + .build()); + + // when + dateCloseScheduler.closePostsByDate(); + + TestTransaction.flagForCommit(); + TestTransaction.end(); + + //then + NotificationDto notification = notificationQueryRepository.findNotifications( + user.getId(), + null, + PageRequest.ofSize(10) + ).getContent().stream().filter(notificationDto -> + notificationDto.notificationRowDto().notificationType().equals(NotificationType.MY_POST_CLOSED)) + .toList().getFirst(); + + assertAll( + () -> assertThat(notification.notificationRowDto().title()).contains("투표가 마감됐습니다!"), + () -> assertThat(notification.notificationRowDto().content()).contains("확인해보세요."), + () -> assertThat(notification.notificationRowDto().profileUrl()).isEqualTo(user.getProfileUrl()), + () -> assertThat(notification.targets()) + .hasSize(1) + .anySatisfy(target -> { + assertThat(target.type()).isEqualTo(TargetType.POST); + } + ), + () -> assertThat(notification.notificationRowDto().imageUrl()).isEqualTo(post.getImageUrl()), + () -> assertThat(notification.notificationRowDto().isRead()).isEqualTo(false) + ); + } + @Test + @DisplayName("내 투표 마감 알림(직접 마감)") + void onMyPostClosedBySelf() throws Exception { + // given + User user = userRepository.save(UserFixture.createDefaultUser()); + Post post = postRepository.save(PostFixture.createDefaultPost(user.getId())); + + // when + postCommandService.close(user.getId(), post.getId()); + + TestTransaction.flagForCommit(); + TestTransaction.end(); + + //then + List notifications = notificationQueryRepository.findNotifications( + user.getId(), + null, + PageRequest.ofSize(10) + ).getContent().stream().filter(notificationDto -> + notificationDto.notificationRowDto().notificationType().equals(NotificationType.MY_POST_CLOSED)) + .toList(); + + assertAll( + () -> assertThat(notifications).hasSize(0) + ); + } +} diff --git a/src/test/java/com/chooz/notification/application/NotificationQueryServiceTest.java b/src/test/java/com/chooz/notification/application/NotificationQueryServiceTest.java new file mode 100644 index 00000000..53be924d --- /dev/null +++ b/src/test/java/com/chooz/notification/application/NotificationQueryServiceTest.java @@ -0,0 +1,107 @@ +package com.chooz.notification.application; + +import com.chooz.notification.application.dto.NotificationContent; +import com.chooz.notification.application.service.NotificationCommandService; +import com.chooz.notification.application.service.NotificationQueryService; +import com.chooz.notification.domain.Notification; +import com.chooz.notification.domain.NotificationType; +import com.chooz.notification.domain.Target; +import com.chooz.notification.domain.TargetType; +import com.chooz.notification.presentation.dto.NotificationPresentResponse; +import com.chooz.notification.presentation.dto.NotificationResponse; +import com.chooz.support.IntegrationTest; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; + +import java.time.LocalDateTime; +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertAll; + +class NotificationQueryServiceTest extends IntegrationTest { + + @Autowired + NotificationQueryService notificationQueryService; + + @Autowired + NotificationCommandService notificationCommandService; + + @Test + @DisplayName("알림 조회") + void notifications() throws Exception { + //given + Long receiverId = 1L; + Long actorId = 2L; + String title = "숨겨진 츄님이 좋아요를 눌렀어요!"; + String content = "지금 바로 확인해보세요."; + String profileUrl = "https://cdn.chooz.site/default_profile.png"; + List targets = List.of(Target.of(3L, TargetType.POST)); + String imageUrl = "https://cdn.chooz.site/images/20865b3c-4e2c-454a-81a1-9ca31bbaf77d"; + LocalDateTime eventAt = LocalDateTime.now(); + NotificationType notificationType = NotificationType.COMMENT_LIKED; + + Notification notification = Notification.create( + notificationType, + eventAt, + NotificationContent.of( + receiverId, + actorId, + title, + content, + profileUrl, + imageUrl, + targets + ) + ).get(); + //when + notificationCommandService.create(notification); + List notifications = notificationQueryService.findNotifications( + receiverId, + null, + 10 + ).data(); + + //then + assertAll( + () -> assertThat(notifications.size()).isNotZero() + ); + } + @Test + @DisplayName("알림 상태 확인") + void present() throws Exception { + //given + Long receiverId = 1L; + Long actorId = 2L; + String title = "숨겨진 츄님이 좋아요를 눌렀어요!"; + String content = "지금 바로 확인해보세요."; + String profileUrl = "https://cdn.chooz.site/default_profile.png"; + List targets = List.of(Target.of(3L, TargetType.POST)); + String imageUrl = "https://cdn.chooz.site/images/20865b3c-4e2c-454a-81a1-9ca31bbaf77d"; + LocalDateTime eventAt = LocalDateTime.now(); + NotificationType notificationType = NotificationType.COMMENT_LIKED; + + Notification notification = Notification.create( + notificationType, + eventAt, + NotificationContent.of( + receiverId, + actorId, + title, + content, + profileUrl, + imageUrl, + targets + ) + ).get(); + //when + notificationCommandService.create(notification); + NotificationPresentResponse notificationPresentResponse = notificationQueryService.present(receiverId); + + //then + assertAll( + () -> assertThat(notificationPresentResponse.present()).isTrue() + ); + } +} diff --git a/src/test/java/com/chooz/notification/application/PostClosedNotificationListenerTest.java b/src/test/java/com/chooz/notification/application/PostClosedNotificationListenerTest.java new file mode 100644 index 00000000..ece5368a --- /dev/null +++ b/src/test/java/com/chooz/notification/application/PostClosedNotificationListenerTest.java @@ -0,0 +1,185 @@ +package com.chooz.notification.application; + +import com.chooz.notification.application.web.dto.NotificationDto; +import com.chooz.notification.domain.NotificationQueryRepository; +import com.chooz.notification.domain.TargetType; +import com.chooz.post.application.DateCloseScheduler; +import com.chooz.post.application.PostCommandService; +import com.chooz.post.application.PostVotedEventListener; +import com.chooz.post.domain.CloseType; +import com.chooz.post.domain.Post; +import com.chooz.post.domain.PostRepository; +import com.chooz.support.IntegrationTest; +import com.chooz.support.fixture.PostFixture; +import com.chooz.support.fixture.UserFixture; +import com.chooz.support.fixture.VoteFixture; +import com.chooz.user.domain.User; +import com.chooz.user.domain.UserRepository; +import com.chooz.vote.application.VotedEvent; +import com.chooz.vote.domain.VoteRepository; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.data.domain.PageRequest; +import org.springframework.test.context.transaction.TestTransaction; + +import java.util.ArrayList; +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertAll; + +class PostClosedNotificationListenerTest extends IntegrationTest { + + @Autowired + UserRepository userRepository; + + @Autowired + PostRepository postRepository; + + @Autowired + VoteRepository voteRepository; + + @Autowired + NotificationQueryRepository notificationQueryRepository; + + @Autowired + PostVotedEventListener postVotedEventListener; + + @Autowired + DateCloseScheduler dateCloseScheduler; + + @Autowired + PostCommandService postCommandService; + + @Test + @DisplayName("투표 마감 알림(참여자 수 마감)") + void onPostClosedByVoter() throws Exception { + //given + User user1 = userRepository.save(UserFixture.createDefaultUser()); + Post post = postRepository.save(PostFixture.createPostBuilder() + .userId(user1.getId()) + .closeOption( + PostFixture.createCloseOptionBuilder() + .closeType(CloseType.VOTER) + .maxVoterCount(5) + .build()) + .build()); + + //when + int voterCount = 5; + List users = new ArrayList<>(); + for (int i = 0; i < voterCount; i++) { + User user = userRepository.save(UserFixture.createDefaultUser()); + users.add(user); + voteRepository.save(VoteFixture.createDefaultVote(user.getId(), post.getId(), post.getPollChoices().get(0).getId())); + } + postVotedEventListener.handle(new VotedEvent(post.getId(), List.of(post.getPollChoices().get(0).getId()), user1.getId())); + TestTransaction.flagForCommit(); + TestTransaction.end(); + + //then + NotificationDto notification = notificationQueryRepository.findNotifications( + users.get(0).getId(), + null, + PageRequest.ofSize(10) + ).getContent().getFirst(); + + assertAll( + () -> assertThat(notification.notificationRowDto().title()).contains("당신이 참여한"), + () -> assertThat(notification.notificationRowDto().content()).contains("확인해보세요."), + () -> assertThat(notification.notificationRowDto().profileUrl()).isEqualTo(user1.getProfileUrl()), + () -> assertThat(notification.targets()) + .hasSize(1) + .anySatisfy(target -> { + assertThat(target.type()).isEqualTo(TargetType.POST); + } + ), + () -> assertThat(notification.notificationRowDto().imageUrl()).isEqualTo(post.getImageUrl()), + () -> assertThat(notification.notificationRowDto().isRead()).isEqualTo(false) + ); + } + @Test + @DisplayName("투표 마감 알림(시간 마감)") + void onMyPostClosedByDate() throws Exception { + // given + User user = userRepository.save(UserFixture.createDefaultUser()); + Post post = postRepository.save(PostFixture.createPostBuilder() + .userId(user.getId()) + .closeOption(PostFixture.createCloseOptionOverDate()) + .build()); + + // when + List users = new ArrayList<>(); + for (int i = 0; i < 5; i++) { + User voteUser = userRepository.save(UserFixture.createDefaultUser()); + users.add(voteUser); + voteRepository.save(VoteFixture.createDefaultVote(voteUser.getId(), post.getId(), post.getPollChoices().get(0).getId())); + } + dateCloseScheduler.closePostsByDate(); + + TestTransaction.flagForCommit(); + TestTransaction.end(); + + //then + NotificationDto notification = notificationQueryRepository.findNotifications( + users.get(0).getId(), + null, + PageRequest.ofSize(10) + ).getContent().getFirst(); + + assertAll( + () -> assertThat(notification.notificationRowDto().title()).contains("당신이 참여한"), + () -> assertThat(notification.notificationRowDto().content()).contains("확인해보세요."), + () -> assertThat(notification.notificationRowDto().profileUrl()).isEqualTo(user.getProfileUrl()), + () -> assertThat(notification.targets()) + .hasSize(1) + .anySatisfy(target -> { + assertThat(target.type()).isEqualTo(TargetType.POST); + } + ), + () -> assertThat(notification.notificationRowDto().imageUrl()).isEqualTo(post.getImageUrl()), + () -> assertThat(notification.notificationRowDto().isRead()).isEqualTo(false) + ); + } + @Test + @DisplayName("투표 마감 알림(직접 마감)") + void onMyPostClosedBySelf() throws Exception { + // given + User user = userRepository.save(UserFixture.createDefaultUser()); + Post post = postRepository.save(PostFixture.createDefaultPost(user.getId())); + + // when + List users = new ArrayList<>(); + for (int i = 0; i < 5; i++) { + User voteUser = userRepository.save(UserFixture.createDefaultUser()); + users.add(voteUser); + voteRepository.save(VoteFixture.createDefaultVote(voteUser.getId(), post.getId(), post.getPollChoices().get(0).getId())); + } + postCommandService.close(user.getId(), post.getId()); + + TestTransaction.flagForCommit(); + TestTransaction.end(); + + //then + NotificationDto notification = notificationQueryRepository.findNotifications( + users.get(0).getId(), + null, + PageRequest.ofSize(10) + ).getContent().getFirst(); + + assertAll( + () -> assertThat(notification.notificationRowDto().title()).contains("당신이 참여한"), + () -> assertThat(notification.notificationRowDto().content()).contains("확인해보세요."), + () -> assertThat(notification.notificationRowDto().profileUrl()).isEqualTo(user.getProfileUrl()), + () -> assertThat(notification.targets()) + .hasSize(1) + .anySatisfy(target -> { + assertThat(target.type()).isEqualTo(TargetType.POST); + } + ), + () -> assertThat(notification.notificationRowDto().imageUrl()).isEqualTo(post.getImageUrl()), + () -> assertThat(notification.notificationRowDto().isRead()).isEqualTo(false) + ); + } +} diff --git a/src/test/java/com/chooz/notification/application/VotedNotificationListenerTest.java b/src/test/java/com/chooz/notification/application/VotedNotificationListenerTest.java new file mode 100644 index 00000000..b55fa3de --- /dev/null +++ b/src/test/java/com/chooz/notification/application/VotedNotificationListenerTest.java @@ -0,0 +1,78 @@ +package com.chooz.notification.application; + +import com.chooz.notification.application.web.dto.NotificationDto; +import com.chooz.notification.domain.NotificationQueryRepository; +import com.chooz.notification.domain.TargetType; +import com.chooz.post.domain.PollChoice; +import com.chooz.post.domain.Post; +import com.chooz.post.domain.PostRepository; +import com.chooz.support.IntegrationTest; +import com.chooz.support.fixture.PostFixture; +import com.chooz.support.fixture.UserFixture; +import com.chooz.user.domain.User; +import com.chooz.user.domain.UserRepository; +import com.chooz.vote.application.VoteService; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.data.domain.PageRequest; +import org.springframework.test.context.transaction.TestTransaction; + +import java.util.stream.Collectors; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertAll; + +class VotedNotificationListenerTest extends IntegrationTest { + + @Autowired + UserRepository userRepository; + + @Autowired + PostRepository postRepository; + + @Autowired + VoteService voteService; + + @Autowired + NotificationQueryRepository notificationQueryRepository; + + @Test + @DisplayName("투표참여 알림") + void onVoted() throws Exception { + //given + User receiver = userRepository.save(UserFixture.createDefaultUser()); + User actor = userRepository.save(UserFixture.createDefaultUser()); + Post post = postRepository.save(PostFixture.createPostBuilder().userId(receiver.getId()).build()); + + //when + voteService.vote( + actor.getId(), + post.getId(), + post.getPollChoices().stream().map(PollChoice::getId).limit(1).collect(Collectors.toList())); + + TestTransaction.flagForCommit(); + TestTransaction.end(); + + //then + NotificationDto notification = notificationQueryRepository.findNotifications( + receiver.getId(), + null, + PageRequest.ofSize(10) + ).getContent().getFirst(); + + assertAll( + () -> assertThat(notification.notificationRowDto().title()).contains("투표에 참여했어요!"), + () -> assertThat(notification.notificationRowDto().content()).contains("확인해보세요."), + () -> assertThat(notification.notificationRowDto().profileUrl()).isEqualTo(actor.getProfileUrl()), + () -> assertThat(notification.targets()) + .hasSize(1) + .anySatisfy(target -> { + assertThat(target.type()).isEqualTo(TargetType.POST); + } + ), + () -> assertThat(notification.notificationRowDto().imageUrl()).isEqualTo(post.getImageUrl()), + () -> assertThat(notification.notificationRowDto().isRead()).isEqualTo(false) + ); + } +} diff --git a/src/test/java/com/chooz/notification/domain/NotificationTest.java b/src/test/java/com/chooz/notification/domain/NotificationTest.java index 43867d75..2736b23c 100644 --- a/src/test/java/com/chooz/notification/domain/NotificationTest.java +++ b/src/test/java/com/chooz/notification/domain/NotificationTest.java @@ -1,9 +1,11 @@ package com.chooz.notification.domain; +import com.chooz.notification.application.dto.NotificationContent; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; import java.time.LocalDateTime; +import java.util.List; import static org.assertj.core.api.Assertions.assertThat; import static org.junit.jupiter.api.Assertions.assertAll; @@ -15,37 +17,43 @@ class NotificationTest { void create() throws Exception { //given Long receiverId = 1L; - String receiverNickname = "공개된 츄"; Long actorId = 2L; - String actorNickname = "숨겨진 츄"; - String actorProfileUrl = "https://cdn.chooz.site/default_profile.png"; - Long targetId = 3L; - TargetType targetType = TargetType.COMMENT; - String targetImageUrl = "https://cdn.chooz.site/default_target.png"; + String title = "숨겨진 츄님이 좋아요를 눌렀어요!"; + String content = "지금 바로 확인해보세요."; + String profileUrl = "https://cdn.chooz.site/default_profile.png"; + List targets = List.of(Target.of(3L, TargetType.POST)); + String imageUrl = "https://cdn.chooz.site/images/20865b3c-4e2c-454a-81a1-9ca31bbaf77d"; LocalDateTime eventAt = LocalDateTime.now(); - //when + NotificationType notificationType = NotificationType.COMMENT_LIKED; + Notification notification = Notification.create( - receiverId, - receiverNickname, - actorId, - actorNickname, - actorProfileUrl, - targetId, - targetType, - targetImageUrl, - eventAt + notificationType, + eventAt, + NotificationContent.of( + receiverId, + actorId, + title, + content, + profileUrl, + imageUrl, + targets + ) ).get(); - //then + //when then assertAll( - () -> assertThat(notification.getReceiver().getId()).isEqualTo(receiverId), - () -> assertThat(notification.getReceiver().getNickname()).isEqualTo(receiverNickname), - () -> assertThat(notification.getActor().getId()).isEqualTo(actorId), - () -> assertThat(notification.getActor().getNickname()).isEqualTo(actorNickname), - () -> assertThat(notification.getActor().getProfileUrl()).isEqualTo(actorProfileUrl), - () -> assertThat(notification.getTarget().getId()).isEqualTo(targetId), - () -> assertThat(notification.getTarget().getType()).isEqualTo(targetType), - () -> assertThat(notification.getTarget().getImageUrl()).isEqualTo(targetImageUrl), + () -> assertThat(notification.getReceiverId()).isEqualTo(receiverId), + () -> assertThat(notification.getProfileUrl()).isEqualTo(profileUrl), + () -> assertThat(notification.getTitle()).isEqualTo(title), + () -> assertThat(notification.getContent()).isEqualTo(content), + () -> assertThat(notification.getTargets()) + .allSatisfy(target -> { + assertThat(target.getId()).isEqualTo(3L); + assertThat(target.getType()).isEqualTo(TargetType.POST); + } + ), + () -> assertThat(notification.getImageUrl()).isEqualTo(imageUrl), + () -> assertThat(notification.isRead()).isEqualTo(false), () -> assertThat(notification.getEventAt()).isEqualTo(eventAt) ); } @@ -54,27 +62,30 @@ void create() throws Exception { void markRead() throws Exception { //given Long receiverId = 1L; - String receiverNickname = "공개된 츄"; Long actorId = 2L; - String actorNickname = "숨겨진 츄"; - String actorProfileUrl = "https://cdn.chooz.site/default_profile.png"; - Long targetId = 3L; - TargetType targetType = TargetType.COMMENT; - String targetImageUrl = "https://cdn.chooz.site/default_target.png"; + String title = "숨겨진 츄님이 좋아요를 눌렀어요!"; + String content = "지금 바로 확인해보세요."; + String profileUrl = "https://cdn.chooz.site/default_profile.png"; + List targets = List.of(Target.of(3L, TargetType.POST)); + String imageUrl = "https://cdn.chooz.site/images/20865b3c-4e2c-454a-81a1-9ca31bbaf77d"; LocalDateTime eventAt = LocalDateTime.now(); - //when + NotificationType notificationType = NotificationType.COMMENT_LIKED; + Notification notification = Notification.create( - receiverId, - receiverNickname, - actorId, - actorNickname, - actorProfileUrl, - targetId, - targetType, - targetImageUrl, - eventAt + notificationType, + eventAt, + NotificationContent.of( + receiverId, + actorId, + title, + content, + profileUrl, + imageUrl, + targets + ) ).get(); + //when notification.markRead(); //then diff --git a/src/test/java/com/chooz/notification/presentation/NotificationControllerTest.java b/src/test/java/com/chooz/notification/presentation/NotificationControllerTest.java index b25d525c..fc562d8f 100644 --- a/src/test/java/com/chooz/notification/presentation/NotificationControllerTest.java +++ b/src/test/java/com/chooz/notification/presentation/NotificationControllerTest.java @@ -1,10 +1,10 @@ package com.chooz.notification.presentation; import com.chooz.common.dto.CursorBasePaginatedResponse; -import com.chooz.notification.domain.Actor; -import com.chooz.notification.domain.Receiver; +import com.chooz.notification.domain.NotificationType; import com.chooz.notification.domain.Target; import com.chooz.notification.domain.TargetType; +import com.chooz.notification.presentation.dto.NotificationPresentResponse; import com.chooz.notification.presentation.dto.NotificationResponse; import com.chooz.support.RestDocsTest; import com.chooz.support.WithMockUserInfo; @@ -12,14 +12,21 @@ import org.junit.jupiter.api.Test; import org.springframework.http.HttpHeaders; import org.springframework.restdocs.payload.JsonFieldType; + import java.time.LocalDateTime; import java.util.List; +import static org.mockito.ArgumentMatchers.any; import static org.mockito.BDDMockito.given; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; import static org.springframework.restdocs.headers.HeaderDocumentation.requestHeaders; import static org.springframework.restdocs.mockmvc.RestDocumentationRequestBuilders.get; +import static org.springframework.restdocs.mockmvc.RestDocumentationRequestBuilders.patch; import static org.springframework.restdocs.payload.PayloadDocumentation.fieldWithPath; import static org.springframework.restdocs.payload.PayloadDocumentation.responseFields; +import static org.springframework.restdocs.request.RequestDocumentation.parameterWithName; +import static org.springframework.restdocs.request.RequestDocumentation.pathParameters; import static org.springframework.restdocs.request.RequestDocumentation.queryParameters; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.content; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; @@ -37,16 +44,18 @@ void findNotifications() throws Exception { List.of( new NotificationResponse( 1L, - 2L, - new Receiver(1L, "숨겨진 츄"), - new Actor(2L, "공개된 츄", "https://cdn.chooz.site/default_profile.png"), - new Target(3L, TargetType.COMMENT, "https://cdn.chooz.site/thumbnail.png"), + NotificationType.COMMENT_LIKED, + "https://cdn.chooz.site/default_profile.png", + "숨겨진 츄님이 좋아요를 눌렀어요!", + "지금 바로 확인해보세요.", + "https://cdn.chooz.site/images/20865b3c-4e2c-454a-81a1-9ca31bbaf77d", + List.of(Target.of(1L, TargetType.POST)), false, LocalDateTime.now() ) ) ); - given(notificationQueryService.findNotifications(1L, null, 10)).willReturn(response); + given(notificationService.findNotifications(1L, null, 10)).willReturn(response); //when then mockMvc.perform(get("/notifications") @@ -65,24 +74,20 @@ void findNotifications() throws Exception { .type(JsonFieldType.ARRAY).description("알림 데이터"), fieldWithPath("data[].id") .type(JsonFieldType.NUMBER).description("알림 ID"), - fieldWithPath("data[].postId") - .type(JsonFieldType.NUMBER).description("게시물 ID"), - fieldWithPath("data[].receiver.id") - .type(JsonFieldType.NUMBER).description("receiver ID"), - fieldWithPath("data[].receiver.nickname") - .type(JsonFieldType.STRING).description("receiver 닉네임"), - fieldWithPath("data[].actor.id") - .type(JsonFieldType.NUMBER).description("actor ID"), - fieldWithPath("data[].actor.nickname") - .type(JsonFieldType.STRING).description("actor 닉네임"), - fieldWithPath("data[].actor.profileUrl") - .type(JsonFieldType.STRING).description("actor 프로필 이미지 url"), - fieldWithPath("data[].target.id") + fieldWithPath("data[].notificationType") + .type(JsonFieldType.STRING).description("알림 유형"), + fieldWithPath("data[].title") + .type(JsonFieldType.STRING).description("알림 내용(제목)"), + fieldWithPath("data[].content") + .type(JsonFieldType.STRING).description("알림 내용(내용)"), + fieldWithPath("data[].profileUrl") + .type(JsonFieldType.STRING).description("알림 프로필 이미지 url"), + fieldWithPath("data[].imageUrl") + .type(JsonFieldType.STRING).description("알림 썸네일 이미지 url"), + fieldWithPath("data[].targets[].id") .type(JsonFieldType.NUMBER).description("알림 타겟 ID"), - fieldWithPath("data[].target.type") + fieldWithPath("data[].targets[].type") .type(JsonFieldType.STRING).description("알림 타겟 유형"), - fieldWithPath("data[].target.imageUrl") - .type(JsonFieldType.STRING).description("알림 타겟 썸네일 이미지 url"), fieldWithPath("data[].isRead") .type(JsonFieldType.BOOLEAN).description("읽음 여부"), fieldWithPath("data[].eventAt") @@ -90,4 +95,35 @@ void findNotifications() throws Exception { ) )); } + @Test + @WithMockUserInfo + @DisplayName("알림 읽기") + void markRead() throws Exception { + //when then + mockMvc.perform(patch("/notifications/{notificationId}", 1) + .header(HttpHeaders.AUTHORIZATION, "Bearer token")) + .andExpect(status().isOk()) + .andDo(restDocs.document( + requestHeaders(authorizationHeader()), + pathParameters(parameterWithName("notificationId").description("알림 ID")) + )); + verify(notificationService, times(1)).markRead(any()); + } + @Test + @WithMockUserInfo + @DisplayName("알림 상태 확인") + void present() throws Exception { + NotificationPresentResponse response = NotificationPresentResponse.of(true); + given(notificationService.present(1L)).willReturn(response); + //when then + mockMvc.perform(get("/notifications/present") + .header(HttpHeaders.AUTHORIZATION, "Bearer token")) + .andExpect(status().isOk()) + .andExpect(content().json(objectMapper.writeValueAsString(response))) + .andDo(restDocs.document( + requestHeaders(authorizationHeader()), + responseFields(fieldWithPath("present").type(JsonFieldType.BOOLEAN).description("알림 상태 여부")) + )); + verify(notificationService, times(1)).present(any()); + } } diff --git a/src/test/java/com/chooz/support/WebUnitTest.java b/src/test/java/com/chooz/support/WebUnitTest.java index d8c329ce..67fe8194 100644 --- a/src/test/java/com/chooz/support/WebUnitTest.java +++ b/src/test/java/com/chooz/support/WebUnitTest.java @@ -1,7 +1,9 @@ package com.chooz.support; import com.chooz.image.application.ImageService; -import com.chooz.notification.application.NotificationQueryService; +import com.chooz.notification.application.NotificationService; +import com.chooz.notification.application.service.NotificationCommandService; +import com.chooz.notification.application.service.NotificationQueryService; import com.fasterxml.jackson.databind.ObjectMapper; import com.chooz.auth.application.AuthService; import com.chooz.auth.presentation.RefreshTokenCookieGenerator; @@ -55,5 +57,5 @@ public abstract class WebUnitTest { protected DiscordMessageSender discordMessageSender; @MockitoBean - protected NotificationQueryService notificationQueryService; + protected NotificationService notificationService; }