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

Ignore: ✨ Push Notifications for Users Not in Chat Room or Chat Room List View #204

Merged
merged 18 commits into from
Nov 20, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
1558e48
feat: add terrible code to chat_message_send_event_listener
psychology50 Nov 14, 2024
a51fad0
Merge branch 'dev' into feat/PW-616-chat-message-realy
psychology50 Nov 18, 2024
48bc884
feat: find user_ids_by_chatroom_id in chat_member_repository
psychology50 Nov 18, 2024
127e236
refactor: separate business logic from listener to service class
psychology50 Nov 18, 2024
1b59f1a
feat: impl domain service to determine push notification target member
psychology50 Nov 18, 2024
f7d72a9
feat: chat_message_relay_service in socket module
psychology50 Nov 18, 2024
a88d885
fix: is_activated method in device token entity add rule about last_s…
psychology50 Nov 18, 2024
da6f195
refactor: seperate device_logic from application service to domain se…
psychology50 Nov 18, 2024
e4df142
rename: upgrade javadoc what chat_message_relay_service doesn't consi…
psychology50 Nov 18, 2024
71f99fb
feat: add notify enable/disable method in chat_member
psychology50 Nov 18, 2024
874a5a2
test: add static method in chat_room & user fixture for custom setting
psychology50 Nov 18, 2024
c81ed36
test: impl bdd flow builder pattern
psychology50 Nov 18, 2024
740d81e
fix: set notify_enabled field to default value in chat_member entity
psychology50 Nov 19, 2024
87af69b
test: impl test builder flow
psychology50 Nov 19, 2024
13ba99f
fix: modify business rule: if even one session is looking chat_room, …
psychology50 Nov 19, 2024
ad65995
rename: delete logs in chat_notification_coordinator_service
psychology50 Nov 19, 2024
f8edab7
test: add unit test
psychology50 Nov 19, 2024
e29e9dd
rename: add domain rule in javadoc
psychology50 Nov 19, 2024
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
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
Loading