Skip to content

Commit

Permalink
feat: ✨ 미확인 푸시 알림 읽음 처리 API (#136)
Browse files Browse the repository at this point in the history
* test: notification update read_at 메서드 unit 테스트

* test: save 3번 -> save_all 수정

* test: notification repository unit test 파일 통합

* test: 미확인 알림 조회 메서드 테스트

* feat: 미확인 알림 리스트 조회 메서드 쿼리 추가

* style: count 파라미터 순서 변경

* feat: notification service 메서드 count, bulk update 메서드 추가

* feat: notification manager 구현

* feat: 읽음 요청 dto 정의

* feat: notification 읽음 처리 controller 정의

* feat: notification update usecase & service 작성

* feat: 불필요한 user_id 파라미터 제거

* docs: swagger 문서 작성

* fix: notification service read_only 옵션 제거
  • Loading branch information
psychology50 authored Jul 18, 2024
1 parent 869be8d commit 4f51e7b
Show file tree
Hide file tree
Showing 9 changed files with 168 additions and 8 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -5,11 +5,9 @@
import io.swagger.v3.oas.annotations.Parameter;
import io.swagger.v3.oas.annotations.Parameters;
import io.swagger.v3.oas.annotations.enums.ParameterIn;
import io.swagger.v3.oas.annotations.media.ArraySchema;
import io.swagger.v3.oas.annotations.media.Content;
import io.swagger.v3.oas.annotations.media.Schema;
import io.swagger.v3.oas.annotations.media.SchemaProperty;
import io.swagger.v3.oas.annotations.media.*;
import io.swagger.v3.oas.annotations.responses.ApiResponse;
import io.swagger.v3.oas.annotations.responses.ApiResponses;
import io.swagger.v3.oas.annotations.tags.Tag;
import kr.co.pennyway.api.apis.notification.dto.NotificationDto;
import kr.co.pennyway.api.common.security.authentication.SecurityUserDetails;
Expand All @@ -19,6 +17,8 @@
import org.springframework.data.web.SortDefault;
import org.springframework.http.ResponseEntity;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.validation.annotation.Validated;
import org.springframework.web.bind.annotation.RequestBody;

@Tag(name = "[알림 API]")
public interface NotificationApi {
Expand Down Expand Up @@ -60,4 +60,18 @@ ResponseEntity<?> getNotifications(
@PageableDefault(page = 0, size = 30) @SortDefault(sort = "notification.createdAt", direction = Sort.Direction.DESC) Pageable pageable,
@AuthenticationPrincipal SecurityUserDetails user
);

@Operation(summary = "수신한 알림 읽음 처리", description = "사용자가 수신한 알림을 읽음처리 합니다. 단, 읽음 처리할 알림의 pk는 사용자가 receiver여야 하며, 미확인 알림만 포함되어 있어야 합니다.")
@ApiResponses({
@ApiResponse(responseCode = "200", description = "알림 읽음 처리 성공"),
@ApiResponse(responseCode = "403", description = "사용자가 접근할 권한이 없는 pk가 포함되어 있거나, 이미 읽음 처리된 알림이 하나라도 존재하는 경우", content = @Content(examples =
@ExampleObject("""
{
"code": "4030",
"message": "ACCESS_TO_THE_REQUESTED_RESOURCE_IS_FORBIDDEN"
}
""")
))
})
ResponseEntity<?> updateNotifications(@RequestBody @Validated NotificationDto.ReadReq readReq);
}
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package kr.co.pennyway.api.apis.notification.controller;

import kr.co.pennyway.api.apis.notification.api.NotificationApi;
import kr.co.pennyway.api.apis.notification.dto.NotificationDto;
import kr.co.pennyway.api.apis.notification.usecase.NotificationUseCase;
import kr.co.pennyway.api.common.response.SuccessResponse;
import kr.co.pennyway.api.common.security.authentication.SecurityUserDetails;
Expand All @@ -13,9 +14,8 @@
import org.springframework.http.ResponseEntity;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.validation.annotation.Validated;
import org.springframework.web.bind.annotation.*;

@Slf4j
@RestController
Expand All @@ -35,4 +35,12 @@ public ResponseEntity<?> getNotifications(
) {
return ResponseEntity.ok(SuccessResponse.from(NOTIFICATIONS, notificationUseCase.getNotifications(user.getUserId(), pageable)));
}

@Override
@PatchMapping("")
@PreAuthorize("isAuthenticated() and @notificationManager.hasPermission(principal.userId, #readReq.notificationIds())")
public ResponseEntity<?> updateNotifications(@RequestBody @Validated NotificationDto.ReadReq readReq) {
notificationUseCase.updateNotificationsToRead(readReq.notificationIds());
return ResponseEntity.ok(SuccessResponse.noContent());
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
import com.fasterxml.jackson.databind.annotation.JsonSerialize;
import com.fasterxml.jackson.datatype.jsr310.ser.LocalDateTimeSerializer;
import io.swagger.v3.oas.annotations.media.Schema;
import jakarta.validation.constraints.NotEmpty;
import kr.co.pennyway.domain.domains.notification.domain.Notification;
import kr.co.pennyway.domain.domains.notification.type.NoticeType;
import lombok.Builder;
Expand All @@ -14,6 +15,14 @@
import java.util.List;

public class NotificationDto {
@Schema(title = "푸시 알림 읽음 처리 요청")
public record ReadReq(
@Schema(description = "푸시 알림 pk 리스트", example = "[1, 2, 3]")
@NotEmpty(message = "notificationIds는 비어있을 수 없습니다.")
List<Long> notificationIds
) {
}

@Schema(title = "푸시 알림 슬라이스 응답")
public record SliceRes(
@Schema(description = "푸시 알림 리스트")
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
package kr.co.pennyway.api.apis.notification.service;

import kr.co.pennyway.domain.domains.notification.service.NotificationService;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;

import java.util.List;

@Slf4j
@Service
@RequiredArgsConstructor
public class NotificationSaveService {
private final NotificationService notificationService;

/**
* 알림 목록을 읽음 상태로 업데이트합니다.
*
* @param notificationIds 읽음 처리할 알림 ID 목록
*/
public void updateNotificationsToRead(List<Long> notificationIds) {
notificationService.updateReadAtByIdsInBulk(notificationIds);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

import kr.co.pennyway.api.apis.notification.dto.NotificationDto;
import kr.co.pennyway.api.apis.notification.mapper.NotificationMapper;
import kr.co.pennyway.api.apis.notification.service.NotificationSaveService;
import kr.co.pennyway.api.apis.notification.service.NotificationSearchService;
import kr.co.pennyway.common.annotation.UseCase;
import kr.co.pennyway.domain.domains.notification.domain.Notification;
Expand All @@ -10,15 +11,22 @@
import org.springframework.data.domain.Pageable;
import org.springframework.data.domain.Slice;

import java.util.List;

@Slf4j
@UseCase
@RequiredArgsConstructor
public class NotificationUseCase {
private final NotificationSearchService notificationSearchService;
private final NotificationSaveService notificationSaveService;

public NotificationDto.SliceRes getNotifications(Long userId, Pageable pageable) {
Slice<Notification> notifications = notificationSearchService.getNotifications(userId, pageable);

return NotificationMapper.toSliceRes(notifications, pageable);
}

public void updateNotificationsToRead(List<Long> notificationIds) {
notificationSaveService.updateNotificationsToRead(notificationIds);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
package kr.co.pennyway.api.common.security.authorization;

import kr.co.pennyway.domain.domains.notification.service.NotificationService;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Component;
import org.springframework.transaction.annotation.Transactional;

import java.util.List;

@Slf4j
@Component("notificationManager")
@RequiredArgsConstructor
public class NotificationManager {
private final NotificationService notificationService;

/**
* 사용자가 알림 리스트에 대한 전체 접근 권한이 있는지 확인한다.
* <p>
* 조회 결과와 요청 파라미터의 개수가 동일해야 하며, 읽음 상태의 알림은 포함되어선 안 된다.
*/
@Transactional(readOnly = true)
public boolean hasPermission(Long userId, List<Long> notificationIds) {
return notificationService.countUnreadNotifications(userId, notificationIds) == (long) notificationIds.size();
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,19 @@

import kr.co.pennyway.domain.common.repository.ExtendedRepository;
import kr.co.pennyway.domain.domains.notification.domain.Notification;
import org.springframework.data.jpa.repository.Modifying;
import org.springframework.data.jpa.repository.Query;
import org.springframework.transaction.annotation.Transactional;

import java.util.List;

public interface NotificationRepository extends ExtendedRepository<Notification, Long>, NotificationCustomRepository {
@Modifying(clearAutomatically = true)
@Transactional
@Query("update Notification n set n.readAt = current_timestamp where n.id in ?1")
void updateReadAtByIdsInBulk(List<Long> notificationIds);

@Transactional(readOnly = true)
@Query("select count(n) from Notification n where n.receiver.id = ?1 and n.id in ?2 and n.readAt is null")
long countUnreadNotificationsByIds(Long userId, List<Long> notificationIds);
}
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,8 @@
import org.springframework.data.domain.Sort;
import org.springframework.transaction.annotation.Transactional;

import java.util.List;

@Slf4j
@DomainService
@RequiredArgsConstructor
Expand All @@ -34,4 +36,14 @@ public Slice<Notification> readNotificationsSlice(Long userId, Pageable pageable

return SliceUtil.toSlice(notificationRepository.findList(predicate, queryHandler, sort), pageable);
}

@Transactional(readOnly = true)
public long countUnreadNotifications(Long userId, List<Long> notificationIds) {
return notificationRepository.countUnreadNotificationsByIds(userId, notificationIds);
}

@Transactional
public void updateReadAtByIdsInBulk(List<Long> notificationIds) {
notificationRepository.updateReadAtByIdsInBulk(notificationIds);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -25,14 +25,15 @@
import java.util.List;

import static org.springframework.test.util.AssertionErrors.assertEquals;
import static org.springframework.test.util.AssertionErrors.assertNotNull;

@Slf4j
@DataJpaTest(properties = {"spring.jpa.hibernate.ddl-auto=create"})
@ContextConfiguration(classes = JpaConfig.class)
@AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE)
@Import(TestJpaConfig.class)
@ActiveProfiles("test")
public class SaveDailySpendingAnnounceInBulkTest extends ContainerMySqlTestConfig {
public class NotificationRepositoryUnitTest extends ContainerMySqlTestConfig {
@Autowired
private UserRepository userRepository;
@Autowired
Expand Down Expand Up @@ -84,6 +85,51 @@ public void notSaveDuplicateNotification() {
assertEquals("알림이 중복 저장되지 않아야 한다.", 2, notifications.size());
}

@Test
@DisplayName("사용자의 여러 알림을 읽음 처리할 수 있다.")
void updateReadAtSuccessfully() {
// given
User user = userRepository.save(createUser("jayang"));

List<Notification> notifications = notificationRepository.saveAll(List.of(
new Notification.Builder(NoticeType.ANNOUNCEMENT, Announcement.DAILY_SPENDING, user).build(),
new Notification.Builder(NoticeType.ANNOUNCEMENT, Announcement.DAILY_SPENDING, user).build(),
new Notification.Builder(NoticeType.ANNOUNCEMENT, Announcement.DAILY_SPENDING, user).build()));

// when
notificationRepository.updateReadAtByIdsInBulk(notifications.stream().map(Notification::getId).toList());

// then
notificationRepository.findAll().forEach(notification -> {
log.info("notification: {}", notification);
assertNotNull("알림이 읽음 처리 되어야 한다.", notification.getReadAt());
});
}

@Test
@DisplayName("사용자의 읽지 않은 알림 개수를 조회할 수 있다.")
void countUnreadNotificationsByIds() {
// given
User user = userRepository.save(createUser("jayang"));

List<Notification> notifications = notificationRepository.saveAll(List.of(
new Notification.Builder(NoticeType.ANNOUNCEMENT, Announcement.DAILY_SPENDING, user).build(),
new Notification.Builder(NoticeType.ANNOUNCEMENT, Announcement.DAILY_SPENDING, user).build(),
new Notification.Builder(NoticeType.ANNOUNCEMENT, Announcement.DAILY_SPENDING, user).build()));
List<Long> ids = notifications.stream().map(Notification::getId).toList();

notificationRepository.updateReadAtByIdsInBulk(List.of(ids.get(1)));

// when
long count = notificationRepository.countUnreadNotificationsByIds(
user.getId(),
notifications.stream().map(Notification::getId).toList()
);

// then
assertEquals("읽지 않은 알림 개수가 2개여야 한다.", 2L, count);
}

private User createUser(String name) {
return User.builder()
.username("test")
Expand Down

0 comments on commit 4f51e7b

Please sign in to comment.