diff --git a/common/api/src/main/java/kr/spot/code/status/ErrorStatus.java b/common/api/src/main/java/kr/spot/code/status/ErrorStatus.java index df597e6d..2a741ec6 100644 --- a/common/api/src/main/java/kr/spot/code/status/ErrorStatus.java +++ b/common/api/src/main/java/kr/spot/code/status/ErrorStatus.java @@ -100,7 +100,7 @@ public enum ErrorStatus implements BaseErrorCode { _NOTIFICATION_PAYLOAD_MISSING_STUDY_ID(400, "NOTIFICATION4002", "알림 페이로드에 studyId가 필요합니다."), _NOTIFICATION_TEMPLATE_NOT_FOUND(500, "NOTIFICATION5000", "알림 템플릿을 찾을 수 없습니다."), _PUSH_NOTIFICATION_FAILED(500, "NOTIFICATION5001", "푸시 알림 발송에 실패했습니다."), - ; + _NOTIFICATION_ACCESS_DENIED(403, "NOTIFICATION403", "해당 알림에 접근할 권한이 없습니다."); private final int httpStatus; private final String code; diff --git a/modules/notification/src/main/java/kr/spot/application/command/NotificationCommandService.java b/modules/notification/src/main/java/kr/spot/application/command/NotificationCommandService.java new file mode 100644 index 00000000..fce3ab44 --- /dev/null +++ b/modules/notification/src/main/java/kr/spot/application/command/NotificationCommandService.java @@ -0,0 +1,21 @@ +package kr.spot.application.command; + +import kr.spot.domain.Notification; +import kr.spot.infrastructure.jpa.NotificationRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +@Transactional +@RequiredArgsConstructor +public class NotificationCommandService { + + private final NotificationRepository notificationRepository; + + public void markAllAsRead(long memberId, long notificationId) { + Notification notification = notificationRepository.getById(notificationId); + notification.markAsRead(memberId); + } + +} diff --git a/modules/notification/src/main/java/kr/spot/application/query/GetNotificationService.java b/modules/notification/src/main/java/kr/spot/application/query/GetNotificationService.java index e71f62a2..6fa6503f 100644 --- a/modules/notification/src/main/java/kr/spot/application/query/GetNotificationService.java +++ b/modules/notification/src/main/java/kr/spot/application/query/GetNotificationService.java @@ -4,6 +4,7 @@ import kr.spot.domain.Notification; import kr.spot.infrastructure.jpa.NotificationRepository; import kr.spot.presentation.query.dto.GetNotificationListResponse; +import kr.spot.presentation.query.dto.GetUnreadNotificationResponse; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @@ -20,4 +21,9 @@ public GetNotificationListResponse getMyNotifications(long memberId) { .findByMemberIdOrderByCreatedAtDesc(memberId); return GetNotificationListResponse.from(notifications); } + + public GetUnreadNotificationResponse hasUnreadNotifications(long memberId) { + boolean hasUnread = notificationRepository.existsByMemberIdAndIsReadFalse(memberId); + return new GetUnreadNotificationResponse(hasUnread); + } } diff --git a/modules/notification/src/main/java/kr/spot/domain/Notification.java b/modules/notification/src/main/java/kr/spot/domain/Notification.java index c8fc014e..0756de56 100644 --- a/modules/notification/src/main/java/kr/spot/domain/Notification.java +++ b/modules/notification/src/main/java/kr/spot/domain/Notification.java @@ -8,8 +8,10 @@ import jakarta.persistence.Index; import jakarta.persistence.Table; import java.time.LocalDateTime; +import kr.spot.code.status.ErrorStatus; import kr.spot.domain.enums.NotificationStatus; import kr.spot.domain.enums.NotificationType; +import kr.spot.exception.GeneralException; import lombok.AccessLevel; import lombok.Getter; import lombok.NoArgsConstructor; @@ -161,7 +163,10 @@ public boolean canRetry() { return retryCount < maxRetry; } - public void markAsRead() { + public void markAsRead(long memberId) { + if (memberId != this.memberId) { + throw new GeneralException(ErrorStatus._NOTIFICATION_ACCESS_DENIED); + } this.isRead = true; } } diff --git a/modules/notification/src/main/java/kr/spot/infrastructure/jpa/NotificationRepository.java b/modules/notification/src/main/java/kr/spot/infrastructure/jpa/NotificationRepository.java index 8484d812..39b54f30 100644 --- a/modules/notification/src/main/java/kr/spot/infrastructure/jpa/NotificationRepository.java +++ b/modules/notification/src/main/java/kr/spot/infrastructure/jpa/NotificationRepository.java @@ -2,8 +2,10 @@ import java.time.LocalDateTime; import java.util.List; +import kr.spot.code.status.ErrorStatus; import kr.spot.domain.Notification; import kr.spot.domain.enums.NotificationStatus; +import kr.spot.exception.GeneralException; import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.data.jpa.repository.Modifying; import org.springframework.data.jpa.repository.Query; @@ -15,6 +17,8 @@ public interface NotificationRepository extends JpaRepository findByMemberIdOrderByCreatedAtDesc(long memberId); + boolean existsByMemberIdAndIsReadFalse(long memberId); + /** * 발송 대상 알림 선점 (FOR UPDATE SKIP LOCKED 사용) MySQL 8.0+에서 지원 */ @@ -46,4 +50,9 @@ int pickPendingNotifications( * 특정 서버가 선점한 알림 목록 조회 */ List findByPickedByAndDispatchStatus(String serverId, NotificationStatus status); + + default Notification getById(long id) { + return findById(id).orElseThrow( + () -> new GeneralException(ErrorStatus._NOTIFICATION_NOT_FOUND)); + } } diff --git a/modules/notification/src/main/java/kr/spot/presentation/command/NotificationCommandController.java b/modules/notification/src/main/java/kr/spot/presentation/command/NotificationCommandController.java new file mode 100644 index 00000000..3da70e28 --- /dev/null +++ b/modules/notification/src/main/java/kr/spot/presentation/command/NotificationCommandController.java @@ -0,0 +1,34 @@ +package kr.spot.presentation.command; + +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.tags.Tag; +import kr.spot.ApiResponse; +import kr.spot.annotations.CurrentMember; +import kr.spot.application.command.NotificationCommandService; +import kr.spot.code.status.SuccessStatus; +import lombok.RequiredArgsConstructor; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +@Tag(name = "알림") +@RestController +@RequestMapping("/api/notifications") +@RequiredArgsConstructor +public class NotificationCommandController { + + private final NotificationCommandService notificationCommandService; + + @Operation(summary = "알림 읽음 처리", description = "특정 알림을 읽음 처리합니다.") + @PostMapping("/{notificationId}/read") + public ResponseEntity> markAsRead( + @PathVariable Long notificationId, + @CurrentMember @Parameter(hidden = true) Long memberId + ) { + notificationCommandService.markAllAsRead(memberId, notificationId); + return ResponseEntity.ok(ApiResponse.onSuccess(SuccessStatus._OK)); + } +} diff --git a/modules/notification/src/main/java/kr/spot/presentation/query/NotificationQueryController.java b/modules/notification/src/main/java/kr/spot/presentation/query/NotificationQueryController.java index 18ef5626..b2dc877d 100644 --- a/modules/notification/src/main/java/kr/spot/presentation/query/NotificationQueryController.java +++ b/modules/notification/src/main/java/kr/spot/presentation/query/NotificationQueryController.java @@ -11,6 +11,7 @@ import kr.spot.application.query.GetNotificationService; import kr.spot.code.status.SuccessStatus; import kr.spot.presentation.query.dto.GetNotificationListResponse; +import kr.spot.presentation.query.dto.GetUnreadNotificationResponse; import lombok.RequiredArgsConstructor; import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.GetMapping; @@ -37,4 +38,13 @@ public ResponseEntity> getMyNotificatio GetNotificationListResponse response = getNotificationService.getMyNotifications(memberId); return ResponseEntity.ok(ApiResponse.onSuccess(SuccessStatus._OK, response)); } + + @Operation(summary = "읽지 않은 알림 존재 여부 조회", description = "로그인한 사용자의 읽지 않은 알림이 있는지 여부를 조회합니다.") + @GetMapping("/me/unread") + public ResponseEntity> hasUnreadNotifications( + @CurrentMember @Parameter(hidden = true) Long memberId) { + GetUnreadNotificationResponse response = getNotificationService.hasUnreadNotifications( + memberId); + return ResponseEntity.ok(ApiResponse.onSuccess(SuccessStatus._OK, response)); + } } diff --git a/modules/notification/src/main/java/kr/spot/presentation/query/dto/GetUnreadNotificationResponse.java b/modules/notification/src/main/java/kr/spot/presentation/query/dto/GetUnreadNotificationResponse.java new file mode 100644 index 00000000..c15fbcf1 --- /dev/null +++ b/modules/notification/src/main/java/kr/spot/presentation/query/dto/GetUnreadNotificationResponse.java @@ -0,0 +1,10 @@ +package kr.spot.presentation.query.dto; + +public record GetUnreadNotificationResponse( + boolean hasUnreadNotifications +) { + + public static GetUnreadNotificationResponse of(boolean hasUnreadNotifications) { + return new GetUnreadNotificationResponse(hasUnreadNotifications); + } +}