Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
22 changes: 22 additions & 0 deletions docs/api-specs/notification-api.md
Original file line number Diff line number Diff line change
Expand Up @@ -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` 요청 바디**
Expand All @@ -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
Expand Down
Original file line number Diff line number Diff line change
@@ -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;
Expand All @@ -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;
Expand Down Expand Up @@ -55,6 +58,22 @@ public ApiResponse<AdminNoticeDetailResponse> getNoticeDetail(@PathVariable Long
return ApiResponse.onSuccess(adminNotificationService.getNoticeDetail(noticeId));
}

@Operation(summary = "공지사항 수정", description = "이미 발송된 알림함/푸시는 재발송되지 않으며, 알림함에 남는 텍스트만 갱신된다.")
@PutMapping("/{noticeId}")
public ApiResponse<AdminNoticeDetailResponse> updateNotice(
@PathVariable Long noticeId,
@RequestBody @Valid AdminNoticeUpdateRequest request
) {
return ApiResponse.onSuccess(adminNotificationService.updateNotice(noticeId, request));
}

@Operation(summary = "공지사항 삭제")
@DeleteMapping("/{noticeId}")
public ApiResponse<Void> deleteNotice(@PathVariable Long noticeId) {
adminNotificationService.deleteNotice(noticeId);
return ApiResponse.onSuccess(null);
}

@Operation(summary = "푸시 알림 발송 테스트", description = "특정 유저의 등록된 디바이스로 알림 설정(ON/OFF) 무관하게 즉시 테스트 푸시를 발송한다.")
@PostMapping("/test")
public ApiResponse<Void> sendTestPush(
Expand Down
Original file line number Diff line number Diff line change
@@ -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
) {}
Original file line number Diff line number Diff line change
@@ -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;
Expand Down Expand Up @@ -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;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand All @@ -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();
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -11,12 +12,15 @@

public interface NotificationRepository extends JpaRepository<Notification, Long> {

Optional<Notification> 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
""")
Expand All @@ -30,6 +34,7 @@ Slice<Notification> 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
Expand All @@ -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 (
Expand All @@ -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<Notification> findNotificationsForAdmin(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
@@ -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);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand All @@ -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);
Expand All @@ -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);

Expand All @@ -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);
Expand Down Expand Up @@ -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);
Expand Down
Loading