diff --git a/backend/build.gradle b/backend/build.gradle index 676c09045..5f70079f6 100644 --- a/backend/build.gradle +++ b/backend/build.gradle @@ -76,6 +76,9 @@ dependencies { annotationProcessor "javax.persistence:javax.persistence-api" annotationProcessor "javax.annotation:javax.annotation-api" annotationProcessor "com.querydsl:querydsl-apt:${dependencyManagement.importedProperties['querydsl.version']}:jpa" + + // fcm + implementation 'com.google.firebase:firebase-admin:9.1.1' } tasks.named('test') { diff --git a/backend/src/main/java/com/yigongil/backend/application/AuthService.java b/backend/src/main/java/com/yigongil/backend/application/AuthService.java index 6f0d559fc..5801f3657 100644 --- a/backend/src/main/java/com/yigongil/backend/application/AuthService.java +++ b/backend/src/main/java/com/yigongil/backend/application/AuthService.java @@ -5,7 +5,7 @@ import com.yigongil.backend.config.oauth.GithubProfileResponse; import com.yigongil.backend.domain.member.Member; import com.yigongil.backend.domain.member.MemberRepository; -import com.yigongil.backend.request.TokenRequest; +import com.yigongil.backend.request.RefreshTokenRequest; import com.yigongil.backend.response.TokenResponse; import java.util.Optional; import org.springframework.stereotype.Service; @@ -40,7 +40,7 @@ public TokenResponse login(String code) { return createTokens(id); } - public TokenResponse refresh(TokenRequest request) { + public TokenResponse refresh(RefreshTokenRequest request) { String refreshToken = request.refreshToken(); jwtTokenProvider.detectTokenTheft(refreshToken); diff --git a/backend/src/main/java/com/yigongil/backend/application/MustDoService.java b/backend/src/main/java/com/yigongil/backend/application/MustDoService.java index b1ed85e52..68dd6f362 100644 --- a/backend/src/main/java/com/yigongil/backend/application/MustDoService.java +++ b/backend/src/main/java/com/yigongil/backend/application/MustDoService.java @@ -23,6 +23,7 @@ public MustDoService( public void updateMustDo(Member member, Long roundId, MustDoUpdateRequest request) { Round round = findRoundById(roundId); round.updateMustDo(member, request.content()); + roundRepository.save(round); } private Round findRoundById(Long roundId) { diff --git a/backend/src/main/java/com/yigongil/backend/application/StudyEventListener.java b/backend/src/main/java/com/yigongil/backend/application/StudyEventListener.java index ae1b68d77..cc1c61307 100644 --- a/backend/src/main/java/com/yigongil/backend/application/StudyEventListener.java +++ b/backend/src/main/java/com/yigongil/backend/application/StudyEventListener.java @@ -1,6 +1,6 @@ package com.yigongil.backend.application; -import com.yigongil.backend.domain.event.MemberDeleteEvent; +import com.yigongil.backend.domain.event.MemberDeletedEvent; import org.springframework.stereotype.Component; import org.springframework.transaction.event.TransactionPhase; import org.springframework.transaction.event.TransactionalEventListener; @@ -15,7 +15,7 @@ public StudyEventListener(StudyService studyService) { } @TransactionalEventListener(phase = TransactionPhase.BEFORE_COMMIT) - public void listenMemberDeleteEvent(MemberDeleteEvent event) { + public void listenMemberDeleteEvent(MemberDeletedEvent event) { studyService.deleteByMasterId(event.memberId()); } } diff --git a/backend/src/main/java/com/yigongil/backend/application/StudyService.java b/backend/src/main/java/com/yigongil/backend/application/StudyService.java index 94186030b..d2a25499c 100644 --- a/backend/src/main/java/com/yigongil/backend/application/StudyService.java +++ b/backend/src/main/java/com/yigongil/backend/application/StudyService.java @@ -1,6 +1,8 @@ package com.yigongil.backend.application; import com.yigongil.backend.domain.certification.Certification; +import com.yigongil.backend.domain.meetingdayoftheweek.MeetingDayOfTheWeek; +import com.yigongil.backend.domain.meetingdayoftheweek.MeetingDayOfTheWeekRepository; import com.yigongil.backend.domain.member.Member; import com.yigongil.backend.domain.round.Round; import com.yigongil.backend.domain.round.RoundRepository; @@ -46,18 +48,21 @@ public class StudyService { private final CertificationService certificationService; private final FeedService feedService; private final RoundRepository roundRepository; + private final MeetingDayOfTheWeekRepository meetingDayOfTheWeekRepository; public StudyService( StudyRepository studyRepository, StudyMemberRepository studyMemberRepository, CertificationService certificationService, FeedService feedService, - final RoundRepository roundRepository) { + final RoundRepository roundRepository, + MeetingDayOfTheWeekRepository meetingDayOfTheWeekRepository) { this.studyRepository = studyRepository; this.studyMemberRepository = studyMemberRepository; this.certificationService = certificationService; this.feedService = feedService; this.roundRepository = roundRepository; + this.meetingDayOfTheWeekRepository = meetingDayOfTheWeekRepository; } @Transactional @@ -128,6 +133,7 @@ public List findMyStudies(Member member) { public void apply(Member member, Long studyId) { Study study = findStudyById(studyId); study.apply(member); + studyRepository.save(study); } @Transactional @@ -162,6 +168,7 @@ public void permitApplicant(Member master, Long studyId, Long memberId) { Study study = studyMember.getStudy(); study.permit(studyMember.getMember(), master); + studyRepository.save(study); } @Transactional(readOnly = true) @@ -230,9 +237,12 @@ public void proceedRound(LocalDate today) { @Transactional public void start(Member member, Long studyId, StudyStartRequest request) { List meetingDaysOfTheWeek = createDayOfWeek(request.meetingDaysOfTheWeek()); - Study study = findStudyById(studyId); study.start(member, meetingDaysOfTheWeek, LocalDateTime.now()); + for (MeetingDayOfTheWeek meetingDayOfTheWeek : study.getMeetingDaysOfTheWeek()) { + meetingDayOfTheWeekRepository.save(meetingDayOfTheWeek); + } + studyRepository.save(study); } private List createDayOfWeek(List daysOfTheWeek) { diff --git a/backend/src/main/java/com/yigongil/backend/domain/BaseEntity.java b/backend/src/main/java/com/yigongil/backend/domain/BaseEntity.java index 90980c463..5e9a54236 100644 --- a/backend/src/main/java/com/yigongil/backend/domain/BaseEntity.java +++ b/backend/src/main/java/com/yigongil/backend/domain/BaseEntity.java @@ -1,22 +1,17 @@ package com.yigongil.backend.domain; -import com.yigongil.backend.domain.event.DomainEvent; import java.time.LocalDateTime; -import java.util.ArrayList; -import java.util.Collection; import javax.persistence.Column; import javax.persistence.EntityListeners; import javax.persistence.MappedSuperclass; -import javax.persistence.Transient; import org.springframework.data.annotation.CreatedDate; import org.springframework.data.annotation.LastModifiedDate; -import org.springframework.data.domain.AfterDomainEventPublication; -import org.springframework.data.domain.DomainEvents; +import org.springframework.data.domain.AbstractAggregateRoot; import org.springframework.data.jpa.domain.support.AuditingEntityListener; @EntityListeners(AuditingEntityListener.class) @MappedSuperclass -public abstract class BaseEntity { +public abstract class BaseEntity extends AbstractAggregateRoot { @CreatedDate @Column(nullable = false) @@ -25,23 +20,6 @@ public abstract class BaseEntity { @LastModifiedDate protected LocalDateTime updatedAt; - @Transient - private Collection domainEvents = new ArrayList<>(); - - @DomainEvents - public Collection events() { - return domainEvents; - } - - @AfterDomainEventPublication - public void clear() { - domainEvents.clear(); - } - - protected void register(DomainEvent event) { - domainEvents.add(event); - } - public LocalDateTime getCreatedAt() { return createdAt; } diff --git a/backend/src/main/java/com/yigongil/backend/domain/certification/Certification.java b/backend/src/main/java/com/yigongil/backend/domain/certification/Certification.java index 265097560..8371d5e6c 100644 --- a/backend/src/main/java/com/yigongil/backend/domain/certification/Certification.java +++ b/backend/src/main/java/com/yigongil/backend/domain/certification/Certification.java @@ -1,6 +1,7 @@ package com.yigongil.backend.domain.certification; import com.yigongil.backend.domain.BaseEntity; +import com.yigongil.backend.domain.event.CertificationCreatedEvent; import com.yigongil.backend.domain.member.Member; import com.yigongil.backend.domain.round.Round; import com.yigongil.backend.domain.study.Study; @@ -11,6 +12,7 @@ import javax.persistence.Id; import javax.persistence.JoinColumn; import javax.persistence.ManyToOne; +import javax.persistence.PostPersist; import lombok.Builder; import lombok.Getter; @@ -59,4 +61,9 @@ public Certification( this.createdAt = createdAt; this.round = round; } + + @PostPersist + public void registerCreatedEvent() { + registerEvent(new CertificationCreatedEvent(study.getId(), study.getName(), author.getGithubId())); + } } diff --git a/backend/src/main/java/com/yigongil/backend/domain/event/CertificationCreatedEvent.java b/backend/src/main/java/com/yigongil/backend/domain/event/CertificationCreatedEvent.java new file mode 100644 index 000000000..7d049e7e6 --- /dev/null +++ b/backend/src/main/java/com/yigongil/backend/domain/event/CertificationCreatedEvent.java @@ -0,0 +1,8 @@ +package com.yigongil.backend.domain.event; + +public record CertificationCreatedEvent( + Long studyId, + String studyName, + String authorGithubId +) { +} diff --git a/backend/src/main/java/com/yigongil/backend/domain/event/FeedPostCreatedEvent.java b/backend/src/main/java/com/yigongil/backend/domain/event/FeedPostCreatedEvent.java new file mode 100644 index 000000000..89c250812 --- /dev/null +++ b/backend/src/main/java/com/yigongil/backend/domain/event/FeedPostCreatedEvent.java @@ -0,0 +1,15 @@ +package com.yigongil.backend.domain.event; + +import java.time.LocalDateTime; + +public record FeedPostCreatedEvent + ( + Long studyId, + String studyName, + String authorGithubId, + String content, + String imageUrl, + LocalDateTime createdAt + ) implements DomainEvent { + +} diff --git a/backend/src/main/java/com/yigongil/backend/domain/event/MemberDeleteEvent.java b/backend/src/main/java/com/yigongil/backend/domain/event/MemberDeleteEvent.java deleted file mode 100644 index 52fa5d122..000000000 --- a/backend/src/main/java/com/yigongil/backend/domain/event/MemberDeleteEvent.java +++ /dev/null @@ -1,5 +0,0 @@ -package com.yigongil.backend.domain.event; - -public record MemberDeleteEvent(Long memberId) implements DomainEvent { - -} diff --git a/backend/src/main/java/com/yigongil/backend/domain/event/MemberDeletedEvent.java b/backend/src/main/java/com/yigongil/backend/domain/event/MemberDeletedEvent.java new file mode 100644 index 000000000..d1f1fa243 --- /dev/null +++ b/backend/src/main/java/com/yigongil/backend/domain/event/MemberDeletedEvent.java @@ -0,0 +1,5 @@ +package com.yigongil.backend.domain.event; + +public record MemberDeletedEvent(Long memberId) implements DomainEvent { + +} diff --git a/backend/src/main/java/com/yigongil/backend/domain/event/MustDoUpdatedEvent.java b/backend/src/main/java/com/yigongil/backend/domain/event/MustDoUpdatedEvent.java new file mode 100644 index 000000000..a73eea7da --- /dev/null +++ b/backend/src/main/java/com/yigongil/backend/domain/event/MustDoUpdatedEvent.java @@ -0,0 +1,8 @@ +package com.yigongil.backend.domain.event; + +public record MustDoUpdatedEvent( + Long studyId, + String studyName, + String mustDo +) implements DomainEvent{ +} diff --git a/backend/src/main/java/com/yigongil/backend/domain/event/StudyAppliedEvent.java b/backend/src/main/java/com/yigongil/backend/domain/event/StudyAppliedEvent.java new file mode 100644 index 000000000..a31555959 --- /dev/null +++ b/backend/src/main/java/com/yigongil/backend/domain/event/StudyAppliedEvent.java @@ -0,0 +1,5 @@ +package com.yigongil.backend.domain.event; + +public record StudyAppliedEvent(Long studyMasterId, String studyName, String appliedMemberGithubId) implements DomainEvent { + +} diff --git a/backend/src/main/java/com/yigongil/backend/domain/event/StudyCreatedEvent.java b/backend/src/main/java/com/yigongil/backend/domain/event/StudyCreatedEvent.java new file mode 100644 index 000000000..c9ea243e3 --- /dev/null +++ b/backend/src/main/java/com/yigongil/backend/domain/event/StudyCreatedEvent.java @@ -0,0 +1,5 @@ +package com.yigongil.backend.domain.event; + +public record StudyCreatedEvent(Long studyId, Long masterId) implements DomainEvent { + +} diff --git a/backend/src/main/java/com/yigongil/backend/domain/event/StudyPermittedEvent.java b/backend/src/main/java/com/yigongil/backend/domain/event/StudyPermittedEvent.java new file mode 100644 index 000000000..284177c13 --- /dev/null +++ b/backend/src/main/java/com/yigongil/backend/domain/event/StudyPermittedEvent.java @@ -0,0 +1,5 @@ +package com.yigongil.backend.domain.event; + +public record StudyPermittedEvent(Long permittedMemberId, Long studyId, String studyName) implements DomainEvent { + +} diff --git a/backend/src/main/java/com/yigongil/backend/domain/event/StudyStartedEvent.java b/backend/src/main/java/com/yigongil/backend/domain/event/StudyStartedEvent.java new file mode 100644 index 000000000..e19d77052 --- /dev/null +++ b/backend/src/main/java/com/yigongil/backend/domain/event/StudyStartedEvent.java @@ -0,0 +1,5 @@ +package com.yigongil.backend.domain.event; + +public record StudyStartedEvent(Long studyId, String studyName) implements DomainEvent { + +} diff --git a/backend/src/main/java/com/yigongil/backend/domain/feedpost/FeedPost.java b/backend/src/main/java/com/yigongil/backend/domain/feedpost/FeedPost.java index 7278718c3..3443daca3 100644 --- a/backend/src/main/java/com/yigongil/backend/domain/feedpost/FeedPost.java +++ b/backend/src/main/java/com/yigongil/backend/domain/feedpost/FeedPost.java @@ -1,6 +1,7 @@ package com.yigongil.backend.domain.feedpost; import com.yigongil.backend.domain.BaseEntity; +import com.yigongil.backend.domain.event.FeedPostCreatedEvent; import com.yigongil.backend.domain.member.Member; import com.yigongil.backend.domain.study.Study; import java.time.LocalDateTime; @@ -11,6 +12,7 @@ import javax.persistence.Id; import javax.persistence.JoinColumn; import javax.persistence.ManyToOne; +import javax.persistence.PostPersist; import lombok.Builder; import lombok.Getter; @@ -54,6 +56,18 @@ public FeedPost( this.createdAt = createdAt; } + @PostPersist + public void registerCreatedEvent() { + registerEvent(new FeedPostCreatedEvent( + study.getId(), + study.getName(), + author.getGithubId(), + content, + imageUrl, + createdAt + )); + } + @Override public boolean equals(Object o) { if (this == o) { diff --git a/backend/src/main/java/com/yigongil/backend/domain/meetingdayoftheweek/MeetingDayOfTheWeekRepository.java b/backend/src/main/java/com/yigongil/backend/domain/meetingdayoftheweek/MeetingDayOfTheWeekRepository.java new file mode 100644 index 000000000..d669bc9b0 --- /dev/null +++ b/backend/src/main/java/com/yigongil/backend/domain/meetingdayoftheweek/MeetingDayOfTheWeekRepository.java @@ -0,0 +1,9 @@ +package com.yigongil.backend.domain.meetingdayoftheweek; + +import org.springframework.data.repository.Repository; + +public interface MeetingDayOfTheWeekRepository extends Repository { + + MeetingDayOfTheWeek save(MeetingDayOfTheWeek meetingDayOfTheWeek); + +} diff --git a/backend/src/main/java/com/yigongil/backend/domain/member/Member.java b/backend/src/main/java/com/yigongil/backend/domain/member/Member.java index 054eb242d..6b13a397a 100644 --- a/backend/src/main/java/com/yigongil/backend/domain/member/Member.java +++ b/backend/src/main/java/com/yigongil/backend/domain/member/Member.java @@ -1,7 +1,7 @@ package com.yigongil.backend.domain.member; import com.yigongil.backend.domain.BaseEntity; -import com.yigongil.backend.domain.event.MemberDeleteEvent; +import com.yigongil.backend.domain.event.MemberDeletedEvent; import java.util.Objects; import javax.persistence.Column; import javax.persistence.Embedded; @@ -103,7 +103,7 @@ public String getIntroduction() { @PreRemove public void registerDeleteEvent() { - register(new MemberDeleteEvent(id)); + registerEvent(new MemberDeletedEvent(id)); } public void addExperience(int exp) { diff --git a/backend/src/main/java/com/yigongil/backend/domain/round/Round.java b/backend/src/main/java/com/yigongil/backend/domain/round/Round.java index 79795fb48..aeb3ff838 100644 --- a/backend/src/main/java/com/yigongil/backend/domain/round/Round.java +++ b/backend/src/main/java/com/yigongil/backend/domain/round/Round.java @@ -1,6 +1,7 @@ package com.yigongil.backend.domain.round; import com.yigongil.backend.domain.BaseEntity; +import com.yigongil.backend.domain.event.MustDoUpdatedEvent; import com.yigongil.backend.domain.meetingdayoftheweek.MeetingDayOfTheWeek; import com.yigongil.backend.domain.member.Member; import com.yigongil.backend.domain.roundofmember.RoundOfMember; @@ -54,7 +55,7 @@ public class Round extends BaseEntity { @JoinColumn(name = "master_id", nullable = false) private Member master; - @Cascade(CascadeType.PERSIST) + @Cascade({CascadeType.PERSIST, CascadeType.MERGE}) @OnDelete(action = OnDeleteAction.CASCADE) @OneToMany @JoinColumn(name = "round_id", nullable = false) @@ -106,6 +107,7 @@ public void updateMustDo(Member author, String content) { validateTodoLength(content); validateMaster(author); mustDo = content; + registerEvent(new MustDoUpdatedEvent(study.getId(), study.getName(), content)); } public void validateMaster(Member member) { diff --git a/backend/src/main/java/com/yigongil/backend/domain/round/RoundRepository.java b/backend/src/main/java/com/yigongil/backend/domain/round/RoundRepository.java index 0138ff6dc..ad71dff0a 100644 --- a/backend/src/main/java/com/yigongil/backend/domain/round/RoundRepository.java +++ b/backend/src/main/java/com/yigongil/backend/domain/round/RoundRepository.java @@ -11,4 +11,6 @@ public interface RoundRepository extends Repository { Optional findById(Long id); List findAllByStudyIdAndWeekNumber(Long studyId, Integer weekNumber); + + Round save(Round round); } diff --git a/backend/src/main/java/com/yigongil/backend/domain/study/Study.java b/backend/src/main/java/com/yigongil/backend/domain/study/Study.java index 6e71cf414..41218d088 100644 --- a/backend/src/main/java/com/yigongil/backend/domain/study/Study.java +++ b/backend/src/main/java/com/yigongil/backend/domain/study/Study.java @@ -1,6 +1,10 @@ package com.yigongil.backend.domain.study; import com.yigongil.backend.domain.BaseEntity; +import com.yigongil.backend.domain.event.StudyAppliedEvent; +import com.yigongil.backend.domain.event.StudyCreatedEvent; +import com.yigongil.backend.domain.event.StudyPermittedEvent; +import com.yigongil.backend.domain.event.StudyStartedEvent; import com.yigongil.backend.domain.meetingdayoftheweek.MeetingDayOfTheWeek; import com.yigongil.backend.domain.member.Member; import com.yigongil.backend.domain.round.Round; @@ -33,6 +37,7 @@ import javax.persistence.GenerationType; import javax.persistence.Id; import javax.persistence.OneToMany; +import javax.persistence.PostPersist; import lombok.Builder; import lombok.Getter; import org.hibernate.annotations.Cascade; @@ -84,12 +89,12 @@ public class Study extends BaseEntity { @Column private Long currentRoundNumber; - @Cascade(CascadeType.PERSIST) + @Cascade({CascadeType.MERGE, CascadeType.PERSIST}) @OneToMany(mappedBy = "study", orphanRemoval = true, fetch = FetchType.LAZY) private List studyMembers = new ArrayList<>(); - @Cascade(CascadeType.PERSIST) + @Cascade({CascadeType.PERSIST, CascadeType.MERGE}) @OnDelete(action = OnDeleteAction.CASCADE) @OneToMany(mappedBy = "study", orphanRemoval = true, fetch = FetchType.LAZY) private List rounds = new ArrayList<>(); @@ -151,6 +156,11 @@ public static Study initializeStudyOf( .build(); } + @PostPersist + public void registerCreatedEvent() { + registerEvent(new StudyCreatedEvent(id, getMaster().getId())); + } + private void validateNumberOfMaximumMembers(Integer numberOfMaximumMembers) { if (numberOfMaximumMembers < MIN_MEMBER_SIZE || numberOfMaximumMembers > MAX_MEMBER_SIZE) { throw new InvalidNumberOfMaximumStudyMember( @@ -193,6 +203,8 @@ public void permit(Member applicant, Member master) { .filter(studyMember -> studyMember.getMember().equals(applicant)) .findAny() .ifPresent(StudyMember::participate); + + registerEvent(new StudyPermittedEvent(applicant.getId(), id, name)); } public void validateMemberSize() { @@ -224,6 +236,7 @@ public void start(Member member, List daysOfTheWeek, LocalDateTime st this.processingStatus = ProcessingStatus.PROCESSING; initializeMeetingDaysOfTheWeek(daysOfTheWeek); initializeRounds(startAt.toLocalDate()); + registerEvent(new StudyStartedEvent(id, name)); } private void deleteLeftApplicant() { @@ -398,6 +411,8 @@ public void apply(Member member) { .member(member) .studyResult(StudyResult.NONE) .build()); + + registerEvent(new StudyAppliedEvent(getMaster().getId(), name, member.getGithubId())); } private void validateApplicant(Member member) { diff --git a/backend/src/main/java/com/yigongil/backend/exception/MemberNotRegisteredException.java b/backend/src/main/java/com/yigongil/backend/exception/MemberNotRegisteredException.java new file mode 100644 index 000000000..769162b9e --- /dev/null +++ b/backend/src/main/java/com/yigongil/backend/exception/MemberNotRegisteredException.java @@ -0,0 +1,10 @@ +package com.yigongil.backend.exception; + +import org.springframework.http.HttpStatus; + +public class MemberNotRegisteredException extends HttpException { + + public MemberNotRegisteredException(final String message, final String input) { + super(HttpStatus.BAD_REQUEST, message, input); + } +} diff --git a/backend/src/main/java/com/yigongil/backend/infra/FirebaseCloudMessageSender.java b/backend/src/main/java/com/yigongil/backend/infra/FirebaseCloudMessageSender.java new file mode 100644 index 000000000..49ee708de --- /dev/null +++ b/backend/src/main/java/com/yigongil/backend/infra/FirebaseCloudMessageSender.java @@ -0,0 +1,32 @@ +package com.yigongil.backend.infra; + +import com.google.firebase.messaging.FirebaseMessaging; +import com.google.firebase.messaging.Message; +import java.util.List; +import org.springframework.context.annotation.Profile; +import org.springframework.stereotype.Component; + +@Profile(value = {"prod", "dev"}) +@Component +public class FirebaseCloudMessageSender implements MessageSender { + + private final FirebaseMessaging firebaseMessaging; + + public FirebaseCloudMessageSender(FirebaseMessaging firebaseMessaging) { + this.firebaseMessaging = firebaseMessaging; + } + + @Override + public void subscribeToTopicAsync(List tokens, String topic) { + firebaseMessaging.subscribeToTopicAsync(tokens, topic); + } + + @Override + public void send(String message, String topic) { + Message topicMessage = Message.builder() + .setTopic(topic) + .putData("message", message) + .build(); + firebaseMessaging.sendAsync(topicMessage); + } +} diff --git a/backend/src/main/java/com/yigongil/backend/infra/FirebaseCloudMessagingService.java b/backend/src/main/java/com/yigongil/backend/infra/FirebaseCloudMessagingService.java new file mode 100644 index 000000000..818e12de9 --- /dev/null +++ b/backend/src/main/java/com/yigongil/backend/infra/FirebaseCloudMessagingService.java @@ -0,0 +1,103 @@ +package com.yigongil.backend.infra; + +import com.yigongil.backend.domain.event.CertificationCreatedEvent; +import com.yigongil.backend.domain.event.FeedPostCreatedEvent; +import com.yigongil.backend.domain.event.MustDoUpdatedEvent; +import com.yigongil.backend.domain.event.StudyAppliedEvent; +import com.yigongil.backend.domain.event.StudyCreatedEvent; +import com.yigongil.backend.domain.event.StudyPermittedEvent; +import com.yigongil.backend.domain.event.StudyStartedEvent; +import com.yigongil.backend.domain.member.Member; +import java.util.List; +import org.jetbrains.annotations.NotNull; +import org.springframework.stereotype.Service; +import org.springframework.transaction.event.TransactionPhase; +import org.springframework.transaction.event.TransactionalEventListener; + +@Service +public class FirebaseCloudMessagingService implements MessagingService { + + private final MessageSender messageSender; + private final TokenRepository tokenRepository; + + public FirebaseCloudMessagingService(MessageSender messageSender, TokenRepository tokenRepository) { + this.messageSender = messageSender; + this.tokenRepository = tokenRepository; + } + + @Override + public void registerToken(String token, Member member) { + tokenRepository.save(new FirebaseToken(member, token)); + subscribeToTopic(List.of(token), getMemberTopicById(member.getId())); + } + + @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT) + public void registerStudyMaster(StudyCreatedEvent event) { + List tokens = findAllTokensByMemberId(event.masterId()); + subscribeToTopic(tokens, getStudyTopicById(event.studyId())); + } + + @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT) + public void sendNotificationOfApplicant(StudyAppliedEvent event) { + sendToMember(event.appliedMemberGithubId() + "님이 " + event.studyName() + " 스터디에 신청했어요!", event.studyMasterId()); + } + + @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT) + public void sendNotificationOfPermitted(StudyPermittedEvent event) { + List tokens = findAllTokensByMemberId(event.permittedMemberId()); + subscribeToTopic(tokens, getStudyTopicById(event.studyId())); + sendToMember(event.studyName() + " 스터디 신청이 수락되었어요!", event.permittedMemberId()); + } + + @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT) + public void sendNotificationOfStudyStarted(StudyStartedEvent event) { + sendToStudy(event.studyName() + " 스터디가 시작되었어요!", event.studyId()); + } + + @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT) + public void sendNotificationOfMustDoUpdated(MustDoUpdatedEvent event) { + sendToStudy(event.studyName() + " 스터디의 머스트두가 " + event.mustDo() + "로 변경되었어요!", event.studyId()); + } + + @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT) + public void sendNotificationOfCertificationCreated(CertificationCreatedEvent event) { + sendToStudy(event.studyName() + " 스터디에" + event.authorGithubId() + " 님의 머스트두 인증이 등록되었어요!", event.studyId()); + } + + @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT) + public void sendNotificationOfFeedPostCreated(FeedPostCreatedEvent event) { + sendToStudy(event.studyName() + " 스터디에 " + event.authorGithubId() + " 님이 피드를 등록했어요!", event.studyId()); + } + + private List findAllTokensByMemberId(Long memberId) { + return tokenRepository.findAllByMemberId(memberId) + .stream() + .map(FirebaseToken::getToken) + .toList(); + } + + public void subscribeToTopic(List tokens, String topic) { + messageSender.subscribeToTopicAsync(tokens, topic); + } + + public void sendToMember(String message, Long memberId) { + doSend(message, getMemberTopicById(memberId)); + } + + private String getMemberTopicById(Long memberId) { + return "member " + memberId; + } + + public void sendToStudy(String message, Long studyId) { + doSend(message, getStudyTopicById(studyId)); + } + + @NotNull + private static String getStudyTopicById(Long studyId) { + return "study " + studyId; + } + + private void doSend(String message, String topic) { + messageSender.send(message, topic); + } +} diff --git a/backend/src/main/java/com/yigongil/backend/infra/FirebaseMessagingConfig.java b/backend/src/main/java/com/yigongil/backend/infra/FirebaseMessagingConfig.java new file mode 100644 index 000000000..3ef237d01 --- /dev/null +++ b/backend/src/main/java/com/yigongil/backend/infra/FirebaseMessagingConfig.java @@ -0,0 +1,35 @@ +package com.yigongil.backend.infra; + +import com.google.auth.oauth2.GoogleCredentials; +import com.google.firebase.FirebaseApp; +import com.google.firebase.FirebaseOptions; +import com.google.firebase.messaging.FirebaseMessaging; +import java.io.IOException; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.context.annotation.Profile; +import org.springframework.core.io.ClassPathResource; + +@Profile(value = {"prod", "dev"}) +@Configuration +public class FirebaseMessagingConfig { + + @Bean + public FirebaseMessaging messagingService(FirebaseApp firebaseApp) { + return FirebaseMessaging.getInstance(firebaseApp); + } + + @Bean + public FirebaseApp firebaseApp(GoogleCredentials credentials) { + FirebaseOptions options = FirebaseOptions.builder() + .setCredentials(credentials) + .build(); + return FirebaseApp.initializeApp(options); + } + + @Bean + public GoogleCredentials googleCredentials() throws IOException { + final var inputStream = new ClassPathResource("yigongil-private/firebase-adminsdk.json").getInputStream(); + return GoogleCredentials.fromStream(inputStream); + } +} diff --git a/backend/src/main/java/com/yigongil/backend/infra/FirebaseToken.java b/backend/src/main/java/com/yigongil/backend/infra/FirebaseToken.java new file mode 100644 index 000000000..472ea9d07 --- /dev/null +++ b/backend/src/main/java/com/yigongil/backend/infra/FirebaseToken.java @@ -0,0 +1,35 @@ +package com.yigongil.backend.infra; + +import com.yigongil.backend.domain.BaseEntity; +import com.yigongil.backend.domain.member.Member; +import javax.persistence.Column; +import javax.persistence.FetchType; +import javax.persistence.GeneratedValue; +import javax.persistence.GenerationType; +import javax.persistence.Id; +import javax.persistence.JoinColumn; +import javax.persistence.ManyToOne; +import lombok.Getter; + +@Getter +public class FirebaseToken extends BaseEntity { + + @GeneratedValue(strategy = GenerationType.IDENTITY) + @Id + private Long id; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "member_id", updatable = false, nullable = false) + private Member member; + + @Column(name = "token", nullable = false) + private String token; + + protected FirebaseToken() { + } + + public FirebaseToken(Member member, String token) { + this.member = member; + this.token = token; + } +} diff --git a/backend/src/main/java/com/yigongil/backend/infra/FirebaseTokenRepository.java b/backend/src/main/java/com/yigongil/backend/infra/FirebaseTokenRepository.java new file mode 100644 index 000000000..6b2c874ea --- /dev/null +++ b/backend/src/main/java/com/yigongil/backend/infra/FirebaseTokenRepository.java @@ -0,0 +1,13 @@ +package com.yigongil.backend.infra; + +import java.util.List; +import org.springframework.context.annotation.Profile; +import org.springframework.data.repository.Repository; + +@Profile(value = {"dev", "prod"}) +public interface FirebaseTokenRepository extends Repository, TokenRepository { + + void save(FirebaseToken firebaseToken); + + List findAllByMemberId(Long memberId); +} diff --git a/backend/src/main/java/com/yigongil/backend/infra/InMemoryTokenRepository.java b/backend/src/main/java/com/yigongil/backend/infra/InMemoryTokenRepository.java new file mode 100644 index 000000000..293a379c3 --- /dev/null +++ b/backend/src/main/java/com/yigongil/backend/infra/InMemoryTokenRepository.java @@ -0,0 +1,29 @@ +package com.yigongil.backend.infra; + +import java.util.ArrayList; +import java.util.List; +import org.springframework.context.annotation.Profile; +import org.springframework.stereotype.Repository; + +@Profile(value = {"test", "local"}) +@Repository +public class InMemoryTokenRepository implements TokenRepository { + + private final List firebaseTokens; + + public InMemoryTokenRepository() { + this.firebaseTokens = new ArrayList<>(); + } + + @Override + public void save(FirebaseToken firebaseToken) { + firebaseTokens.add(firebaseToken); + } + + @Override + public List findAllByMemberId(Long memberId) { + return firebaseTokens.stream() + .filter(firebaseToken -> firebaseToken.getMember().getId().equals(memberId)) + .toList(); + } +} diff --git a/backend/src/main/java/com/yigongil/backend/infra/LocalMessageSender.java b/backend/src/main/java/com/yigongil/backend/infra/LocalMessageSender.java new file mode 100644 index 000000000..79eff42f8 --- /dev/null +++ b/backend/src/main/java/com/yigongil/backend/infra/LocalMessageSender.java @@ -0,0 +1,31 @@ +package com.yigongil.backend.infra; + +import java.util.HashMap; +import java.util.HashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; +import org.springframework.context.annotation.Profile; +import org.springframework.stereotype.Component; + +@Profile(value = {"test", "local"}) +@Component +public class LocalMessageSender implements MessageSender { + + private final Map> tokensByTopic; + + public LocalMessageSender() { + this.tokensByTopic = new HashMap<>(); + } + + @Override + public void subscribeToTopicAsync(List tokens, String topic) { + Set savedTokens = tokensByTopic.computeIfAbsent(topic, key -> new HashSet<>()); + savedTokens.addAll(tokens); + } + + @Override + public void send(String message, String topic) { + System.out.println("LocalMessageSender: " + message + " to " + topic); + } +} diff --git a/backend/src/main/java/com/yigongil/backend/infra/MessageSender.java b/backend/src/main/java/com/yigongil/backend/infra/MessageSender.java new file mode 100644 index 000000000..fac09566a --- /dev/null +++ b/backend/src/main/java/com/yigongil/backend/infra/MessageSender.java @@ -0,0 +1,10 @@ +package com.yigongil.backend.infra; + +import java.util.List; + +public interface MessageSender { + + void subscribeToTopicAsync(List tokens, String topic); + + void send(String message, String topic); +} diff --git a/backend/src/main/java/com/yigongil/backend/infra/MessagingService.java b/backend/src/main/java/com/yigongil/backend/infra/MessagingService.java new file mode 100644 index 000000000..475c35d47 --- /dev/null +++ b/backend/src/main/java/com/yigongil/backend/infra/MessagingService.java @@ -0,0 +1,29 @@ +package com.yigongil.backend.infra; + +import com.yigongil.backend.domain.event.CertificationCreatedEvent; +import com.yigongil.backend.domain.event.FeedPostCreatedEvent; +import com.yigongil.backend.domain.event.MustDoUpdatedEvent; +import com.yigongil.backend.domain.event.StudyAppliedEvent; +import com.yigongil.backend.domain.event.StudyCreatedEvent; +import com.yigongil.backend.domain.event.StudyPermittedEvent; +import com.yigongil.backend.domain.event.StudyStartedEvent; +import com.yigongil.backend.domain.member.Member; + +public interface MessagingService { + + void registerToken(String token, Member member); + + void registerStudyMaster(StudyCreatedEvent event); + + void sendNotificationOfApplicant(StudyAppliedEvent event); + + void sendNotificationOfPermitted(StudyPermittedEvent event); + + void sendNotificationOfStudyStarted(StudyStartedEvent event); + + void sendNotificationOfMustDoUpdated(MustDoUpdatedEvent event); + + void sendNotificationOfCertificationCreated(CertificationCreatedEvent event); + + void sendNotificationOfFeedPostCreated(FeedPostCreatedEvent event); +} diff --git a/backend/src/main/java/com/yigongil/backend/infra/TokenRepository.java b/backend/src/main/java/com/yigongil/backend/infra/TokenRepository.java new file mode 100644 index 000000000..b43dc3bd1 --- /dev/null +++ b/backend/src/main/java/com/yigongil/backend/infra/TokenRepository.java @@ -0,0 +1,10 @@ +package com.yigongil.backend.infra; + +import java.util.List; + +public interface TokenRepository { + + void save(FirebaseToken firebaseToken); + + List findAllByMemberId(Long memberId); +} diff --git a/backend/src/main/java/com/yigongil/backend/request/MessagingTokenRequest.java b/backend/src/main/java/com/yigongil/backend/request/MessagingTokenRequest.java new file mode 100644 index 000000000..ee148432e --- /dev/null +++ b/backend/src/main/java/com/yigongil/backend/request/MessagingTokenRequest.java @@ -0,0 +1,7 @@ +package com.yigongil.backend.request; + +public record MessagingTokenRequest( + String token +) { + +} diff --git a/backend/src/main/java/com/yigongil/backend/request/TokenRequest.java b/backend/src/main/java/com/yigongil/backend/request/RefreshTokenRequest.java similarity index 67% rename from backend/src/main/java/com/yigongil/backend/request/TokenRequest.java rename to backend/src/main/java/com/yigongil/backend/request/RefreshTokenRequest.java index 1fbfb6c2b..59ced38b9 100644 --- a/backend/src/main/java/com/yigongil/backend/request/TokenRequest.java +++ b/backend/src/main/java/com/yigongil/backend/request/RefreshTokenRequest.java @@ -1,6 +1,6 @@ package com.yigongil.backend.request; -public record TokenRequest( +public record RefreshTokenRequest( String refreshToken ) { diff --git a/backend/src/main/java/com/yigongil/backend/ui/LoginController.java b/backend/src/main/java/com/yigongil/backend/ui/LoginController.java index 9124a077c..401b608af 100644 --- a/backend/src/main/java/com/yigongil/backend/ui/LoginController.java +++ b/backend/src/main/java/com/yigongil/backend/ui/LoginController.java @@ -1,7 +1,11 @@ package com.yigongil.backend.ui; import com.yigongil.backend.application.AuthService; -import com.yigongil.backend.request.TokenRequest; +import com.yigongil.backend.config.auth.Authorization; +import com.yigongil.backend.domain.member.Member; +import com.yigongil.backend.infra.MessagingService; +import com.yigongil.backend.request.MessagingTokenRequest; +import com.yigongil.backend.request.RefreshTokenRequest; import com.yigongil.backend.response.TokenResponse; import com.yigongil.backend.ui.doc.LoginApi; import org.springframework.http.ResponseEntity; @@ -17,9 +21,11 @@ public class LoginController implements LoginApi { private final AuthService authService; + private final MessagingService messagingService; - public LoginController(AuthService authService) { + public LoginController(AuthService authService, MessagingService messagingService) { this.authService = authService; + this.messagingService = messagingService; } @GetMapping("/github/tokens") @@ -28,13 +34,22 @@ public ResponseEntity createMemberToken(@RequestParam String code return ResponseEntity.ok(response); } + @PostMapping("/messaging/tokens") + public ResponseEntity registerDevice( + @Authorization Member member, + @RequestBody MessagingTokenRequest request + ) { + messagingService.registerToken(request.token(), member); + return ResponseEntity.ok().build(); + } + @GetMapping("/tokens/validate") public ResponseEntity validateToken() { return ResponseEntity.ok().build(); } @PostMapping("/tokens/refresh") - public ResponseEntity refreshMemberToken(@RequestBody TokenRequest request) { + public ResponseEntity refreshMemberToken(@RequestBody RefreshTokenRequest request) { TokenResponse response = authService.refresh(request); return ResponseEntity.ok(response); } diff --git a/backend/src/main/java/com/yigongil/backend/ui/doc/LoginApi.java b/backend/src/main/java/com/yigongil/backend/ui/doc/LoginApi.java index f8d1ba490..08593511b 100644 --- a/backend/src/main/java/com/yigongil/backend/ui/doc/LoginApi.java +++ b/backend/src/main/java/com/yigongil/backend/ui/doc/LoginApi.java @@ -1,6 +1,9 @@ package com.yigongil.backend.ui.doc; -import com.yigongil.backend.request.TokenRequest; +import com.yigongil.backend.config.auth.Authorization; +import com.yigongil.backend.domain.member.Member; +import com.yigongil.backend.request.MessagingTokenRequest; +import com.yigongil.backend.request.RefreshTokenRequest; import com.yigongil.backend.response.TokenResponse; import io.swagger.v3.oas.annotations.Hidden; import org.springframework.http.ResponseEntity; @@ -10,7 +13,12 @@ @Hidden public interface LoginApi { + ResponseEntity registerDevice( + @Authorization Member member, + @RequestBody MessagingTokenRequest request + ); + ResponseEntity createMemberToken(@RequestParam String code); - ResponseEntity refreshMemberToken(@RequestBody TokenRequest request); + ResponseEntity refreshMemberToken(@RequestBody RefreshTokenRequest request); } diff --git a/backend/src/test/java/com/yigongil/backend/acceptance/steps/AcceptanceTest.java b/backend/src/test/java/com/yigongil/backend/acceptance/steps/AcceptanceTest.java index e5dc55ba7..0323a892c 100644 --- a/backend/src/test/java/com/yigongil/backend/acceptance/steps/AcceptanceTest.java +++ b/backend/src/test/java/com/yigongil/backend/acceptance/steps/AcceptanceTest.java @@ -1,8 +1,10 @@ package com.yigongil.backend.acceptance.steps; import com.fasterxml.jackson.databind.ObjectMapper; +import com.google.firebase.messaging.FirebaseMessaging; import com.yigongil.backend.BackendApplication; import com.yigongil.backend.config.auth.TokenTheftDetector; +import com.yigongil.backend.infra.MessagingService; import io.cucumber.java.Before; import io.cucumber.spring.CucumberContextConfiguration; import io.restassured.RestAssured; @@ -11,6 +13,8 @@ import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.context.SpringBootTest; import org.springframework.boot.test.context.SpringBootTest.WebEnvironment; +import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.boot.test.mock.mockito.SpyBean; import org.springframework.boot.test.web.server.LocalServerPort; import org.springframework.context.ApplicationContext; import org.springframework.test.context.ContextConfiguration; @@ -32,6 +36,12 @@ public class AcceptanceTest { @Autowired ApplicationContext applicationContext; + @SpyBean + MessagingService messagingService; + + @MockBean + FirebaseMessaging firebaseMessaging; + @Before public void before() { RestAssured.port = port; diff --git a/backend/src/test/java/com/yigongil/backend/acceptance/steps/AuthorizationSteps.java b/backend/src/test/java/com/yigongil/backend/acceptance/steps/AuthorizationSteps.java index 347039041..38fb9c180 100644 --- a/backend/src/test/java/com/yigongil/backend/acceptance/steps/AuthorizationSteps.java +++ b/backend/src/test/java/com/yigongil/backend/acceptance/steps/AuthorizationSteps.java @@ -7,7 +7,7 @@ import com.fasterxml.jackson.core.JsonProcessingException; import com.fasterxml.jackson.databind.ObjectMapper; import com.yigongil.backend.config.auth.JwtTokenProvider; -import com.yigongil.backend.request.TokenRequest; +import com.yigongil.backend.request.RefreshTokenRequest; import com.yigongil.backend.response.TokenResponse; import io.cucumber.java.en.Given; import io.cucumber.java.en.Then; @@ -37,7 +37,7 @@ public AuthorizationSteps(JwtTokenProvider jwtTokenProvider, ObjectMapper object @When("{string}가 토큰이 만료되어 리프레시 토큰을 사용해 새 토큰을 받는다.") public void 토큰_리프레시(String githubId) throws JsonProcessingException { - TokenRequest request = new TokenRequest(sharedContext.getRefresh(githubId)); + RefreshTokenRequest request = new RefreshTokenRequest(sharedContext.getRefresh(githubId)); ExtractableResponse response = given().log().all() .contentType(MediaType.APPLICATION_JSON_VALUE) @@ -66,7 +66,7 @@ public AuthorizationSteps(JwtTokenProvider jwtTokenProvider, ObjectMapper object @Given("{string}가 {string}의 리프레시 토큰을 탈취하여 새 토큰을 받는다.") public void 토큰_탈취(String thief, String victim) throws JsonProcessingException { - TokenRequest request = new TokenRequest(sharedContext.getRefresh(victim)); + RefreshTokenRequest request = new RefreshTokenRequest(sharedContext.getRefresh(victim)); TokenResponse response = given().log().all() .contentType(MediaType.APPLICATION_JSON_VALUE) diff --git a/backend/src/test/java/com/yigongil/backend/acceptance/steps/MemberSteps.java b/backend/src/test/java/com/yigongil/backend/acceptance/steps/MemberSteps.java index 9a7fa3015..cf8a479e9 100644 --- a/backend/src/test/java/com/yigongil/backend/acceptance/steps/MemberSteps.java +++ b/backend/src/test/java/com/yigongil/backend/acceptance/steps/MemberSteps.java @@ -6,6 +6,7 @@ import com.fasterxml.jackson.core.JsonProcessingException; import com.fasterxml.jackson.databind.ObjectMapper; +import com.yigongil.backend.domain.member.Member; import com.yigongil.backend.request.ProfileUpdateRequest; import com.yigongil.backend.response.NicknameValidationResponse; import com.yigongil.backend.response.OnboardingCheckResponse; @@ -45,6 +46,14 @@ public MemberSteps( sharedContext.setTokens(githubId, tokenResponse.accessToken()); sharedContext.setId(githubId, tokenResponse.accessToken()); + Member member = Member.builder() + .id(sharedContext.getId(githubId)) + .githubId(githubId) + .nickname(githubId) + .profileImageUrl("this_is_fake_image_url") + .isOnboardingDone(true) + .build(); + sharedContext.setMember(githubId, member); sharedContext.setRefresh(githubId, tokenResponse.refreshToken()); } diff --git a/backend/src/test/java/com/yigongil/backend/acceptance/steps/MessagingSteps.java b/backend/src/test/java/com/yigongil/backend/acceptance/steps/MessagingSteps.java new file mode 100644 index 000000000..3e698176c --- /dev/null +++ b/backend/src/test/java/com/yigongil/backend/acceptance/steps/MessagingSteps.java @@ -0,0 +1,231 @@ +package com.yigongil.backend.acceptance.steps; + +import static io.restassured.RestAssured.given; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.verify; + +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.yigongil.backend.domain.event.CertificationCreatedEvent; +import com.yigongil.backend.domain.event.MustDoUpdatedEvent; +import com.yigongil.backend.domain.event.StudyAppliedEvent; +import com.yigongil.backend.domain.event.StudyPermittedEvent; +import com.yigongil.backend.domain.event.StudyStartedEvent; +import com.yigongil.backend.domain.round.RoundStatus; +import com.yigongil.backend.infra.MessagingService; +import com.yigongil.backend.request.CertificationCreateRequest; +import com.yigongil.backend.request.FeedPostCreateRequest; +import com.yigongil.backend.request.MessagingTokenRequest; +import com.yigongil.backend.request.MustDoUpdateRequest; +import com.yigongil.backend.request.StudyStartRequest; +import com.yigongil.backend.response.MembersCertificationResponse; +import com.yigongil.backend.response.RoundResponse; +import io.cucumber.java.en.Then; +import io.restassured.response.ExtractableResponse; +import io.restassured.response.Response; +import java.util.Arrays; +import java.util.List; +import org.springframework.http.HttpHeaders; +import org.springframework.http.HttpStatus; +import org.springframework.http.MediaType; + +public class MessagingSteps { + + private final ObjectMapper objectMapper; + private final SharedContext sharedContext; + private final MessagingService messagingService; + + public MessagingSteps(ObjectMapper objectMapper, SharedContext sharedContext, + MessagingService messagingService) { + this.objectMapper = objectMapper; + this.sharedContext = sharedContext; + this.messagingService = messagingService; + } + + @Then("{string}가 토큰을 메시지 서비스에 등록한다.") + public void 토큰_등록(String githubId) throws JsonProcessingException { + String token = sharedContext.getToken(githubId); + MessagingTokenRequest request = new MessagingTokenRequest(token); + given().log() + .all() + .contentType(MediaType.APPLICATION_JSON_VALUE) + .body(objectMapper.writeValueAsString(request)) + .header("Authorization", token) + .when() + .post("/login/fake/messaging/tokens") + .then() + .statusCode(HttpStatus.OK.value()) + .log() + .all(); + + verify(messagingService).registerToken(token, sharedContext.getMember(githubId)); + } + + @Then("{string}가 {string}의 {string} 스터디 신청 알림을 받는다.") + public void 신청_알림_발송(String masterGithubId, String applicantGithubId, String studyName) { + String token = sharedContext.getToken(applicantGithubId); + ExtractableResponse response = given().log() + .all() + .header(HttpHeaders.AUTHORIZATION, token) + .when() + .post("/studies/" + + sharedContext.getParameter( + studyName) + "/applicants") + .then() + .log() + .all() + .extract(); + + sharedContext.setResponse(response); + StudyAppliedEvent appliedEvent = new StudyAppliedEvent( + sharedContext.getId(masterGithubId), + studyName, + applicantGithubId + ); + verify(messagingService).sendNotificationOfApplicant(appliedEvent); + } + + @Then("{string}가 {string}의 신청을 수락하고, {string} 스터디 신청 수락 알림을 받는다.") + public void 신청수락_알림_발송( + String masterGithubId, + String permittedMemberGithubId, + String studyName + ) { + Object studyId = sharedContext.getParameter(studyName); + Object memberId = sharedContext.getParameter(permittedMemberGithubId); + + given().log().all() + .header(HttpHeaders.AUTHORIZATION, sharedContext.getToken(masterGithubId)) + .when() + .patch("/studies/{studyId}/applicants/{memberId}", studyId, memberId) + .then() + .log().all() + .extract(); + + StudyPermittedEvent studyPermittedEvent = new StudyPermittedEvent( + Long.parseLong(String.valueOf(memberId)), + Long.parseLong(String.valueOf(studyId)), + studyName + ); + verify(messagingService).sendNotificationOfPermitted(studyPermittedEvent); + } + + @Then("{string}가 {string} 스터디를 {string}에 진행되도록 하여 시작한다. 스터디 시작 알림이 발송된다.") + public void 스터디시작_알림_발송(String masterGithubId, String studyName, String days) { + String token = sharedContext.getToken(masterGithubId); + String studyId = (String) sharedContext.getParameter(studyName); + StudyStartRequest request = new StudyStartRequest( + Arrays.stream(days.split(",")).map(String::strip).toList()); + + given().log().all() + .header(HttpHeaders.AUTHORIZATION, token) + .contentType(MediaType.APPLICATION_JSON_VALUE) + .body(request) + .when() + .patch("/studies/" + studyId + "/start") + .then().log().all(); + + StudyStartedEvent studyStartedEvent = new StudyStartedEvent( + Long.parseLong(studyId), + studyName + ); + verify(messagingService).sendNotificationOfStudyStarted(studyStartedEvent); + } + + @Then("{string}가 {string} 스터디의 현재 주차에 {string}라는 머스트두를 추가한다. 머스트두 등록 알림이 발송된다.") + public void 머스트두등록_알림_발송(String masterGithubId, String studyName, String mustDoContent) { + String token = sharedContext.getToken(masterGithubId); + String studyId = (String) sharedContext.getParameter(studyName); + + MembersCertificationResponse membersCertificationResponse = given().log().all() + .header(HttpHeaders.AUTHORIZATION, + token) + .when() + .get("/studies/" + + studyId + + "/certifications") + .then().log().all() + .extract() + .as(MembersCertificationResponse.class); + + List roundResponses = given().log().all() + .header(HttpHeaders.AUTHORIZATION, token) + .when() + .get("/studies/" + studyId + + "/rounds?weekNumber=" + + membersCertificationResponse.upcomingRound() + .weekNumber()) + .then().log().all() + .extract() + .response() + .jsonPath().getList(".", RoundResponse.class); + + RoundResponse round = roundResponses.stream() + .filter(roundResponse -> roundResponse.status() + == RoundStatus.IN_PROGRESS) + .findAny() + .get(); + + MustDoUpdateRequest request = new MustDoUpdateRequest(mustDoContent); + + given().log().all() + .contentType(MediaType.APPLICATION_JSON_VALUE) + .body(request) + .header(HttpHeaders.AUTHORIZATION, token) + .when() + .put("/rounds/{roundId}/todos", round.id()) + .then().log().all(); + + MustDoUpdatedEvent mustDoUpdatedEvent = new MustDoUpdatedEvent( + Long.valueOf(studyId), + studyName, + mustDoContent + ); + verify(messagingService).sendNotificationOfMustDoUpdated(mustDoUpdatedEvent); + } + + @Then("{string}가 {string}스터디 피드에 {string}의 글을 작성한다. 피드 등록 알림이 발송된다.") + public void 피드등록_알림_발송(String memberGithubId, String studyName, String feedContent) { + String token = sharedContext.getToken(memberGithubId); + FeedPostCreateRequest request = new FeedPostCreateRequest(feedContent, + "https://yigongil.png"); + + given().log().all() + .header(HttpHeaders.AUTHORIZATION, token) + .contentType(MediaType.APPLICATION_JSON_VALUE) + .body(request) + .when() + .post("/studies/{studyId}/feeds", sharedContext.getParameter(studyName)) + .then().log().all() + .statusCode(HttpStatus.OK.value()); + + verify(messagingService).sendNotificationOfFeedPostCreated(any()); + } + + @Then("{string}가 {string}스터디 피드에 {string}의 인증 글을 작성한다. 인증 등록 알림이 발송된다.") + public void 인증등록_알림_발송(String memberGithubId, String studyName, String certificationContent) { + String token = sharedContext.getToken(memberGithubId); + String studyId = (String) sharedContext.getParameter(studyName); + + CertificationCreateRequest request = new CertificationCreateRequest( + certificationContent, + "https://yigongil.png" + ); + + given().log().all() + .header(HttpHeaders.AUTHORIZATION, token) + .contentType("application/json") + .body(request) + .when() + .post("/studies/{studyId}/certifications", studyId) + .then().log().all() + .statusCode(HttpStatus.CREATED.value()); + + CertificationCreatedEvent certificationCreatedEvent = new CertificationCreatedEvent( + Long.valueOf(studyId), + studyName, + memberGithubId + ); + verify(messagingService).sendNotificationOfCertificationCreated(certificationCreatedEvent); + } +} diff --git a/backend/src/test/java/com/yigongil/backend/acceptance/steps/SharedContext.java b/backend/src/test/java/com/yigongil/backend/acceptance/steps/SharedContext.java index ebbbf8b6e..5593210c3 100644 --- a/backend/src/test/java/com/yigongil/backend/acceptance/steps/SharedContext.java +++ b/backend/src/test/java/com/yigongil/backend/acceptance/steps/SharedContext.java @@ -1,6 +1,7 @@ package com.yigongil.backend.acceptance.steps; import com.yigongil.backend.config.auth.JwtTokenProvider; +import com.yigongil.backend.domain.member.Member; import io.cucumber.spring.ScenarioScope; import io.restassured.response.ExtractableResponse; import io.restassured.response.Response; @@ -17,6 +18,7 @@ public class SharedContext { private ExtractableResponse response; private final Map parameters = new HashMap<>(); + private final Map members = new HashMap<>(); private final Map tokens = new HashMap<>(); @Autowired @@ -65,4 +67,12 @@ public void setTokens(String userName, String token) { public String getToken(String userName) { return tokens.get(userName); } + + public void setMember(final String githubId, final Member member) { + members.put(githubId, member); + } + + public Member getMember(final String githubId) { + return members.get(githubId); + } } diff --git a/backend/src/test/java/com/yigongil/backend/application/MustDoServiceTest.java b/backend/src/test/java/com/yigongil/backend/application/MustDoServiceTest.java index 1ae6eb943..6b3f349b3 100644 --- a/backend/src/test/java/com/yigongil/backend/application/MustDoServiceTest.java +++ b/backend/src/test/java/com/yigongil/backend/application/MustDoServiceTest.java @@ -8,8 +8,10 @@ import com.yigongil.backend.domain.round.Round; import com.yigongil.backend.domain.round.RoundRepository; import com.yigongil.backend.domain.roundofmember.RoundOfMember; +import com.yigongil.backend.domain.study.Study; import com.yigongil.backend.exception.InvalidTodoLengthException; import com.yigongil.backend.fixture.MemberFixture; +import com.yigongil.backend.fixture.StudyFixture; import com.yigongil.backend.request.MustDoUpdateRequest; import java.util.List; import java.util.Optional; @@ -40,8 +42,12 @@ void setUp() { RoundOfMember roundOfMember = RoundOfMember.builder() .member(member) .build(); + + Study study = StudyFixture.자바_스터디_모집중_ID_1.toStudyWithMaster(member); + round = Round.builder() .id(3L) + .study(study) .roundOfMembers(List.of(roundOfMember)) .master(member) .mustDo(null) diff --git a/backend/src/test/java/com/yigongil/backend/application/StudyServiceTest.java b/backend/src/test/java/com/yigongil/backend/application/StudyServiceTest.java index 3c6b4ce1a..33328fe77 100644 --- a/backend/src/test/java/com/yigongil/backend/application/StudyServiceTest.java +++ b/backend/src/test/java/com/yigongil/backend/application/StudyServiceTest.java @@ -4,6 +4,7 @@ import static org.mockito.BDDMockito.given; import static org.mockito.Mockito.mock; +import com.yigongil.backend.domain.meetingdayoftheweek.MeetingDayOfTheWeekRepository; import com.yigongil.backend.domain.member.Member; import com.yigongil.backend.domain.round.RoundRepository; import com.yigongil.backend.domain.study.StudyRepository; @@ -29,7 +30,8 @@ class StudyServiceTest { studyMemberRepository, mock(CertificationService.class), mock(FeedService.class), - mock(RoundRepository.class) + mock(RoundRepository.class), + mock(MeetingDayOfTheWeekRepository.class) ); @Nested diff --git a/backend/src/test/java/com/yigongil/backend/domain/round/RoundTest.java b/backend/src/test/java/com/yigongil/backend/domain/round/RoundTest.java index 9001a9230..c263be084 100644 --- a/backend/src/test/java/com/yigongil/backend/domain/round/RoundTest.java +++ b/backend/src/test/java/com/yigongil/backend/domain/round/RoundTest.java @@ -1,18 +1,19 @@ package com.yigongil.backend.domain.round; +import static com.yigongil.backend.fixture.RoundFixture.아이디_삼_머스트두없는_라운드; +import static com.yigongil.backend.fixture.RoundFixture.아이디없는_라운드; +import static com.yigongil.backend.fixture.StudyFixture.자바_스터디_모집중_ID_1; import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatThrownBy; import com.yigongil.backend.domain.member.Member; import com.yigongil.backend.exception.InvalidTodoLengthException; import com.yigongil.backend.exception.NotStudyMasterException; -import com.yigongil.backend.fixture.RoundFixture; import com.yigongil.backend.fixture.RoundOfMemberFixture; import org.assertj.core.api.ThrowableAssert.ThrowingCallable; import org.junit.jupiter.api.Nested; import org.junit.jupiter.api.Test; - class RoundTest { @Nested @@ -21,7 +22,7 @@ class 머스트두_생성 { @Test void 정상생성한다() { //given - Round round = RoundFixture.아이디_삼_머스트두없는_라운드.toRound(); + Round round = 아이디_삼_머스트두없는_라운드.toRoundWithStudy(자바_스터디_모집중_ID_1.toStudy()); //then round.updateMustDo(round.getMaster(), " 머스트두"); @@ -33,7 +34,7 @@ class 머스트두_생성 { @Test void 스터디_마스터가_아니라면_예외를_던진다() { //given - Round round = RoundFixture.아이디_삼_머스트두없는_라운드.toRound(); + Round round = 아이디_삼_머스트두없는_라운드.toRound(); final Member another = Member.builder() .id(3L) .introduction("소개") @@ -50,10 +51,11 @@ class 머스트두_생성 { @Test void 길이가_20자를_넘으면_예외를_던진다() { //given - final Round round = RoundFixture.아이디_삼_머스트두없는_라운드.toRound(); + final Round round = 아이디_삼_머스트두없는_라운드.toRound(); //when - ThrowingCallable throwable = () -> round.updateMustDo(round.getMaster(), "굉장히긴머스트두길이입니다이십자도넘을수도있어"); + ThrowingCallable throwable = () -> round.updateMustDo(round.getMaster(), + "굉장히긴머스트두길이입니다이십자도넘을수도있어"); //then assertThatThrownBy(throwable).isInstanceOf(InvalidTodoLengthException.class); @@ -63,7 +65,7 @@ class 머스트두_생성 { @Test void 멤버의_머스트두_완료여부를_반환한다() { //given - Round round = RoundFixture.아이디없는_라운드.toRound(); + Round round = 아이디없는_라운드.toRound(); Member master = round.getMaster(); //when @@ -79,7 +81,7 @@ class 머스트두_진행률_계산 { @Test void 멤버들의_진행률을_계산한다() { //given - Round round = RoundFixture.아이디없는_라운드.toRoundWithRoundOfMember( + Round round = 아이디없는_라운드.toRoundWithRoundOfMember( RoundOfMemberFixture.노이만_라오멤, RoundOfMemberFixture.노이만_라오멤, RoundOfMemberFixture.노이만_라오멤 @@ -96,7 +98,7 @@ class 머스트두_진행률_계산 { @Test void 머스트두를_완료한_멤버가_없다() { //given - Round round = RoundFixture.아이디없는_라운드.toRoundWithRoundOfMember( + Round round = 아이디없는_라운드.toRoundWithRoundOfMember( RoundOfMemberFixture.노이만_라오멤, RoundOfMemberFixture.노이만_라오멤, RoundOfMemberFixture.노이만_라오멤 diff --git a/backend/src/test/java/com/yigongil/backend/fake/FakeController.java b/backend/src/test/java/com/yigongil/backend/fake/FakeController.java index bc7b5ed20..c340b7b0e 100644 --- a/backend/src/test/java/com/yigongil/backend/fake/FakeController.java +++ b/backend/src/test/java/com/yigongil/backend/fake/FakeController.java @@ -1,9 +1,12 @@ package com.yigongil.backend.fake; import com.yigongil.backend.application.StudyService; +import com.yigongil.backend.config.auth.Authorization; import com.yigongil.backend.config.auth.JwtTokenProvider; import com.yigongil.backend.domain.member.Member; import com.yigongil.backend.domain.member.MemberRepository; +import com.yigongil.backend.infra.MessagingService; +import com.yigongil.backend.request.MessagingTokenRequest; import com.yigongil.backend.response.TokenResponse; import java.time.LocalDate; import java.util.Optional; @@ -12,7 +15,9 @@ import org.springframework.http.MediaType; import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.PutMapping; +import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.bind.annotation.RestController; @@ -23,11 +28,13 @@ public class FakeController { private final MemberRepository memberRepository; private final JwtTokenProvider jwtTokenProvider; private final StudyService studyService; + private final MessagingService messagingService; - public FakeController(MemberRepository memberRepository, JwtTokenProvider jwtTokenProvider, StudyService studyService) { + public FakeController(MemberRepository memberRepository, JwtTokenProvider jwtTokenProvider, StudyService studyService, final MessagingService messagingService) { this.memberRepository = memberRepository; this.jwtTokenProvider = jwtTokenProvider; this.studyService = studyService; + this.messagingService = messagingService; } @GetMapping("/login/fake/tokens") @@ -45,6 +52,15 @@ public ResponseEntity createFakeToken(@RequestParam String github return ResponseEntity.ok().header(HttpHeaders.CONTENT_TYPE, MediaType.APPLICATION_JSON_VALUE).body(new TokenResponse(jwtTokenProvider.createAccessToken(id), jwtTokenProvider.createRefreshToken(id))); } + @PostMapping("/login/fake/messaging/tokens") + public ResponseEntity registerDevice( + @Authorization Member member, + @RequestBody MessagingTokenRequest request + ) { + messagingService.registerToken(request.token(), member); + return ResponseEntity.ok().build(); + } + @PutMapping("/fake/proceed") public ResponseEntity proceed(@RequestParam Integer days) { for (int i = 1; i <= days; i++) { diff --git a/backend/src/test/java/com/yigongil/backend/fixture/RoundFixture.java b/backend/src/test/java/com/yigongil/backend/fixture/RoundFixture.java index c892b6249..989e97c19 100644 --- a/backend/src/test/java/com/yigongil/backend/fixture/RoundFixture.java +++ b/backend/src/test/java/com/yigongil/backend/fixture/RoundFixture.java @@ -3,6 +3,7 @@ import com.yigongil.backend.domain.member.Member; import com.yigongil.backend.domain.round.Round; import com.yigongil.backend.domain.roundofmember.RoundOfMember; +import com.yigongil.backend.domain.study.Study; import java.util.ArrayList; import java.util.Arrays; import java.util.List; @@ -39,6 +40,16 @@ public Round toRound() { .build(); } + public Round toRoundWithStudy(Study study) { + return Round.builder() + .id(id) + .study(study) + .mustDo(content) + .master(master) + .roundOfMembers(new ArrayList<>(List.of(RoundOfMemberFixture.김진우_라운드_삼.toRoundOfMember(), RoundOfMemberFixture.노이만_라오멤.toRoundOfMember()))) + .build(); + } + public Round toRoundWithRoundOfMember(RoundOfMemberFixture... roundOfMemberFixtures) { List roundOfMembers = Arrays.stream(roundOfMemberFixtures) .map(RoundOfMemberFixture::toRoundOfMember) diff --git a/backend/src/test/java/com/yigongil/backend/fixture/StudyFixture.java b/backend/src/test/java/com/yigongil/backend/fixture/StudyFixture.java index 8a6745942..a2512a32f 100644 --- a/backend/src/test/java/com/yigongil/backend/fixture/StudyFixture.java +++ b/backend/src/test/java/com/yigongil/backend/fixture/StudyFixture.java @@ -12,6 +12,7 @@ public enum StudyFixture { 자바_스터디_모집중(null, LocalDateTime.now(), "자바", "스터디소개", ProcessingStatus.RECRUITING, 4, 2, 4), + 자바_스터디_모집중_ID_1(1L, LocalDateTime.now(), "자바", "스터디소개", ProcessingStatus.RECRUITING, 4, 2, 4), 자바_스터디_모집중_정원_2(null, LocalDateTime.now(), "자바", "스터디소개", ProcessingStatus.RECRUITING, 2, 2, 4) ; diff --git a/backend/src/test/resources/features/notification-test.feature b/backend/src/test/resources/features/notification-test.feature new file mode 100644 index 000000000..73204242b --- /dev/null +++ b/backend/src/test/resources/features/notification-test.feature @@ -0,0 +1,16 @@ +Feature: 알림을 발송한다. + + Scenario: 스터디의 과정에서 알림을 발송한다. + Given "jinwoo"의 깃허브 아이디로 회원가입을 한다. + Given "jinwoo"가 토큰을 메시지 서비스에 등록한다. + Given "jinwoo"가 제목-"자바1", 정원-"6"명, 최소 주차-"7"주, 주당 진행 횟수-"3"회, 소개-"스터디소개1"로 스터디를 개설한다. + + Given "noiman"의 깃허브 아이디로 회원가입을 한다. + Given "noiman"가 토큰을 메시지 서비스에 등록한다. + + Then "jinwoo"가 "noiman"의 "자바1" 스터디 신청 알림을 받는다. + Then "jinwoo"가 "noiman"의 신청을 수락하고, "자바1" 스터디 신청 수락 알림을 받는다. + Then "jinwoo"가 "자바1" 스터디를 "MONDAY"에 진행되도록 하여 시작한다. 스터디 시작 알림이 발송된다. + Then "jinwoo"가 "자바1" 스터디의 현재 주차에 "이번주 머두"라는 머스트두를 추가한다. 머스트두 등록 알림이 발송된다. + Then "jinwoo"가 "자바1"스터디 피드에 "내용"의 인증 글을 작성한다. 인증 등록 알림이 발송된다. + Then "jinwoo"가 "자바1"스터디 피드에 "내용"의 글을 작성한다. 피드 등록 알림이 발송된다.