Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

알림 기능 구현 (이벤트 발행 및 구독, 알림 조회, 알림 삭제, 알림 업데이트) #625

Merged
merged 42 commits into from
Oct 10, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
42 commits
Select commit Hold shift + click to select a range
6adff16
feat: 알람 내 값객체 구현 (제목, 내용, 연관 식별자값, 읽기 여부, 알람 타입, 알람 메시지 모음)
hyena0608 Oct 6, 2023
dbdaac8
feat: 알람 엔티티 구현
hyena0608 Oct 6, 2023
6008273
feat: 알람 예외 클래스 구현
hyena0608 Oct 6, 2023
e86b31d
feat: 알람 목록 조회 레포지터리 기능 구현
hyena0608 Oct 6, 2023
8387f3c
feat: 알람 소프트 딜리트, 읽기 여부 true 업데이트 레포지터리 기능 구현
hyena0608 Oct 6, 2023
9b273e1
feat: 알람 소프트 딜리트, 읽기 여부 true 업데이트 서비스 기능 구현
hyena0608 Oct 6, 2023
a4e5fcb
feat: 알람 목록 조회 서비스 기능 구현
hyena0608 Oct 6, 2023
c3c91e3
feat: 알람 읽음 여부 기록 및 삭제 API 구현
hyena0608 Oct 6, 2023
9dbf347
feat: 사용자 알람 목록 조회 API 구현
hyena0608 Oct 6, 2023
594d028
feat: 러너 게시글 식별자값으로 러너 게시글과 서포터, 사용자 조회 레포지터리 기능 구현
hyena0608 Oct 6, 2023
613ded3
feat: 알람 이벤트 (러너 게시글 리뷰 완료, 러너 게시글 서포터 할당, 러너 게시글 서포터 지원) 리스너 구현
hyena0608 Oct 6, 2023
7cd4773
feat: 이벤트 (러너 게시글 리뷰 완료, 러너 게시글 서포터 할당, 러너 게시글 서포터 지원) 발행 구현
hyena0608 Oct 6, 2023
395ec4b
Merge remote-tracking branch 'origin/dev/BE' into dev/BE
hyena0608 Oct 7, 2023
f70698e
Merge remote-tracking branch 'origin/dev/BE' into dev/BE
hyena0608 Oct 7, 2023
cf176df
feat: 알람 생성 시간 초단위 삭제를 위한 BaseEntity 상속 클래스 구현
hyena0608 Oct 7, 2023
99c1e62
refactor: 알람 이벤트 리스너 내부 메서드 리팩터링
hyena0608 Oct 7, 2023
3e12959
test: 로그인된 사용자 알람 목록 조회에서 알람 생성시간 테스트 수정
hyena0608 Oct 7, 2023
15db043
test: 테스트용 알람 조회 레포지터리 구현 및 테스트용 회원 조회 레포지터리 수정
hyena0608 Oct 7, 2023
7c648b6
test: 알람 읽음 여부 기록 업데이트 인수 테스트
hyena0608 Oct 7, 2023
af44d16
test: 러너 게시글 지원자 생성 인수 서포트 내부 사용하지 않는 검증문 삭제
hyena0608 Oct 7, 2023
b9a839e
test: 알람 삭제 인수 테스트
hyena0608 Oct 7, 2023
bce0fbe
test: 로그인된 사용자 알람 목록 조회 인수 테스트
hyena0608 Oct 7, 2023
d88f40b
chore: flyway 알람 테이블 생성, 알람 사용자(member) 외래키 제약조건 추가
hyena0608 Oct 7, 2023
e1edbe2
test: 알람 삭제, 업데이트 restdocs pathVariable 추가
hyena0608 Oct 7, 2023
ffbe8ad
docs: 로그인된 사용자 알람 목록 조회, 알람 삭제, 알람 읽음 여부 업데이트 api 문서 추가
hyena0608 Oct 7, 2023
844582a
style: 사용하지 않는 메서드 삭제 및 정렬
hyena0608 Oct 7, 2023
48d2bc2
test: 테스트 로그 삭제
hyena0608 Oct 7, 2023
444613a
style: 사용하지 않는 메서드 삭제
hyena0608 Oct 7, 2023
3964795
test: 실험용 테스트 코드 삭제
hyena0608 Oct 7, 2023
ce69d94
docs: API 문서 Index 추가
hyena0608 Oct 7, 2023
9e8fdd4
style: 알람(Alarm)을 알림(Notification)으로 수정
hyena0608 Oct 9, 2023
43c8b49
test: willDoNothing()을 doNothing()으로 수정
hyena0608 Oct 9, 2023
2c5d1d9
test: 이벤트 발행 카운트 테스트 수정
hyena0608 Oct 9, 2023
325636b
refactor: IsRead 를 정적 팩터리 메서드를 이용해서 생성하도록 리팩터링
hyena0608 Oct 9, 2023
a9f2a15
feat: 알림 읽음 여부 수정 기능 구현
hyena0608 Oct 9, 2023
02d962c
feat: 알림 읽음 여부 수정 서비스 리팩터링
hyena0608 Oct 9, 2023
7befc7a
refactor: 알림 이벤트 리스너 내부 NotificationText 를 문자열로 리팩터링
hyena0608 Oct 10, 2023
3dc95e4
feat: 알림 목록 조회 querydsl 기능 구현
hyena0608 Oct 10, 2023
771524d
refactor: JpaRepository 조회 레포지터리를 Querydsl 레포지터리로 리팩터링
hyena0608 Oct 10, 2023
00731b6
Merge remote-tracking branch 'origin/dev/BE' into feat/624
hyena0608 Oct 10, 2023
ad5418c
test: 알림 Restdocs 테스트 수정
hyena0608 Oct 10, 2023
539b493
test: 러너 게시글에 서포터 조인 조회 기능 테스트 수정
hyena0608 Oct 10, 2023
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 29 additions & 0 deletions backend/baton/src/docs/asciidoc/NotificationDeleteApi.adoc
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
ifndef::snippets[]
:snippets: ../../../build/generated-snippets
endif::[]
:doctype: book
:icons: font
:source-highlighter: highlight.js
:toc: left
:toclevels: 3
:sectlinks:
:operation-http-request-title: Example Request
:operation-http-response-title: Example Response

==== *알림 삭제 API*

===== *Http Request*

include::{snippets}/../../build/generated-snippets/notification-delete-api-test/delete-notification-by-notification-id/http-request.adoc[]

===== *Http Request Headers*

include::{snippets}/../../build/generated-snippets/notification-delete-api-test/delete-notification-by-notification-id/request-headers.adoc[]

===== *Http Request Path Parameters*

include::{snippets}/../../build/generated-snippets/notification-delete-api-test/delete-notification-by-notification-id/path-parameters.adoc[]

===== *Http Response*

include::{snippets}/../../build/generated-snippets/notification-delete-api-test/delete-notification-by-notification-id/http-response.adoc[]
29 changes: 29 additions & 0 deletions backend/baton/src/docs/asciidoc/NotificationLoginReadApi.adoc
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
ifndef::snippets[]
:snippets: ../../../build/generated-snippets
endif::[]
:doctype: book
:icons: font
:source-highlighter: highlight.js
:toc: left
:toclevels: 3
:sectlinks:
:operation-http-request-title: Example Request
:operation-http-response-title: Example Response

==== *로그인된 사용자 알림 목록 조회 API*

===== *Http Request*

include::{snippets}/../../build/generated-snippets/notification-read-with-logined-member-api-test/read-notifications-by-member-id/http-request.adoc[]

===== *Http Request Headers*

include::{snippets}/../../build/generated-snippets/notification-read-with-logined-member-api-test/read-notifications-by-member-id/request-headers.adoc[]

===== *Http Response*

include::{snippets}/../../build/generated-snippets/notification-read-with-logined-member-api-test/read-notifications-by-member-id/http-response.adoc[]

===== *Http Response Fields*

include::{snippets}/../../build/generated-snippets/notification-read-with-logined-member-api-test/read-notifications-by-member-id/response-fields.adoc[]
29 changes: 29 additions & 0 deletions backend/baton/src/docs/asciidoc/NotificationUpdateApi.adoc
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
ifndef::snippets[]
:snippets: ../../../build/generated-snippets
endif::[]
:doctype: book
:icons: font
:source-highlighter: highlight.js
:toc: left
:toclevels: 3
:sectlinks:
:operation-http-request-title: Example Request
:operation-http-response-title: Example Response

==== *알림 읽음 여부 업데이트 API*

===== *Http Request*

include::{snippets}/../../build/generated-snippets/notification-update-api-test/update-notification-is-read-true-by-member/http-request.adoc[]

===== *Http Request Headers*

include::{snippets}/../../build/generated-snippets/notification-update-api-test/update-notification-is-read-true-by-member/request-headers.adoc[]

===== *Http Request Path Parameters*

include::{snippets}/../../build/generated-snippets/notification-update-api-test/update-notification-is-read-true-by-member/path-parameters.adoc[]

===== *Http Response*

include::{snippets}/../../build/generated-snippets/notification-update-api-test/update-notification-is-read-true-by-member/http-response.adoc[]
14 changes: 14 additions & 0 deletions backend/baton/src/docs/asciidoc/index.adoc
Original file line number Diff line number Diff line change
Expand Up @@ -63,3 +63,17 @@ include::RunnerPostUpdateApplicantCancelationApi.adoc[]
=== *러너 게시글 삭제*

include::RunnerPostDeleteApi.adoc[]

== *[ 알림 ]*

=== *알림 조회*

include::NotificationLoginReadApi.adoc[]

=== *알림 수정*

include::NotificationUpdateApi.adoc[]

=== *알림 삭제*

include::NotificationDeleteApi.adoc[]
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
package touch.baton.domain.common;

import java.time.LocalDateTime;
import java.time.temporal.ChronoUnit;

public abstract class TruncatedBaseEntity extends BaseEntity {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

굿굿


@Override
public LocalDateTime getCreatedAt() {
return super.getCreatedAt().truncatedTo(ChronoUnit.MINUTES);
}

@Override
public LocalDateTime getDeletedAt() {
return super.getDeletedAt().truncatedTo(ChronoUnit.MINUTES);
}

@Override
public LocalDateTime getUpdatedAt() {
return super.getUpdatedAt().truncatedTo(ChronoUnit.MINUTES);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,146 @@
package touch.baton.domain.notification.command;

import jakarta.persistence.Column;
import jakarta.persistence.Embedded;
import jakarta.persistence.Entity;
import jakarta.persistence.Enumerated;
import jakarta.persistence.ForeignKey;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.Id;
import jakarta.persistence.JoinColumn;
import jakarta.persistence.ManyToOne;
import lombok.Builder;
import lombok.Getter;
import lombok.NoArgsConstructor;
import org.hibernate.annotations.SQLDelete;
import org.hibernate.annotations.Where;
import touch.baton.domain.common.TruncatedBaseEntity;
import touch.baton.domain.member.command.Member;
import touch.baton.domain.notification.command.vo.IsRead;
import touch.baton.domain.notification.command.vo.NotificationMessage;
import touch.baton.domain.notification.command.vo.NotificationReferencedId;
import touch.baton.domain.notification.command.vo.NotificationTitle;
import touch.baton.domain.notification.command.vo.NotificationType;
import touch.baton.domain.notification.exception.NotificationDomainException;

import java.util.Objects;

import static jakarta.persistence.EnumType.STRING;
import static jakarta.persistence.FetchType.LAZY;
import static jakarta.persistence.GenerationType.IDENTITY;
import static lombok.AccessLevel.PROTECTED;

@Getter
@NoArgsConstructor(access = PROTECTED)
@Where(clause = "deleted_at IS NULL")
@SQLDelete(sql = "UPDATE notification SET deleted_at = now() WHERE id = ?")
@Entity
public class Notification extends TruncatedBaseEntity {

@Id
@GeneratedValue(strategy = IDENTITY)
private Long id;

@Embedded
private NotificationTitle notificationTitle;

@Embedded
private NotificationMessage notificationMessage;

@Enumerated(STRING)
@Column(nullable = false)
private NotificationType notificationType;

@Embedded
private NotificationReferencedId notificationReferencedId;

@Embedded
private IsRead isRead;

@ManyToOne(fetch = LAZY)
@JoinColumn(name = "member_id",
nullable = false,
foreignKey = @ForeignKey(name = "fk_notification_to_member"))
private Member member;

@Builder
private Notification(final NotificationTitle notificationTitle,
final NotificationMessage notificationMessage,
final NotificationType notificationType,
final NotificationReferencedId notificationReferencedId,
final IsRead isRead,
final Member member
) {
this(null, notificationTitle, notificationMessage, notificationType, notificationReferencedId, isRead, member);
}

private Notification(final Long id,
final NotificationTitle notificationTitle,
final NotificationMessage notificationMessage,
final NotificationType notificationType,
final NotificationReferencedId notificationReferencedId,
final IsRead isRead,
final Member member
) {
validateNotNull(notificationTitle, notificationMessage, notificationType, notificationReferencedId, isRead, member);
this.id = id;
this.notificationTitle = notificationTitle;
this.notificationMessage = notificationMessage;
this.notificationType = notificationType;
this.notificationReferencedId = notificationReferencedId;
this.isRead = isRead;
this.member = member;
}

private void validateNotNull(final NotificationTitle notificationTitle,
final NotificationMessage notificationMessage,
final NotificationType notificationType,
final NotificationReferencedId notificationReferencedId,
final IsRead isRead,
final Member member
) {
if (notificationTitle == null) {
throw new NotificationDomainException("NotificationTitle 의 notificationTitle 은 null 일 수 없습니다.");
}
if (notificationMessage == null) {
throw new NotificationDomainException("NotificationMessage 의 notificationMessage 는 null 일 수 없습니다.");
}
if (notificationType == null) {
throw new NotificationDomainException("NotificationType 의 notificationType 는 null 일 수 없습니다.");
}
if (notificationReferencedId == null) {
throw new NotificationDomainException("NotificationReferencedId 의 notificationReferencedId 은 null 일 수 없습니다.");
}
if (isRead == null) {
throw new NotificationDomainException("IsRead 의 isRead 는 null 일 수 없습니다.");
}
if (member == null) {
throw new NotificationDomainException("Member 의 member 는 null 일 수 없습니다.");
}
}

public void markAsRead(final Member currentMember) {
if (!this.member.equals(currentMember)) {
throw new NotificationDomainException("Notification 의 주인(사용자)가 아니므로 알림의 읽은 여부를 수정할 수 없습니다.");
}

this.isRead = IsRead.asRead();
}

public boolean isNotOwner(final Member currentMember) {
return !this.member.equals(currentMember);
}

@Override
public boolean equals(final Object o) {
if (this == o) return true;
if (o == null || getClass() != o.getClass()) return false;
final Notification notification = (Notification) o;
return Objects.equals(id, notification.id);
}

@Override
public int hashCode() {
return Objects.hash(id);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
package touch.baton.domain.notification.command.controller;

import lombok.RequiredArgsConstructor;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.DeleteMapping;
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.RestController;
import touch.baton.domain.notification.command.service.NotificationCommandService;
import touch.baton.domain.member.command.Member;
import touch.baton.domain.oauth.query.controller.resolver.AuthMemberPrincipal;

@RequiredArgsConstructor
@RequestMapping("/api/v1/notifications")
@RestController
public class NotificationCommandController {

private final NotificationCommandService notificationCommandService;

@PatchMapping("/{notificationId}")
public ResponseEntity<Void> updateNotificationIsReadTrueByNotificationId(@AuthMemberPrincipal final Member member,
@PathVariable final Long notificationId
) {
notificationCommandService.updateNotificationIsReadTrueByMember(member, notificationId);

return ResponseEntity.noContent().build();
}

@DeleteMapping("/{notificationId}")
public ResponseEntity<Void> deleteNotificationByNotificationId(@AuthMemberPrincipal final Member member,
@PathVariable final Long notificationId
) {
notificationCommandService.deleteNotificationByMember(member, notificationId);

return ResponseEntity.noContent().build();
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
package touch.baton.domain.notification.command.event;

import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Component;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.transaction.event.TransactionalEventListener;
import touch.baton.domain.member.command.Member;
import touch.baton.domain.notification.command.Notification;
import touch.baton.domain.notification.command.repository.NotificationCommandRepository;
import touch.baton.domain.notification.command.vo.IsRead;
import touch.baton.domain.notification.command.vo.NotificationMessage;
import touch.baton.domain.notification.command.vo.NotificationReferencedId;
import touch.baton.domain.notification.command.vo.NotificationTitle;
import touch.baton.domain.notification.command.vo.NotificationType;
import touch.baton.domain.notification.exception.NotificationBusinessException;
import touch.baton.domain.runnerpost.command.RunnerPost;
import touch.baton.domain.runnerpost.command.event.RunnerPostApplySupporterEvent;
import touch.baton.domain.runnerpost.command.event.RunnerPostAssignSupporterEvent;
import touch.baton.domain.runnerpost.command.event.RunnerPostReviewStatusDoneEvent;
import touch.baton.domain.runnerpost.query.repository.RunnerPostQueryRepository;

import static org.springframework.transaction.annotation.Propagation.REQUIRES_NEW;
import static org.springframework.transaction.event.TransactionPhase.AFTER_COMMIT;

@RequiredArgsConstructor
@Component
public class NotificationEventListener {

private final NotificationCommandRepository notificationCommandRepository;
private final RunnerPostQueryRepository runnerPostQueryRepository;

@TransactionalEventListener(phase = AFTER_COMMIT)
@Transactional(propagation = REQUIRES_NEW)
public void subscribeRunnerPostApplySupporterEvent(final RunnerPostApplySupporterEvent event) {
final RunnerPost foundRunnerPost = getRunnerPostWithRunnerOrThrowException(event.runnerPostId());

notificationCommandRepository.save(
createNotification("서포터의 제안이 왔습니다.", foundRunnerPost, foundRunnerPost.getRunner().getMember())
);
}

private RunnerPost getRunnerPostWithRunnerOrThrowException(final Long runnerPostId) {
return runnerPostQueryRepository.joinMemberByRunnerPostId(runnerPostId)
.orElseThrow(() -> new NotificationBusinessException("러너 게시글 식별자값으로 러너 게시글과 러너(작성자)를 조회하던 도중에 오류가 발생하였습니다."));
}

private Notification createNotification(final String notificationTitle, final RunnerPost runnerPost, final Member targetMember) {
return Notification.builder()
.notificationTitle(new NotificationTitle(notificationTitle))
.notificationMessage(new NotificationMessage(String.format("관련 게시글 - %s", runnerPost.getTitle().getValue())))
.notificationType(NotificationType.RUNNER_POST)
.notificationReferencedId(new NotificationReferencedId(runnerPost.getId()))
.isRead(IsRead.asUnRead())
.member(targetMember)
.build();
}

@TransactionalEventListener(phase = AFTER_COMMIT)
@Transactional(propagation = REQUIRES_NEW)
public void subscribeRunnerPostReviewStatusDoneEvent(final RunnerPostReviewStatusDoneEvent event) {
final RunnerPost foundRunnerPost = getRunnerPostWithRunnerOrThrowException(event.runnerPostId());

notificationCommandRepository.save(
createNotification("코드 리뷰 상태가 완료로 변경되었습니다.", foundRunnerPost, foundRunnerPost.getRunner().getMember())
);
}

@TransactionalEventListener(phase = AFTER_COMMIT)
@Transactional(propagation = REQUIRES_NEW)
public void subscribeRunnerPostAssignSupporterEvent(final RunnerPostAssignSupporterEvent event) {
final RunnerPost foundRunnerPost = getRunnerPostWithSupporterOrThrowException(event.runnerPostId());

notificationCommandRepository.save(
createNotification("코드 리뷰 매칭이 완료되었습니다.", foundRunnerPost, foundRunnerPost.getSupporter().getMember())
);
}

private RunnerPost getRunnerPostWithSupporterOrThrowException(final Long runnerPostId) {
return runnerPostQueryRepository.joinSupporterByRunnerPostId(runnerPostId)
.orElseThrow(() -> new NotificationBusinessException("러너 게시글 식별자값으로 러너 게시글과 서포터(지원자)를 조회하던 도중에 오류가 발생하였습니다."));
}
}
Loading