diff --git a/src/main/java/com/mos/backend/BackendApplication.java b/src/main/java/com/mos/backend/BackendApplication.java index 601a3705..2d563fae 100644 --- a/src/main/java/com/mos/backend/BackendApplication.java +++ b/src/main/java/com/mos/backend/BackendApplication.java @@ -2,8 +2,10 @@ import org.springframework.boot.SpringApplication; import org.springframework.boot.autoconfigure.SpringBootApplication; +import org.springframework.data.web.config.EnableSpringDataWebSupport; @SpringBootApplication +@EnableSpringDataWebSupport(pageSerializationMode = EnableSpringDataWebSupport.PageSerializationMode.VIA_DTO) public class BackendApplication { public static void main(String[] args) { diff --git a/src/main/java/com/mos/backend/common/config/WebConfig.java b/src/main/java/com/mos/backend/common/config/WebConfig.java index 8a42c159..033650ef 100644 --- a/src/main/java/com/mos/backend/common/config/WebConfig.java +++ b/src/main/java/com/mos/backend/common/config/WebConfig.java @@ -2,6 +2,7 @@ import com.mos.backend.common.argumentresolvers.pageable.CustomPageableArgumentResolver; import org.springframework.context.annotation.Configuration; +import org.springframework.data.web.config.EnableSpringDataWebSupport; import org.springframework.web.method.support.HandlerMethodArgumentResolver; import org.springframework.web.servlet.config.annotation.WebMvcConfigurer; diff --git a/src/main/java/com/mos/backend/notifications/application/NotificationLogService.java b/src/main/java/com/mos/backend/notifications/application/NotificationLogService.java index 1ab5a3f0..4d283a69 100644 --- a/src/main/java/com/mos/backend/notifications/application/NotificationLogService.java +++ b/src/main/java/com/mos/backend/notifications/application/NotificationLogService.java @@ -2,14 +2,20 @@ import com.mos.backend.common.event.EventType; import com.mos.backend.common.infrastructure.EntityFacade; +import com.mos.backend.notifications.application.dto.NotificationListResponseDto; +import com.mos.backend.notifications.application.dto.NotificationResponseDto; +import com.mos.backend.notifications.application.dto.NotificationUnreadCountDto; import com.mos.backend.notifications.entity.NotificationLog; +import com.mos.backend.notifications.entity.NotificationReadStatus; import com.mos.backend.notifications.infrastructure.notificationlog.NotificationLogRepository; import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.security.access.prepost.PostAuthorize; +import org.springframework.security.access.prepost.PreAuthorize; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; -import java.util.List; - @Service @RequiredArgsConstructor @Transactional(readOnly = true) @@ -23,9 +29,48 @@ public void create (Long recipientId, EventType eventType, String title, String notificationLogRepository.save(NotificationLog.create(entityFacade.getUser(recipientId), eventType, title, content)); } + /** + * 알림 상세 조회하며 읽음 체크 + * @param notificationLogId 읽을 알림의 아이디 + */ @Transactional - public void read(Long notificationLogId) { + @PostAuthorize("#returnObject.recipientId == authentication.principal") + public NotificationResponseDto read(Long notificationLogId) { NotificationLog notificationLog = entityFacade.getNotificationLog(notificationLogId); notificationLog.read(); + return new NotificationResponseDto(notificationLog); + } + + /** + * 사용자가 읽지 않은 알림 수 조회 + * @param userId 읽지 않은 알림의 수를 조회할 사용자 아이디 + */ + @Transactional + @PreAuthorize("hasRole('ADMIN') or authentication.principal == #userId") + public NotificationUnreadCountDto getUnreadCount(Long userId) { + Integer unreadCount = notificationLogRepository.getUnreadCount(userId); + return new NotificationUnreadCountDto(unreadCount); + } + + /** + * 전체 알림 조회 + * @param pageable + * @param userId 조회할 유저 아이디 + * @param status 읽음 상태 필터링 + */ + @Transactional + @PreAuthorize("hasRole('ADMIN') or authentication.principal == #userId") + public NotificationListResponseDto getNotifications( + Pageable pageable, + Long userId, + NotificationReadStatus status + ) { + Page notifications = notificationLogRepository.getNotifications(pageable, userId, status); + return new NotificationListResponseDto( + notifications.getTotalElements(), + notifications.getNumber(), + notifications.getTotalPages(), + notifications.getContent() + ); } } diff --git a/src/main/java/com/mos/backend/notifications/application/dto/NotificationListResponseDto.java b/src/main/java/com/mos/backend/notifications/application/dto/NotificationListResponseDto.java new file mode 100644 index 00000000..1441b177 --- /dev/null +++ b/src/main/java/com/mos/backend/notifications/application/dto/NotificationListResponseDto.java @@ -0,0 +1,15 @@ +package com.mos.backend.notifications.application.dto; + +import lombok.AllArgsConstructor; +import lombok.Getter; + +import java.util.List; + +@Getter +@AllArgsConstructor +public class NotificationListResponseDto { + private long totalNotifications; + private int currentPage; + private int totalPages; + private List notifications; +} diff --git a/src/main/java/com/mos/backend/notifications/application/dto/NotificationResponseDto.java b/src/main/java/com/mos/backend/notifications/application/dto/NotificationResponseDto.java new file mode 100644 index 00000000..2016c7ba --- /dev/null +++ b/src/main/java/com/mos/backend/notifications/application/dto/NotificationResponseDto.java @@ -0,0 +1,45 @@ +package com.mos.backend.notifications.application.dto; + +import com.mos.backend.common.event.EventType; +import com.mos.backend.notifications.entity.NotificationLog; +import lombok.Getter; +import lombok.NoArgsConstructor; + +import java.time.LocalDateTime; + +@Getter +@NoArgsConstructor +public class NotificationResponseDto { + private Long notificationId; + + private Long recipientId; + + private EventType type; + + private String title; + + private String content; + + private boolean isRead; + private LocalDateTime createdAt; + + public NotificationResponseDto(Long notificationId, Long recipientId, EventType type, String title, String content, boolean isRead, LocalDateTime createdAt) { + this.notificationId = notificationId; + this.recipientId = recipientId; + this.type = type; + this.title = title; + this.content = content; + this.isRead = isRead; + this.createdAt = createdAt; + } + + public NotificationResponseDto(NotificationLog notificationLog) { + this.notificationId = notificationLog.getId(); + this.recipientId = notificationLog.getRecipient().getId(); + this.type = notificationLog.getType(); + this.title = notificationLog.getTitle(); + this.content = notificationLog.getContent(); + this.isRead = notificationLog.isRead(); + this.createdAt = notificationLog.getCreatedAt(); + } +} diff --git a/src/main/java/com/mos/backend/notifications/application/dto/NotificationUnreadCountDto.java b/src/main/java/com/mos/backend/notifications/application/dto/NotificationUnreadCountDto.java new file mode 100644 index 00000000..5ee2ee99 --- /dev/null +++ b/src/main/java/com/mos/backend/notifications/application/dto/NotificationUnreadCountDto.java @@ -0,0 +1,10 @@ +package com.mos.backend.notifications.application.dto; + +import lombok.AllArgsConstructor; +import lombok.Getter; + +@Getter +@AllArgsConstructor +public class NotificationUnreadCountDto { + private Integer unreadCount; +} diff --git a/src/main/java/com/mos/backend/notifications/entity/NotificationLog.java b/src/main/java/com/mos/backend/notifications/entity/NotificationLog.java index 0ae7eb32..e1be4c74 100644 --- a/src/main/java/com/mos/backend/notifications/entity/NotificationLog.java +++ b/src/main/java/com/mos/backend/notifications/entity/NotificationLog.java @@ -39,7 +39,7 @@ public class NotificationLog extends BaseTimeEntity { @Column(nullable = true) private String content; - @Column + @Column(nullable = false, name = "is_read") private boolean isRead = false; public static NotificationLog create(User recipient, EventType type,String title, String content) { diff --git a/src/main/java/com/mos/backend/notifications/entity/NotificationReadStatus.java b/src/main/java/com/mos/backend/notifications/entity/NotificationReadStatus.java new file mode 100644 index 00000000..66c1158f --- /dev/null +++ b/src/main/java/com/mos/backend/notifications/entity/NotificationReadStatus.java @@ -0,0 +1,5 @@ +package com.mos.backend.notifications.entity; + +public enum NotificationReadStatus { + READ, UNREAD, ALL +} diff --git a/src/main/java/com/mos/backend/notifications/infrastructure/notificationlog/NotificationLogJpaRepository.java b/src/main/java/com/mos/backend/notifications/infrastructure/notificationlog/NotificationLogJpaRepository.java index cfbbbd6f..8ef0a50f 100644 --- a/src/main/java/com/mos/backend/notifications/infrastructure/notificationlog/NotificationLogJpaRepository.java +++ b/src/main/java/com/mos/backend/notifications/infrastructure/notificationlog/NotificationLogJpaRepository.java @@ -4,4 +4,6 @@ import org.springframework.data.jpa.repository.JpaRepository; public interface NotificationLogJpaRepository extends JpaRepository{ + + Integer countNotificationLogByRecipientIdAndIsReadIsFalse(Long userId); } diff --git a/src/main/java/com/mos/backend/notifications/infrastructure/notificationlog/NotificationLogQueryDslRepository.java b/src/main/java/com/mos/backend/notifications/infrastructure/notificationlog/NotificationLogQueryDslRepository.java new file mode 100644 index 00000000..97d62303 --- /dev/null +++ b/src/main/java/com/mos/backend/notifications/infrastructure/notificationlog/NotificationLogQueryDslRepository.java @@ -0,0 +1,70 @@ +package com.mos.backend.notifications.infrastructure.notificationlog; + +import com.mos.backend.common.utils.QueryDslSortUtil; +import com.mos.backend.notifications.application.dto.NotificationResponseDto; +import com.mos.backend.notifications.entity.NotificationLog; +import com.mos.backend.notifications.entity.NotificationReadStatus; +import com.querydsl.core.types.OrderSpecifier; +import com.querydsl.core.types.Projections; +import com.querydsl.core.types.dsl.BooleanExpression; +import com.querydsl.jpa.JPQLQuery; +import com.querydsl.jpa.impl.JPAQueryFactory; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.data.support.PageableExecutionUtils; +import org.springframework.stereotype.Repository; + +import java.util.List; + +import static com.mos.backend.notifications.entity.QNotificationLog.notificationLog; + +@Repository +@RequiredArgsConstructor +public class NotificationLogQueryDslRepository { + private final JPAQueryFactory queryFactory; + + public Page getNotificationLogs(Pageable pageable, Long userId, NotificationReadStatus readStatus) { + List content = queryFactory + .select(Projections.constructor(NotificationResponseDto.class, + notificationLog.id, + notificationLog.recipient.id, + notificationLog.type, + notificationLog.title, + notificationLog.content, + notificationLog.isRead, + notificationLog.createdAt + )) + .from(notificationLog) + .where( + notificationLog.recipient.id.eq(userId), + readStatusEq(readStatus) + ) + .offset(pageable.getOffset()) + .limit(pageable.getPageSize()) + .orderBy(getOrderSpecifiers(pageable)) + .fetch(); + + JPQLQuery countQuery = queryFactory + .select(notificationLog) + .from(notificationLog) + .where( + notificationLog.recipient.id.eq(userId), + readStatusEq(readStatus) + ); + return PageableExecutionUtils.getPage(content, pageable, countQuery::fetchCount); + } + + + private BooleanExpression readStatusEq(NotificationReadStatus readStatus) { + return switch (readStatus) { + case NotificationReadStatus.READ -> notificationLog.isRead.isTrue(); + case NotificationReadStatus.UNREAD -> notificationLog.isRead.isFalse(); + default -> null; + }; + } + + private static OrderSpecifier[] getOrderSpecifiers(Pageable pageable) { + return QueryDslSortUtil.toOrderSpecifiers(pageable.getSort(), NotificationLog.class).toArray(new OrderSpecifier[0]); + } +} diff --git a/src/main/java/com/mos/backend/notifications/infrastructure/notificationlog/NotificationLogRepository.java b/src/main/java/com/mos/backend/notifications/infrastructure/notificationlog/NotificationLogRepository.java index 2039182c..fffc6734 100644 --- a/src/main/java/com/mos/backend/notifications/infrastructure/notificationlog/NotificationLogRepository.java +++ b/src/main/java/com/mos/backend/notifications/infrastructure/notificationlog/NotificationLogRepository.java @@ -1,6 +1,10 @@ package com.mos.backend.notifications.infrastructure.notificationlog; +import com.mos.backend.notifications.application.dto.NotificationResponseDto; import com.mos.backend.notifications.entity.NotificationLog; +import com.mos.backend.notifications.entity.NotificationReadStatus; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; import java.util.Optional; @@ -8,4 +12,8 @@ public interface NotificationLogRepository { void save(NotificationLog notificationLog); Optional findById(Long notificationId); + + Integer getUnreadCount(Long userId); + + Page getNotifications(Pageable pageable, Long userId, NotificationReadStatus readStatus); } diff --git a/src/main/java/com/mos/backend/notifications/infrastructure/notificationlog/NotificationLogRepositoryImpl.java b/src/main/java/com/mos/backend/notifications/infrastructure/notificationlog/NotificationLogRepositoryImpl.java index 9a97c632..61dc34be 100644 --- a/src/main/java/com/mos/backend/notifications/infrastructure/notificationlog/NotificationLogRepositoryImpl.java +++ b/src/main/java/com/mos/backend/notifications/infrastructure/notificationlog/NotificationLogRepositoryImpl.java @@ -1,7 +1,11 @@ package com.mos.backend.notifications.infrastructure.notificationlog; +import com.mos.backend.notifications.application.dto.NotificationResponseDto; import com.mos.backend.notifications.entity.NotificationLog; +import com.mos.backend.notifications.entity.NotificationReadStatus; import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; import org.springframework.stereotype.Repository; import java.util.Optional; @@ -10,6 +14,7 @@ @Repository public class NotificationLogRepositoryImpl implements NotificationLogRepository{ private final NotificationLogJpaRepository notificationLogJpaRepository; + private final NotificationLogQueryDslRepository notificationLogQueryDslRepository; @Override public void save(NotificationLog notificationLog) { @@ -20,4 +25,14 @@ public void save(NotificationLog notificationLog) { public Optional findById(Long notificationId) { return notificationLogJpaRepository.findById(notificationId); } + + @Override + public Integer getUnreadCount(Long userId) { + return notificationLogJpaRepository.countNotificationLogByRecipientIdAndIsReadIsFalse(userId); + } + + @Override + public Page getNotifications(Pageable pageable, Long userId, NotificationReadStatus readStatus) { + return notificationLogQueryDslRepository.getNotificationLogs(pageable, userId, readStatus); + } } diff --git a/src/main/java/com/mos/backend/notifications/presentation/controller/api/NotificationLogController.java b/src/main/java/com/mos/backend/notifications/presentation/controller/api/NotificationLogController.java new file mode 100644 index 00000000..5a92a895 --- /dev/null +++ b/src/main/java/com/mos/backend/notifications/presentation/controller/api/NotificationLogController.java @@ -0,0 +1,58 @@ +package com.mos.backend.notifications.presentation.controller.api; + +import com.mos.backend.notifications.application.NotificationLogService; +import com.mos.backend.notifications.application.dto.NotificationListResponseDto; +import com.mos.backend.notifications.application.dto.NotificationResponseDto; +import com.mos.backend.notifications.application.dto.NotificationUnreadCountDto; +import com.mos.backend.notifications.entity.NotificationReadStatus; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.http.HttpStatus; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.web.bind.annotation.*; + +@RequiredArgsConstructor +@RestController +public class NotificationLogController { + private final NotificationLogService notificationLogService; + + /** + * 전체 알림 조회 + * @param pageable + * @param userId 조회할 유저 아이디 + * @param readStatus 읽음 상태 필터링 + */ + @GetMapping("/notifications") + @ResponseStatus(HttpStatus.OK) + public NotificationListResponseDto getNotifications( + @AuthenticationPrincipal Long userId, + @RequestParam(required = false, defaultValue = "ALL") NotificationReadStatus readStatus, + Pageable pageable + ) { + return notificationLogService.getNotifications(pageable, userId, readStatus); + } + + /** + * 알림 상세 조회하며 읽음 체크 + * @param notificationId 읽을 알림의 아이디 + */ + @PostMapping("/notifications/{notificationId}") + @ResponseStatus(HttpStatus.OK) + public NotificationResponseDto read( + @PathVariable Long notificationId + ) { + return notificationLogService.read(notificationId); + } + + /** + * 사용자가 읽지 않은 알림 수 조회 + * @param userId + */ + @GetMapping("/notifications/unread") + public NotificationUnreadCountDto getUnreadCount( + @AuthenticationPrincipal Long userId + ) { + return notificationLogService.getUnreadCount(userId); + } +} diff --git a/src/test/java/com/mos/backend/notifications/application/NotificationLogServiceTest.java b/src/test/java/com/mos/backend/notifications/application/NotificationLogServiceTest.java index 27e60d42..fabe81f1 100644 --- a/src/test/java/com/mos/backend/notifications/application/NotificationLogServiceTest.java +++ b/src/test/java/com/mos/backend/notifications/application/NotificationLogServiceTest.java @@ -2,7 +2,11 @@ import com.mos.backend.common.event.EventType; import com.mos.backend.common.infrastructure.EntityFacade; +import com.mos.backend.notifications.application.dto.NotificationListResponseDto; +import com.mos.backend.notifications.application.dto.NotificationResponseDto; // DTO import 추가 +import com.mos.backend.notifications.application.dto.NotificationUnreadCountDto; import com.mos.backend.notifications.entity.NotificationLog; +import com.mos.backend.notifications.entity.NotificationReadStatus; import com.mos.backend.notifications.infrastructure.notificationlog.NotificationLogRepository; import com.mos.backend.users.entity.User; import org.junit.jupiter.api.DisplayName; @@ -10,6 +14,13 @@ import org.junit.jupiter.api.extension.ExtendWith; import org.mockito.*; import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageImpl; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Pageable; + +import java.time.LocalDateTime; +import java.util.List; import static org.assertj.core.api.Assertions.assertThat; import static org.mockito.Mockito.*; @@ -26,18 +37,18 @@ class NotificationLogServiceTest { @InjectMocks private NotificationLogService notificationLogService; - @Captor private ArgumentCaptor notificationLogCaptor; + @Captor + private ArgumentCaptor notificationLogCaptor; @Test @DisplayName("알림 로그 생성 성공") public void createTest() { - //given Long userId = 1L; EventType type = EventType.STUDY_JOINED; String title = "test title"; String content = "test content"; - User mockUser = Mockito.mock(User.class); + User mockUser = mock(User.class); when(entityFacade.getUser(userId)).thenReturn(mockUser); // when @@ -55,16 +66,74 @@ public void createTest() { @Test @DisplayName("알림 읽기 성공") public void readTest() { - // given Long notificationLogId = 1L; + Long recipientId = 1L; + + User mockRecipient = mock(User.class); + when(mockRecipient.getId()).thenReturn(recipientId); + NotificationLog mockNotificationLog = mock(NotificationLog.class); - when(entityFacade.getNotificationLog(1L)).thenReturn(mockNotificationLog); + when(entityFacade.getNotificationLog(notificationLogId)).thenReturn(mockNotificationLog); + + when(mockNotificationLog.getId()).thenReturn(notificationLogId); + when(mockNotificationLog.getRecipient()).thenReturn(mockRecipient); + when(mockNotificationLog.isRead()).thenReturn(true); // when - notificationLogService.read(notificationLogId); + NotificationResponseDto responseDto = notificationLogService.read(notificationLogId); // then verify(mockNotificationLog).read(); + + assertThat(responseDto).isNotNull(); + assertThat(responseDto.getNotificationId()).isEqualTo(notificationLogId); + assertThat(responseDto.getRecipientId()).isEqualTo(recipientId); + assertThat(responseDto.isRead()).isTrue(); + } + + @Test + @DisplayName("읽지 않은 알림 수 조회 성공") + public void getUnreadCountTest() { + // given + Long userId = 1L; + Integer expectedCount = 5; + when(notificationLogRepository.getUnreadCount(userId)).thenReturn(expectedCount); + + // when + NotificationUnreadCountDto result = notificationLogService.getUnreadCount(userId); + + // then + verify(notificationLogRepository).getUnreadCount(userId); + assertThat(result).isNotNull(); + assertThat(result.getUnreadCount()).isEqualTo(expectedCount); + } + + @Test + @DisplayName("알림 목록 조회 성공") + public void getNotificationsTest() { + // given + Long userId = 1L; + Pageable pageable = PageRequest.of(0, 10); + NotificationReadStatus status = NotificationReadStatus.ALL; + + List notificationList = List.of( + new NotificationResponseDto(1L, userId, EventType.STUDY_CREATED, "Title 1", "Content 1", true, LocalDateTime.now()), + new NotificationResponseDto(2L, userId, EventType.STUDY_JOINED, "Title 2", "Content 2", false, LocalDateTime.now()) + ); + Page responsePage = new PageImpl<>(notificationList, pageable, notificationList.size()); + + when(notificationLogRepository.getNotifications(pageable, userId, status)).thenReturn(responsePage); + + // when + NotificationListResponseDto result = notificationLogService.getNotifications(pageable, userId, status); + + // then + verify(notificationLogRepository).getNotifications(pageable, userId, status); + + assertThat(result).isNotNull(); + assertThat(result.getTotalPages()).isEqualTo(1); + assertThat(result.getNotifications()).hasSize(2); + assertThat(result.getNotifications().get(0).getTitle()).isEqualTo("Title 1"); } } \ No newline at end of file