diff --git a/src/docs/asciidoc/index.adoc b/src/docs/asciidoc/index.adoc index b3be91b..4c59269 100644 --- a/src/docs/asciidoc/index.adoc +++ b/src/docs/asciidoc/index.adoc @@ -109,4 +109,6 @@ include::votes.adoc[] include::comments.adoc[] -include::comment-likes.adoc[] \ No newline at end of file +include::comment-likes.adoc[] + +include::notifications.adoc[] \ No newline at end of file diff --git a/src/docs/asciidoc/notifications.adoc b/src/docs/asciidoc/notifications.adoc new file mode 100644 index 0000000..dd84ba9 --- /dev/null +++ b/src/docs/asciidoc/notifications.adoc @@ -0,0 +1,7 @@ +[[알림-API]] +== 알림 API + +[[알림-조회]] +=== `GET` 알림 조회 + +operation::comment-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 diff --git a/src/main/java/com/chooz/commentLike/application/CommentLikeCommandService.java b/src/main/java/com/chooz/commentLike/application/CommentLikeCommandService.java index f356d69..0c88522 100644 --- a/src/main/java/com/chooz/commentLike/application/CommentLikeCommandService.java +++ b/src/main/java/com/chooz/commentLike/application/CommentLikeCommandService.java @@ -3,13 +3,17 @@ import com.chooz.commentLike.domain.CommentLike; import com.chooz.commentLike.domain.CommentLikeRepository; import com.chooz.commentLike.presentation.dto.CommentLikeIdResponse; +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 lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; +import java.time.LocalDateTime; + @Service @Transactional(readOnly = true) @RequiredArgsConstructor @@ -17,14 +21,22 @@ public class CommentLikeCommandService { private final CommentLikeRepository commentLikeRepository; + private final EventPublisher eventPublisher; public CommentLikeIdResponse createCommentLike(Long commentId, Long userId) { if(commentLikeRepository.existsByCommentIdAndUserId(commentId, userId)){ throw new BadRequestException(ErrorCode.COMMENT_LIKE_NOT_FOUND); } + CommentLike commentLike = commentLikeRepository.save(CommentLike.create(commentId, userId)); + eventPublisher.publish(new CommentLikedEvent( + commentId, + commentLike.getId(), + userId, + LocalDateTime.now() + )); return new CommentLikeIdResponse( - commentLikeRepository.save(CommentLike.create(commentId, userId)).getId(), + commentLike.getId(), commentLikeRepository.countByCommentId(commentId) ); } diff --git a/src/main/java/com/chooz/notification/application/CommentLikeNotificationListener.java b/src/main/java/com/chooz/notification/application/CommentLikeNotificationListener.java new file mode 100644 index 0000000..a940cbf --- /dev/null +++ b/src/main/java/com/chooz/notification/application/CommentLikeNotificationListener.java @@ -0,0 +1,34 @@ +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 new file mode 100644 index 0000000..6818ade --- /dev/null +++ b/src/main/java/com/chooz/notification/application/NotificationCommandService.java @@ -0,0 +1,20 @@ +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 new file mode 100644 index 0000000..ab49dff --- /dev/null +++ b/src/main/java/com/chooz/notification/application/NotificationContentAssembler.java @@ -0,0 +1,49 @@ +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.TargetPostDto; +import com.chooz.notification.application.dto.TargetUserDto; +import com.chooz.notification.domain.NotificationQueryRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; + +@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)); + + return new CommentLikedContent( + targetUserDto.nickname(), + targetUserDto.profileUrl(), + targetPostDto.imageUrl(), + commentAuthorDto.id(), + commentAuthorDto.nickname() + ); + } + +// 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); +// } +} diff --git a/src/main/java/com/chooz/notification/application/NotificationQueryService.java b/src/main/java/com/chooz/notification/application/NotificationQueryService.java new file mode 100644 index 0000000..e4c0b70 --- /dev/null +++ b/src/main/java/com/chooz/notification/application/NotificationQueryService.java @@ -0,0 +1,29 @@ +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/dto/CommentLikedContent.java b/src/main/java/com/chooz/notification/application/dto/CommentLikedContent.java new file mode 100644 index 0000000..082009e --- /dev/null +++ b/src/main/java/com/chooz/notification/application/dto/CommentLikedContent.java @@ -0,0 +1,22 @@ +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 new file mode 100644 index 0000000..f401520 --- /dev/null +++ b/src/main/java/com/chooz/notification/application/dto/NotificationContent.java @@ -0,0 +1,13 @@ +package com.chooz.notification.application.dto; + +import lombok.Getter; +import lombok.RequiredArgsConstructor; + +@Getter +@RequiredArgsConstructor +public abstract class NotificationContent { + private final String actorName; + private final String actorProfileImageUrl; + private final String targetThumbnailUrl; + +} diff --git a/src/main/java/com/chooz/notification/application/dto/NotificationDto.java b/src/main/java/com/chooz/notification/application/dto/NotificationDto.java new file mode 100644 index 0000000..b0ab162 --- /dev/null +++ b/src/main/java/com/chooz/notification/application/dto/NotificationDto.java @@ -0,0 +1,23 @@ +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/TargetPostDto.java b/src/main/java/com/chooz/notification/application/dto/TargetPostDto.java new file mode 100644 index 0000000..1728347 --- /dev/null +++ b/src/main/java/com/chooz/notification/application/dto/TargetPostDto.java @@ -0,0 +1,10 @@ +package com.chooz.notification.application.dto; + + +import com.querydsl.core.annotations.QueryProjection; + +@QueryProjection +public record TargetPostDto( + Long id, + String imageUrl +) {} diff --git a/src/main/java/com/chooz/notification/application/dto/TargetUserDto.java b/src/main/java/com/chooz/notification/application/dto/TargetUserDto.java new file mode 100644 index 0000000..285c4c6 --- /dev/null +++ b/src/main/java/com/chooz/notification/application/dto/TargetUserDto.java @@ -0,0 +1,10 @@ +package com.chooz.notification.application.dto; + +import com.querydsl.core.annotations.QueryProjection; + +@QueryProjection +public record TargetUserDto( + Long id, + String nickname, + String profileUrl +) {} diff --git a/src/main/java/com/chooz/notification/domain/Actor.java b/src/main/java/com/chooz/notification/domain/Actor.java new file mode 100644 index 0000000..746ebc9 --- /dev/null +++ b/src/main/java/com/chooz/notification/domain/Actor.java @@ -0,0 +1,25 @@ +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 new file mode 100644 index 0000000..b9820f3 --- /dev/null +++ b/src/main/java/com/chooz/notification/domain/Notification.java @@ -0,0 +1,78 @@ +package com.chooz.notification.domain; + +import com.chooz.common.domain.BaseEntity; +import jakarta.persistence.Column; +import jakarta.persistence.Embedded; +import jakarta.persistence.Entity; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.Table; +import lombok.AccessLevel; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +import java.time.LocalDateTime; +import java.util.Optional; + +@Getter +@Entity +@Table(name = "notifications") +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@AllArgsConstructor(access = AccessLevel.PRIVATE) +@Builder +public class Notification extends BaseEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Embedded + private Receiver receiver; + + @Embedded + private Actor actor; + + @Embedded + private Target target; + + @Column(name = "is_read", nullable = false) + private boolean isRead; + + @Column(name = "event_at", nullable = false) + 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 + ) { + if (checkMine(actorId, receiverId)) { + 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)) + .isRead(false) + .eventAt(eventAt) + .build()); + } + private static boolean checkMine(Long actorId, Long receiverId) { + return actorId != null && actorId.equals(receiverId); + } + + public void markRead() { + if (!isRead) { + this.isRead = true; + } + } +} diff --git a/src/main/java/com/chooz/notification/domain/NotificationQueryRepository.java b/src/main/java/com/chooz/notification/domain/NotificationQueryRepository.java new file mode 100644 index 0000000..c009150 --- /dev/null +++ b/src/main/java/com/chooz/notification/domain/NotificationQueryRepository.java @@ -0,0 +1,16 @@ +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 org.springframework.data.domain.Pageable; +import org.springframework.data.domain.Slice; + +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); +} diff --git a/src/main/java/com/chooz/notification/domain/NotificationRepository.java b/src/main/java/com/chooz/notification/domain/NotificationRepository.java new file mode 100644 index 0000000..859367f --- /dev/null +++ b/src/main/java/com/chooz/notification/domain/NotificationRepository.java @@ -0,0 +1,5 @@ +package com.chooz.notification.domain; + +public interface NotificationRepository { + Notification save(Notification notification); +} diff --git a/src/main/java/com/chooz/notification/domain/Receiver.java b/src/main/java/com/chooz/notification/domain/Receiver.java new file mode 100644 index 0000000..6a02e8d --- /dev/null +++ b/src/main/java/com/chooz/notification/domain/Receiver.java @@ -0,0 +1,23 @@ +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 Receiver { + @Column(name = "receiver_id", nullable = false) + private Long id; + + @Column(name = "receiver_nickname", nullable = false) + private String nickname; + +} diff --git a/src/main/java/com/chooz/notification/domain/Target.java b/src/main/java/com/chooz/notification/domain/Target.java new file mode 100644 index 0000000..b4aae82 --- /dev/null +++ b/src/main/java/com/chooz/notification/domain/Target.java @@ -0,0 +1,26 @@ +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 Target { + @Column(name = "target_id", nullable = false) + private Long id; + + @Enumerated(EnumType.STRING) + @Column(name = "target_type", nullable = false) + private TargetType type; + + @Column(name = "target_image_url", nullable = false) + private String imageUrl; +} diff --git a/src/main/java/com/chooz/notification/domain/TargetType.java b/src/main/java/com/chooz/notification/domain/TargetType.java new file mode 100644 index 0000000..87f5a68 --- /dev/null +++ b/src/main/java/com/chooz/notification/domain/TargetType.java @@ -0,0 +1,7 @@ +package com.chooz.notification.domain; + +public enum TargetType { + POST, + COMMENT, + VOTE, +} diff --git a/src/main/java/com/chooz/notification/domain/event/CommentLikedEvent.java b/src/main/java/com/chooz/notification/domain/event/CommentLikedEvent.java new file mode 100644 index 0000000..857de50 --- /dev/null +++ b/src/main/java/com/chooz/notification/domain/event/CommentLikedEvent.java @@ -0,0 +1,11 @@ +package com.chooz.notification.domain.event; + +import java.time.LocalDateTime; + +public record CommentLikedEvent( + Long commentId, + Long commentLikeId, + Long likerId, + LocalDateTime eventAt +) {} + diff --git a/src/main/java/com/chooz/notification/persistence/NotificationJpaRepository.java b/src/main/java/com/chooz/notification/persistence/NotificationJpaRepository.java new file mode 100644 index 0000000..f30cdbe --- /dev/null +++ b/src/main/java/com/chooz/notification/persistence/NotificationJpaRepository.java @@ -0,0 +1,27 @@ +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 + ); +} diff --git a/src/main/java/com/chooz/notification/persistence/NotificationQueryDslRepository.java b/src/main/java/com/chooz/notification/persistence/NotificationQueryDslRepository.java new file mode 100644 index 0000000..11c02df --- /dev/null +++ b/src/main/java/com/chooz/notification/persistence/NotificationQueryDslRepository.java @@ -0,0 +1,94 @@ +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.querydsl.jpa.impl.JPAQueryFactory; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Pageable; +import org.springframework.data.domain.Slice; +import org.springframework.data.domain.SliceImpl; +import org.springframework.stereotype.Repository; + +import java.util.List; +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; + +@Repository +@RequiredArgsConstructor +public class NotificationQueryDslRepository { + + private final JPAQueryFactory queryFactory; + + public Slice findNotifications(Long userId, Long cursor, Pageable pageable) { + List notifications = queryFactory + .select(new QNotificationDto( + 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.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), + cursor != null ? notification.id.lt(cursor) : null + ) + .orderBy(notification.id.desc()) + .limit(pageable.getPageSize() + 1) + .fetch(); + + boolean hasNext = notifications.size() > pageable.getPageSize(); + if (hasNext) notifications.removeLast(); + return new SliceImpl<>(notifications, pageable, hasNext); + } + + Optional getPost(Long commentId) { + return Optional.ofNullable( + queryFactory.select(new QTargetPostDto(post.id, post.imageUrl)) + .from(comment) + .join(post).on(post.id.eq(comment.postId)) + .where(comment.id.eq(commentId)) + .limit(1) + .fetchFirst()); + } + Optional getUserByCommentId(Long commentId) { + return Optional.ofNullable( + queryFactory.select(new QTargetUserDto(user.id, user.nickname, user.profileUrl)) + .from(comment) + .join(user).on(user.id.eq(comment.userId)) + .where(comment.id.eq(commentId)) + .limit(1) + .fetchFirst()); + } + Optional getUser(Long userId) { + return Optional.ofNullable( + queryFactory.select(new QTargetUserDto(user.id, user.nickname, user.profileUrl)) + .from(user) + .where(user.id.eq(userId)) + .limit(1) + .fetchFirst()); + } + +} diff --git a/src/main/java/com/chooz/notification/persistence/NotificationQueryRepositoryImpl.java b/src/main/java/com/chooz/notification/persistence/NotificationQueryRepositoryImpl.java new file mode 100644 index 0000000..736e881 --- /dev/null +++ b/src/main/java/com/chooz/notification/persistence/NotificationQueryRepositoryImpl.java @@ -0,0 +1,47 @@ +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.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.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); + } + + @Override + public Optional getUserByCommentId(Long commentId) { + return notificationQueryDslRepository.getUserByCommentId(commentId); + } + + @Override + public Optional getUser(Long userId) { + return notificationQueryDslRepository.getUser(userId); + } +} \ 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 new file mode 100644 index 0000000..fb55a13 --- /dev/null +++ b/src/main/java/com/chooz/notification/persistence/NotificationRepositoryImpl.java @@ -0,0 +1,20 @@ +package com.chooz.notification.persistence; + +import com.chooz.notification.domain.Notification; +import com.chooz.notification.domain.NotificationRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Repository; + + +@Repository +@RequiredArgsConstructor +public class NotificationRepositoryImpl implements NotificationRepository { + + private final NotificationJpaRepository notificationJpaRepository; + + @Override + public Notification save(Notification notification) { + return notificationJpaRepository.save(notification); + } + +} \ 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 new file mode 100644 index 0000000..7a37f65 --- /dev/null +++ b/src/main/java/com/chooz/notification/presentation/NotificationController.java @@ -0,0 +1,30 @@ +package com.chooz.notification.presentation; + +import com.chooz.auth.domain.UserInfo; +import com.chooz.common.dto.CursorBasePaginatedResponse; +import com.chooz.notification.application.NotificationQueryService; +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.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +@RestController +@RequiredArgsConstructor +@RequestMapping("/notifications") +public class NotificationController { + private final NotificationQueryService notificationQueryService; + + @GetMapping("") + public ResponseEntity> findNotifications( + @RequestParam(name = "cursor", required = false) @Min(0) Long cursor, + @RequestParam(name = "size", required = false, defaultValue = "10") @Min(1) int size, + @AuthenticationPrincipal UserInfo userInfo + ) { + return ResponseEntity.ok(notificationQueryService.findNotifications(userInfo.userId(), cursor, size)); + } +} diff --git a/src/main/java/com/chooz/notification/presentation/dto/NotificationResponse.java b/src/main/java/com/chooz/notification/presentation/dto/NotificationResponse.java new file mode 100644 index 0000000..64b7706 --- /dev/null +++ b/src/main/java/com/chooz/notification/presentation/dto/NotificationResponse.java @@ -0,0 +1,44 @@ +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.domain.Target; + +import java.time.LocalDateTime; + +public record NotificationResponse ( + Long id, + Long postId, + Receiver receiver, + Actor actor, + Target target, + 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() + ); + } + + @Override + public long getId() { return this.id; } +} diff --git a/src/test/java/com/chooz/notification/application/CommentLikeNotificationListenerTest.java b/src/test/java/com/chooz/notification/application/CommentLikeNotificationListenerTest.java new file mode 100644 index 0000000..52d5e47 --- /dev/null +++ b/src/test/java/com/chooz/notification/application/CommentLikeNotificationListenerTest.java @@ -0,0 +1,78 @@ +package com.chooz.notification.application; + +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.domain.NotificationQueryRepository; +import com.chooz.notification.domain.TargetType; +import com.chooz.post.domain.Post; +import com.chooz.post.domain.PostRepository; +import com.chooz.support.IntegrationTest; +import com.chooz.support.fixture.CommentFixture; +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 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.data.domain.Slice; +import org.springframework.test.context.transaction.TestTransaction; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertAll; + +class CommentLikeNotificationListenerTest extends IntegrationTest { + + @Autowired + UserRepository userRepository; + + @Autowired + PostRepository postRepository; + + @Autowired + CommentRepository commentRepository; + + @Autowired + NotificationQueryRepository notificationQueryRepository; + + @Autowired + CommentLikeService commentLikeService; + + @Test + @DisplayName("댓글좋아요 알림") + void onCommentLiked() 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()); + Comment comment = commentRepository.save(CommentFixture.createCommentBuilder() + .userId(receiver.getId()) + .postId(post.getId()) + .build()); + + //when + commentLikeService.createCommentLike(comment.getId(), actor.getId()); + TestTransaction.flagForCommit(); + TestTransaction.end(); + + //then + Slice notificationSlice = notificationQueryRepository.findNotifications( + receiver.getId(), + null, + PageRequest.ofSize(10) + ); + + 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()) + ); + } +} diff --git a/src/test/java/com/chooz/notification/domain/NotificationTest.java b/src/test/java/com/chooz/notification/domain/NotificationTest.java new file mode 100644 index 0000000..43867d7 --- /dev/null +++ b/src/test/java/com/chooz/notification/domain/NotificationTest.java @@ -0,0 +1,85 @@ +package com.chooz.notification.domain; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import java.time.LocalDateTime; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertAll; + +class NotificationTest { + + @Test + @DisplayName("알림 생성") + 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"; + LocalDateTime eventAt = LocalDateTime.now(); + //when + Notification notification = Notification.create( + receiverId, + receiverNickname, + actorId, + actorNickname, + actorProfileUrl, + targetId, + targetType, + targetImageUrl, + eventAt + ).get(); + + //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.getEventAt()).isEqualTo(eventAt) + ); + } + @Test + @DisplayName("알림 읽음 확인") + 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"; + LocalDateTime eventAt = LocalDateTime.now(); + //when + Notification notification = Notification.create( + receiverId, + receiverNickname, + actorId, + actorNickname, + actorProfileUrl, + targetId, + targetType, + targetImageUrl, + eventAt + ).get(); + + notification.markRead(); + + //then + assertAll( + () -> assertThat(notification.isRead()).isTrue() + ); + } +} diff --git a/src/test/java/com/chooz/notification/presentation/NotificationControllerTest.java b/src/test/java/com/chooz/notification/presentation/NotificationControllerTest.java new file mode 100644 index 0000000..b25d525 --- /dev/null +++ b/src/test/java/com/chooz/notification/presentation/NotificationControllerTest.java @@ -0,0 +1,93 @@ +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.Target; +import com.chooz.notification.domain.TargetType; +import com.chooz.notification.presentation.dto.NotificationResponse; +import com.chooz.support.RestDocsTest; +import com.chooz.support.WithMockUserInfo; +import org.junit.jupiter.api.DisplayName; +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.BDDMockito.given; +import static org.springframework.restdocs.headers.HeaderDocumentation.requestHeaders; +import static org.springframework.restdocs.mockmvc.RestDocumentationRequestBuilders.get; +import static org.springframework.restdocs.payload.PayloadDocumentation.fieldWithPath; +import static org.springframework.restdocs.payload.PayloadDocumentation.responseFields; +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; + +public class NotificationControllerTest extends RestDocsTest { + + @Test + @WithMockUserInfo + @DisplayName("알림 목록 조회") + void findNotifications() throws Exception { + //given + var response = new CursorBasePaginatedResponse<>( + 1L, + false, + 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"), + false, + LocalDateTime.now() + ) + ) + ); + given(notificationQueryService.findNotifications(1L, null, 10)).willReturn(response); + + //when then + mockMvc.perform(get("/notifications") + .header(HttpHeaders.AUTHORIZATION, "Bearer token")) + .andExpect(status().isOk()) + .andExpect(content().json(objectMapper.writeValueAsString(response))) + .andDo(restDocs.document( + requestHeaders(authorizationHeader()), + queryParameters(cursorQueryParams()), + responseFields( + fieldWithPath("nextCursor").type(JsonFieldType.NUMBER).optional() + .description("다음 조회 커서 값"), + fieldWithPath("hasNext") + .type(JsonFieldType.BOOLEAN).description("다음 페이지 존재 여부 (기본 값 10)"), + fieldWithPath("data[]") + .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") + .type(JsonFieldType.NUMBER).description("알림 타겟 ID"), + fieldWithPath("data[].target.type") + .type(JsonFieldType.STRING).description("알림 타겟 유형"), + fieldWithPath("data[].target.imageUrl") + .type(JsonFieldType.STRING).description("알림 타겟 썸네일 이미지 url"), + fieldWithPath("data[].isRead") + .type(JsonFieldType.BOOLEAN).description("읽음 여부"), + fieldWithPath("data[].eventAt") + .type(JsonFieldType.STRING).description("이벤트 발생 시간") + ) + )); + } +} diff --git a/src/test/java/com/chooz/support/WebUnitTest.java b/src/test/java/com/chooz/support/WebUnitTest.java index 1717393..d8c329c 100644 --- a/src/test/java/com/chooz/support/WebUnitTest.java +++ b/src/test/java/com/chooz/support/WebUnitTest.java @@ -1,6 +1,7 @@ package com.chooz.support; import com.chooz.image.application.ImageService; +import com.chooz.notification.application.NotificationQueryService; import com.fasterxml.jackson.databind.ObjectMapper; import com.chooz.auth.application.AuthService; import com.chooz.auth.presentation.RefreshTokenCookieGenerator; @@ -10,7 +11,6 @@ import com.chooz.post.application.PostService; import com.chooz.user.application.UserService; import com.chooz.vote.application.VoteService; -import com.fasterxml.jackson.databind.ObjectMapper; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest; import org.springframework.context.annotation.Import; @@ -53,4 +53,7 @@ public abstract class WebUnitTest { @MockitoBean protected DiscordMessageSender discordMessageSender; + + @MockitoBean + protected NotificationQueryService notificationQueryService; }