Skip to content

Commit

Permalink
feat: ✨ Push Notifications for Users Not in Chat Room or Chat Room Li…
Browse files Browse the repository at this point in the history
…st View (#204)

* feat: add terrible code to chat_message_send_event_listener

* feat: find user_ids_by_chatroom_id in chat_member_repository

* refactor: separate business logic from listener to service class

* feat: impl domain service to determine push notification target member

* feat: chat_message_relay_service in socket module

* fix: is_activated method in device token entity add rule about last_signed_in_at

* refactor: seperate device_logic from application service to domain service

* rename: upgrade javadoc what chat_message_relay_service doesn't consider about retry logic

* feat: add notify enable/disable method in chat_member

* test: add static method in chat_room & user fixture for custom setting

* test: impl bdd flow builder pattern

* fix: set notify_enabled field to default value in chat_member entity

* test: impl test builder flow

* fix: modify business rule: if even one session is looking chat_room, all of user's sessions exclude

* rename: delete logs in chat_notification_coordinator_service

* test: add unit test

* rename: add domain rule in javadoc
  • Loading branch information
psychology50 authored Nov 20, 2024
1 parent 04700fc commit d40b450
Show file tree
Hide file tree
Showing 11 changed files with 1,396 additions and 1 deletion.
Original file line number Diff line number Diff line change
Expand Up @@ -51,8 +51,15 @@ public static DeviceToken of(String token, String deviceId, String deviceName, U
return new DeviceToken(token, deviceId, deviceName, Boolean.TRUE, user);
}

/**
* 디바이스 토큰이 활성화되었는지 확인한다.
*
* @return 토큰이 활성화 되었고, 마지막 로그인 시간이 7일 이내이면 true, 그렇지 않으면 false
*/
public Boolean isActivated() {
return activated;
LocalDateTime now = LocalDateTime.now();

return activated && lastSignedInAt.plusDays(7).isAfter(now);
}

public void activate() {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,7 @@ protected ChatMember(String name, User user, ChatRoom chatRoom, ChatMemberRole r
this.user = user;
this.chatRoom = chatRoom;
this.role = role;
this.notifyEnabled = true;
}

public static ChatMember of(User user, ChatRoom chatRoom, ChatMemberRole role) {
Expand Down Expand Up @@ -97,6 +98,14 @@ public boolean isBannedMember() {
return deletedAt != null && banned;
}

public void enableNotify() {
this.notifyEnabled = true;
}

public void disableNotify() {
this.notifyEnabled = false;
}

public void ban() {
this.banned = true;
this.deletedAt = LocalDateTime.now();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -32,4 +32,8 @@ public interface ChatMemberRepository extends ExtendedRepository<ChatMember, Lon
@Transactional(readOnly = true)
@Query("SELECT cm.chatRoom.id FROM ChatMember cm WHERE cm.user.id = :userId AND cm.deletedAt IS NULL")
Set<Long> findChatRoomIdsByUserId(Long userId);

@Transactional(readOnly = true)
@Query("SELECT cm.user.id FROM ChatMember cm WHERE cm.chatRoom.id = :chatRoomId AND cm.deletedAt IS NULL")
Set<Long> findUserIdsByChatRoomId(Long chatRoomId);
}
Original file line number Diff line number Diff line change
Expand Up @@ -114,6 +114,11 @@ public Set<Long> readChatRoomIdsByUserId(Long userId) {
return chatMemberRepository.findChatRoomIdsByUserId(userId);
}

@Transactional(readOnly = true)
public Set<Long> readUserIdsByChatRoomId(Long chatRoomId) {
return chatMemberRepository.findUserIdsByChatRoomId(chatRoomId);
}

/**
* 채팅방에 해당 유저가 존재하는지 확인한다.
* 이 때, 삭제된 사용자 데이터는 조회하지 않는다.
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
package kr.co.pennyway.domain.services.chat.context;

import java.util.List;

public record ChatPushNotificationContext(
String senderName,
String senderImageUrl,
List<String> deviceTokens
) {
public static ChatPushNotificationContext of(String senderName, String senderImageUrl, List<String> deviceTokens) {
return new ChatPushNotificationContext(senderName, senderImageUrl, deviceTokens);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,154 @@
package kr.co.pennyway.domain.services.chat.service;

import kr.co.pennyway.common.annotation.DomainService;
import kr.co.pennyway.domain.common.redis.session.UserSession;
import kr.co.pennyway.domain.common.redis.session.UserSessionService;
import kr.co.pennyway.domain.common.redis.session.UserStatus;
import kr.co.pennyway.domain.domains.device.domain.DeviceToken;
import kr.co.pennyway.domain.domains.device.service.DeviceTokenService;
import kr.co.pennyway.domain.domains.member.domain.ChatMember;
import kr.co.pennyway.domain.domains.member.service.ChatMemberService;
import kr.co.pennyway.domain.domains.user.domain.User;
import kr.co.pennyway.domain.domains.user.service.UserService;
import kr.co.pennyway.domain.services.chat.context.ChatPushNotificationContext;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.transaction.annotation.Transactional;

import java.util.*;
import java.util.stream.Collectors;

@Slf4j
@DomainService
@RequiredArgsConstructor
public class ChatNotificationCoordinatorService {
private final UserService userService;
private final ChatMemberService chatMemberService;
private final DeviceTokenService deviceTokenService;

private final UserSessionService userSessionService;

/**
* 채팅방에 참여 중인 사용자들 중에서 푸시 알림을 받아야 하는 사용자들을 판별합니다.
* <pre>
* [판별 기준]
* - 전송자는 푸시 알림을 받지 않습니다.
* - 채팅방에 참여 중인 사용자 중에서 채팅방 리스트 뷰를 보고 있지 않는 사용자들만 필터링합니다.
* - 사용자 세션 중 하나라도 해당 채팅방 뷰를 보고 있는 경우, 해당 사용자의 전체 세션을 제외합니다.
* - 채팅방에 참여 중인 사용자 중에서 채팅 알림을 받지 않는 사용자들은 제외합니다.
* - 채팅방에 참여 중인 사용자 중에서 채팅방의 알림을 받지 않는 사용자들은 제외합니다.
* </pre>
*
* @param senderId Long 전송자 아이디. Must not be null.
* @param chatRoomId Long 채팅방 아이디. Must not be null.
* @return {@link ChatPushNotificationContext} 전송자와 푸시 알림을 받아야 하는 사용자들의 정보를 담은 컨텍스트
* @throws IllegalArgumentException 전송자 정보를 찾을 수 없을 때 발생합니다.
*/
@Transactional(readOnly = true)
public ChatPushNotificationContext determineRecipients(Long senderId, Long chatRoomId) {
User sender = userService.readUser(senderId).orElseThrow(() -> new IllegalArgumentException("전송자 정보를 찾을 수 없습니다."));

Map<Long, Set<UserSession>> participants = getUserSessionGroupByUserId(senderId, chatRoomId);

Set<UserSession> targets = filterNotificationEnabledUserSessions(participants, chatRoomId);

List<String> deviceTokens = getDeviceTokens(targets);

return ChatPushNotificationContext.of(sender.getName(), sender.getProfileImageUrl(), deviceTokens);
}

/**
* <pre>
* [STEP]
* 1. 채팅방에 참여 중인 사용자 세션들을 가져옴 (사용자 별로 여러 세션이 존재할 수 있음)
* 2. 사용자 세션 중에서 전송자는 제외하고, 채팅방에 참여 중 혹은 채팅방 리스트 뷰를 보고 있지 않은 사용자들만 필터링
* 3. 사용자 세션을 사용자 아이디 별로 그룹핑
* 4. 사용자 세션 중 하나라도 해당 채팅방에 참여 중인 경우, 해당 사용자의 전체 세션 제외
* </pre>
*
* @return 사용자 아이디 별로 사용자 세션들을 그룹핑한 맵
*/
private Map<Long, Set<UserSession>> getUserSessionGroupByUserId(Long senderId, Long chatRoomId) {
Set<Long> userIds = chatMemberService.readUserIdsByChatRoomId(chatRoomId);

List<Map<String, UserSession>> userSessions = userIds.stream()
.filter(userId -> !userId.equals(senderId))
.map(userSessionService::readAll)
.toList();

Map<Long, Set<UserSession>> sessions = userSessions.stream()
.flatMap(userSessionMap -> userSessionMap.entrySet().stream())
.filter(entry -> isTargetStatus(entry, chatRoomId))
.collect(Collectors.groupingBy(entry -> entry.getValue().getUserId(), Collectors.mapping(Map.Entry::getValue, Collectors.toSet())));

sessions.entrySet().removeIf(entry -> entry.getValue().stream().anyMatch(userSession -> isExistsViewingChatRoom(Map.entry(entry.getKey(), userSession), chatRoomId)));

return sessions;
}

/**
* 사용자 세션의 상태가 푸시 알림을 받아야 하는 상태인지 판별합니다.
*
* @return '채팅방 리스트 뷰'를 보고 있지 않은 경우 false를 반환합니다.
*/
private boolean isTargetStatus(Map.Entry<String, UserSession> entry, Long chatRoomId) {
return !(UserStatus.ACTIVE_CHAT_ROOM_LIST.equals(entry.getValue().getStatus()));
}

/**
* chatRoomId에 해당하는 채팅방을 보고 있는 사용자 세션이 존재하는지 판별합니다.
*/
private boolean isExistsViewingChatRoom(Map.Entry<Long, UserSession> entry, Long chatRoomId) {
return UserStatus.ACTIVE_CHAT_ROOM.equals(entry.getValue().getStatus()) && chatRoomId.equals(entry.getValue().getCurrentChatRoomId());
}

/**
* <pre>
* [STEP]
* 1. 사용자 아이디로 채팅 알림 off 여부 판단. 만약 false면, 해당 사용자는 모두 제외
* 2. 사용자 아이디로 채팅방의 알림 off 여부 판단. 만약 false면, 해당 사용자는 모두 제외
* 3. 사용자 아이디로 디바이스 토큰을 가져옴
* </pre>
*
* @return 푸시 알림을 받아야 하는 사용자 세션들
*/
private Set<UserSession> filterNotificationEnabledUserSessions(Map<Long, Set<UserSession>> participants, Long chatRoomId) {
return participants.entrySet().stream()
.filter(entry -> isChatNotifyEnabled(entry.getKey())) // N개 쿼리 발생
.filter(entry -> isChatRoomNotifyEnabled(entry.getKey(), chatRoomId)) // N개 쿼리 발생
.flatMap(entry -> entry.getValue().stream())
.collect(Collectors.toUnmodifiableSet());
}

private boolean isChatNotifyEnabled(Long userId) {
Optional<User> user = userService.readUser(userId);

return user.isPresent() && user.get().getNotifySetting().isChatNotify();
}

private boolean isChatRoomNotifyEnabled(Long userId, Long chatRoomId) {
Optional<ChatMember> chatMember = chatMemberService.readChatMember(userId, chatRoomId);

return chatMember.isPresent() && chatMember.get().isNotifyEnabled();
}

/**
* 사용자 세션들 중에서 기기별 활성화된 디바이스 토큰들을 가져옵니다.
*
* @return 활성화된 디바이스 토큰들
*/
private List<String> getDeviceTokens(Iterable<UserSession> targets) {
List<String> deviceTokens = new ArrayList<>();

for (UserSession target : targets) {
deviceTokenService.readAllByUserId(target.getUserId()).stream()
.filter(DeviceToken::isActivated)
.filter(deviceToken -> deviceToken.getDeviceId().equals(target.getDeviceId()))
.findFirst()
.map(DeviceToken::getToken)
.ifPresent(deviceTokens::add);
}

return deviceTokens;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -27,4 +27,14 @@ public ChatRoom toEntity() {
.password(password != null ? Integer.valueOf(password) : null)
.build();
}

public ChatRoom toEntityWithId(Long id) {
return ChatRoom.builder()
.id(id)
.title(title)
.description(description)
.backgroundImageUrl(backgroundImageUrl)
.password(password != null ? Integer.valueOf(password) : null)
.build();
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
import kr.co.pennyway.domain.domains.user.type.ProfileVisibility;
import kr.co.pennyway.domain.domains.user.type.Role;
import lombok.Getter;
import org.springframework.test.util.ReflectionTestUtils;

@Getter
public enum UserFixture {
Expand Down Expand Up @@ -46,4 +47,20 @@ public User toUser() {
.locked(locked)
.build();
}

public User toUserWithCustomSetting(Long id, String username, String name, NotifySetting notifySetting) {
User user = User.builder()
.username(username)
.password(password)
.name(name)
.phone(phone)
.role(role)
.profileVisibility(profileVisibility)
.notifySetting(notifySetting)
.locked(locked)
.build();
ReflectionTestUtils.setField(user, "id", id);

return user;
}
}
Loading

0 comments on commit d40b450

Please sign in to comment.