diff --git a/docs/api-specs/notification-api.md b/docs/api-specs/notification-api.md index 1e6309c..59c7a4f 100644 --- a/docs/api-specs/notification-api.md +++ b/docs/api-specs/notification-api.md @@ -347,6 +347,8 @@ | `POST` | `/api/v1/admin/notices` | 공지사항/이벤트 작성 (즉시 전체 발송) | | `GET` | `/api/v1/admin/notices` | 공지 목록 조회 (`category`, `page`, `size` 쿼리) | | `GET` | `/api/v1/admin/notices/{noticeId}` | 공지 상세 조회 | +| `PUT` | `/api/v1/admin/notices/{noticeId}` | 공지 수정 (제목/본문) | +| `DELETE` | `/api/v1/admin/notices/{noticeId}` | 공지 삭제 | | `POST` | `/api/v1/admin/notices/test` | 특정 유저 대상 테스트 푸시 발송 (알림 설정 ON/OFF 무관) | **`POST /api/v1/admin/notices` 요청 바디** @@ -367,6 +369,26 @@ 응답은 `AdminNoticeDetailResponse` (`notificationId`, `category`, `detailCode`, `title`, `body`, `referenceId`, `createdAt`). +**`PUT /api/v1/admin/notices/{noticeId}` 요청 바디** + +```json +{ + "title": "서비스 점검 안내 (수정)", + "body": "8/30 02:00~05:00로 점검 시간이 변경되었습니다." +} +``` + +| 필드 | 타입 | 필수 | 설명 | +|---|---|---|---| +| `title` | `string` | Y | 알림 제목 | +| `body` | `string` | Y | 알림 본문 | + +`category`/`detailCode`는 등록 시점에 확정되어 수정 대상이 아닙니다. 이미 발송된 알림함/푸시는 재발송되지 않고, 알림함에 남아있는 텍스트만 갱신됩니다. 응답은 등록 API와 동일한 `AdminNoticeDetailResponse`. + +**`DELETE /api/v1/admin/notices/{noticeId}`** + +성공 시 `200 OK`, `data: null`. 삭제된 공지는 알림함에서도 사라집니다. + **`POST /api/v1/admin/notices/test` 요청 바디** ```json diff --git a/src/main/java/com/swyp/picke/domain/admin/controller/AdminNotificationController.java b/src/main/java/com/swyp/picke/domain/admin/controller/AdminNotificationController.java index c434a9c..2154814 100644 --- a/src/main/java/com/swyp/picke/domain/admin/controller/AdminNotificationController.java +++ b/src/main/java/com/swyp/picke/domain/admin/controller/AdminNotificationController.java @@ -1,6 +1,7 @@ package com.swyp.picke.domain.admin.controller; import com.swyp.picke.domain.admin.dto.notification.request.AdminNoticeCreateRequest; +import com.swyp.picke.domain.admin.dto.notification.request.AdminNoticeUpdateRequest; import com.swyp.picke.domain.admin.dto.notification.request.AdminNotificationTestRequest; import com.swyp.picke.domain.admin.dto.notification.response.AdminNoticeDetailResponse; import com.swyp.picke.domain.admin.dto.notification.response.AdminNoticeListResponse; @@ -13,9 +14,11 @@ import jakarta.validation.Valid; import lombok.RequiredArgsConstructor; import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.web.bind.annotation.DeleteMapping; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.PathVariable; import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.PutMapping; import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RequestParam; @@ -55,6 +58,22 @@ public ApiResponse getNoticeDetail(@PathVariable Long return ApiResponse.onSuccess(adminNotificationService.getNoticeDetail(noticeId)); } + @Operation(summary = "공지사항 수정", description = "이미 발송된 알림함/푸시는 재발송되지 않으며, 알림함에 남는 텍스트만 갱신된다.") + @PutMapping("/{noticeId}") + public ApiResponse updateNotice( + @PathVariable Long noticeId, + @RequestBody @Valid AdminNoticeUpdateRequest request + ) { + return ApiResponse.onSuccess(adminNotificationService.updateNotice(noticeId, request)); + } + + @Operation(summary = "공지사항 삭제") + @DeleteMapping("/{noticeId}") + public ApiResponse deleteNotice(@PathVariable Long noticeId) { + adminNotificationService.deleteNotice(noticeId); + return ApiResponse.onSuccess(null); + } + @Operation(summary = "푸시 알림 발송 테스트", description = "특정 유저의 등록된 디바이스로 알림 설정(ON/OFF) 무관하게 즉시 테스트 푸시를 발송한다.") @PostMapping("/test") public ApiResponse sendTestPush( diff --git a/src/main/java/com/swyp/picke/domain/admin/dto/notification/request/AdminNoticeUpdateRequest.java b/src/main/java/com/swyp/picke/domain/admin/dto/notification/request/AdminNoticeUpdateRequest.java new file mode 100644 index 0000000..8def0bb --- /dev/null +++ b/src/main/java/com/swyp/picke/domain/admin/dto/notification/request/AdminNoticeUpdateRequest.java @@ -0,0 +1,8 @@ +package com.swyp.picke.domain.admin.dto.notification.request; + +import jakarta.validation.constraints.NotBlank; + +public record AdminNoticeUpdateRequest( + @NotBlank String title, + @NotBlank String body +) {} diff --git a/src/main/java/com/swyp/picke/domain/admin/service/AdminNotificationService.java b/src/main/java/com/swyp/picke/domain/admin/service/AdminNotificationService.java index c5f6605..19da48c 100644 --- a/src/main/java/com/swyp/picke/domain/admin/service/AdminNotificationService.java +++ b/src/main/java/com/swyp/picke/domain/admin/service/AdminNotificationService.java @@ -1,6 +1,7 @@ package com.swyp.picke.domain.admin.service; import com.swyp.picke.domain.admin.dto.notification.request.AdminNoticeCreateRequest; +import com.swyp.picke.domain.admin.dto.notification.request.AdminNoticeUpdateRequest; import com.swyp.picke.domain.admin.dto.notification.response.AdminNoticeDetailResponse; import com.swyp.picke.domain.admin.dto.notification.response.AdminNoticeListResponse; import com.swyp.picke.domain.admin.dto.notification.response.AdminNoticeSummaryResponse; @@ -66,11 +67,26 @@ public AdminNoticeListResponse getNotices(NotificationCategory category, int pag } public AdminNoticeDetailResponse getNoticeDetail(Long notificationId) { - Notification notification = notificationRepository.findById(notificationId) + Notification notification = notificationRepository.findByIdAndDeletedAtIsNull(notificationId) .orElseThrow(() -> new CustomException(ErrorCode.NOTIFICATION_NOT_FOUND)); return toDetailResponse(notification); } + @Transactional + public AdminNoticeDetailResponse updateNotice(Long notificationId, AdminNoticeUpdateRequest request) { + Notification notification = notificationRepository.findByIdAndDeletedAtIsNull(notificationId) + .orElseThrow(() -> new CustomException(ErrorCode.NOTIFICATION_NOT_FOUND)); + notification.updateContent(request.title(), request.body()); + return toDetailResponse(notification); + } + + @Transactional + public void deleteNotice(Long notificationId) { + Notification notification = notificationRepository.findByIdAndDeletedAtIsNull(notificationId) + .orElseThrow(() -> new CustomException(ErrorCode.NOTIFICATION_NOT_FOUND)); + notification.delete(); + } + private NotificationCategory normalizeCategory(NotificationCategory category) { if (category == null || category == NotificationCategory.ALL) { return null; diff --git a/src/main/java/com/swyp/picke/domain/notification/entity/Notification.java b/src/main/java/com/swyp/picke/domain/notification/entity/Notification.java index e360140..7a773de 100644 --- a/src/main/java/com/swyp/picke/domain/notification/entity/Notification.java +++ b/src/main/java/com/swyp/picke/domain/notification/entity/Notification.java @@ -55,6 +55,9 @@ public class Notification extends BaseEntity { @Column(name = "read_at") private LocalDateTime readAt; + @Column(name = "deleted_at") + private LocalDateTime deletedAt; + @Builder private Notification(User user, NotificationCategory category, NotificationDetailCode detailCode, String title, String body, Long referenceId, Long perspectiveId) { @@ -68,10 +71,19 @@ private Notification(User user, NotificationCategory category, NotificationDetai this.read = false; } + public void updateContent(String title, String body) { + this.title = title; + this.body = body; + } + public void markAsRead() { if (!this.read) { this.read = true; this.readAt = LocalDateTime.now(); } } + + public void delete() { + this.deletedAt = LocalDateTime.now(); + } } diff --git a/src/main/java/com/swyp/picke/domain/notification/repository/NotificationRepository.java b/src/main/java/com/swyp/picke/domain/notification/repository/NotificationRepository.java index 14bf3bd..129bd0d 100644 --- a/src/main/java/com/swyp/picke/domain/notification/repository/NotificationRepository.java +++ b/src/main/java/com/swyp/picke/domain/notification/repository/NotificationRepository.java @@ -2,6 +2,7 @@ import com.swyp.picke.domain.notification.entity.Notification; import com.swyp.picke.domain.notification.enums.NotificationCategory; +import java.util.Optional; import org.springframework.data.domain.Pageable; import org.springframework.data.domain.Slice; import org.springframework.data.jpa.repository.JpaRepository; @@ -11,12 +12,15 @@ public interface NotificationRepository extends JpaRepository { + Optional findByIdAndDeletedAtIsNull(Long id); + @Query(""" SELECT n FROM Notification n WHERE ( (n.user IS NOT NULL AND n.user.id = :userId) OR n.user IS NULL ) + AND n.deletedAt IS NULL AND (:category IS NULL OR n.category = :category) ORDER BY n.createdAt DESC """) @@ -30,6 +34,7 @@ Slice findVisibleNotifications( SELECT CASE WHEN COUNT(n) > 0 THEN true ELSE false END FROM Notification n WHERE n.user IS NULL + AND n.deletedAt IS NULL AND n.category = :category AND NOT EXISTS ( SELECT 1 FROM NotificationRead nr @@ -41,7 +46,8 @@ AND NOT EXISTS ( @Query(""" SELECT CASE WHEN COUNT(n) > 0 THEN true ELSE false END FROM Notification n - WHERE (:category IS NULL OR n.category = :category) + WHERE n.deletedAt IS NULL + AND (:category IS NULL OR n.category = :category) AND ( (n.user.id = :userId AND n.read = false) OR (n.user IS NULL AND NOT EXISTS ( @@ -61,7 +67,8 @@ SELECT CASE WHEN COUNT(n) > 0 THEN true ELSE false END @Query(""" SELECT n FROM Notification n - WHERE (:category IS NULL OR n.category = :category) + WHERE n.deletedAt IS NULL + AND (:category IS NULL OR n.category = :category) ORDER BY n.createdAt DESC """) Slice findNotificationsForAdmin( diff --git a/src/main/java/com/swyp/picke/domain/notification/service/NotificationService.java b/src/main/java/com/swyp/picke/domain/notification/service/NotificationService.java index ea74943..5055033 100644 --- a/src/main/java/com/swyp/picke/domain/notification/service/NotificationService.java +++ b/src/main/java/com/swyp/picke/domain/notification/service/NotificationService.java @@ -153,7 +153,7 @@ public NotificationUnreadResponse hasUnread(Long userId, NotificationCategory ca } private Notification getAccessibleNotification(Long userId, Long notificationId) { - Notification notification = notificationRepository.findById(notificationId) + Notification notification = notificationRepository.findByIdAndDeletedAtIsNull(notificationId) .orElseThrow(() -> new CustomException(ErrorCode.NOTIFICATION_NOT_FOUND)); boolean isAccessible = notification.getUser() == null diff --git a/src/test/java/com/swyp/picke/domain/admin/service/AdminNotificationServiceTest.java b/src/test/java/com/swyp/picke/domain/admin/service/AdminNotificationServiceTest.java new file mode 100644 index 0000000..e7259b6 --- /dev/null +++ b/src/test/java/com/swyp/picke/domain/admin/service/AdminNotificationServiceTest.java @@ -0,0 +1,91 @@ +package com.swyp.picke.domain.admin.service; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.Mockito.when; + +import com.swyp.picke.domain.admin.dto.notification.request.AdminNoticeUpdateRequest; +import com.swyp.picke.domain.admin.dto.notification.response.AdminNoticeDetailResponse; +import com.swyp.picke.domain.notification.entity.Notification; +import com.swyp.picke.domain.notification.enums.NotificationCategory; +import com.swyp.picke.domain.notification.enums.NotificationDetailCode; +import com.swyp.picke.domain.notification.repository.NotificationRepository; +import com.swyp.picke.domain.notification.service.NotificationDispatchService; +import com.swyp.picke.domain.notification.service.NotificationService; +import com.swyp.picke.global.common.exception.CustomException; +import java.util.Optional; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +@ExtendWith(MockitoExtension.class) +class AdminNotificationServiceTest { + + @Mock + private NotificationService notificationService; + + @Mock + private NotificationDispatchService notificationDispatchService; + + @Mock + private NotificationRepository notificationRepository; + + @InjectMocks + private AdminNotificationService adminNotificationService; + + private Notification newNotice(String title, String body) { + return Notification.builder() + .user(null) + .category(NotificationCategory.NOTICE) + .detailCode(NotificationDetailCode.POLICY_CHANGE) + .title(title) + .body(body) + .build(); + } + + @Test + @DisplayName("공지사항을 수정한다") + void updateNotice_updatesExistingNotice() { + Notification notification = newNotice("원본 제목", "원본 본문"); + when(notificationRepository.findByIdAndDeletedAtIsNull(1L)).thenReturn(Optional.of(notification)); + + AdminNoticeDetailResponse response = adminNotificationService.updateNotice( + 1L, new AdminNoticeUpdateRequest("수정된 제목", "수정된 본문")); + + assertThat(response.title()).isEqualTo("수정된 제목"); + assertThat(response.body()).isEqualTo("수정된 본문"); + } + + @Test + @DisplayName("존재하지 않는 공지사항을 수정하면 예외를 던진다") + void updateNotice_throws_whenNotFound() { + when(notificationRepository.findByIdAndDeletedAtIsNull(999L)).thenReturn(Optional.empty()); + + assertThatThrownBy(() -> adminNotificationService.updateNotice( + 999L, new AdminNoticeUpdateRequest("제목", "본문"))) + .isInstanceOf(CustomException.class); + } + + @Test + @DisplayName("공지사항을 삭제하면 deletedAt이 기록된다") + void deleteNotice_softDeletesExistingNotice() { + Notification notification = newNotice("제목", "본문"); + when(notificationRepository.findByIdAndDeletedAtIsNull(1L)).thenReturn(Optional.of(notification)); + + adminNotificationService.deleteNotice(1L); + + assertThat(notification.getDeletedAt()).isNotNull(); + } + + @Test + @DisplayName("존재하지 않는 공지사항을 삭제하면 예외를 던진다") + void deleteNotice_throws_whenNotFound() { + when(notificationRepository.findByIdAndDeletedAtIsNull(999L)).thenReturn(Optional.empty()); + + assertThatThrownBy(() -> adminNotificationService.deleteNotice(999L)) + .isInstanceOf(CustomException.class); + } +} diff --git a/src/test/java/com/swyp/picke/domain/notification/service/NotificationServiceTest.java b/src/test/java/com/swyp/picke/domain/notification/service/NotificationServiceTest.java index 0afe16f..f413779 100644 --- a/src/test/java/com/swyp/picke/domain/notification/service/NotificationServiceTest.java +++ b/src/test/java/com/swyp/picke/domain/notification/service/NotificationServiceTest.java @@ -164,7 +164,7 @@ void getNotifications_includes_content_broadcast_notifications() { @Test @DisplayName("존재하지 않는 알림 읽음 처리 시 예외를 던진다") void markAsRead_throws_when_not_found() { - when(notificationRepository.findById(999L)).thenReturn(Optional.empty()); + when(notificationRepository.findByIdAndDeletedAtIsNull(999L)).thenReturn(Optional.empty()); assertThatThrownBy(() -> notificationService.markAsRead(1L, 999L)) .isInstanceOf(CustomException.class); @@ -184,7 +184,7 @@ void markAsRead_saves_notification_read_for_broadcast() { .referenceId(50L) .build(); - when(notificationRepository.findById(notificationId)).thenReturn(Optional.of(notification)); + when(notificationRepository.findByIdAndDeletedAtIsNull(notificationId)).thenReturn(Optional.of(notification)); when(notificationReadRepository.existsByNotificationIdAndUserId(notificationId, userId)).thenReturn(false); notificationService.markAsRead(userId, notificationId); @@ -209,7 +209,7 @@ void getNotificationDetail_returns_owned_notification() { setUserId(user, userId); setNotificationId(notification, 10L); - when(notificationRepository.findById(10L)).thenReturn(Optional.of(notification)); + when(notificationRepository.findByIdAndDeletedAtIsNull(10L)).thenReturn(Optional.of(notification)); NotificationDetailResponse response = notificationService.getNotificationDetail(userId, 10L); @@ -235,7 +235,7 @@ void getNotificationDetail_returns_broadcast_notification() { setNotificationId(notification, notificationId); - when(notificationRepository.findById(notificationId)).thenReturn(Optional.of(notification)); + when(notificationRepository.findByIdAndDeletedAtIsNull(notificationId)).thenReturn(Optional.of(notification)); when(notificationReadRepository.existsByNotificationIdAndUserId(notificationId, userId)).thenReturn(false); NotificationDetailResponse response = notificationService.getNotificationDetail(userId, notificationId); @@ -263,7 +263,7 @@ void getNotificationDetail_throws_when_notification_not_accessible() { .build(); setUserId(owner, ownerId); - when(notificationRepository.findById(30L)).thenReturn(Optional.of(notification)); + when(notificationRepository.findByIdAndDeletedAtIsNull(30L)).thenReturn(Optional.of(notification)); assertThatThrownBy(() -> notificationService.getNotificationDetail(requesterId, 30L)) .isInstanceOf(CustomException.class);