diff --git a/docs/api-specs/notification-api.md b/docs/api-specs/notification-api.md index 0d6069b..1e6309c 100644 --- a/docs/api-specs/notification-api.md +++ b/docs/api-specs/notification-api.md @@ -334,10 +334,127 @@ --- -## 6. 에러 코드 +## 6. 관리자 API + +모든 API는 `ROLE_ADMIN` 권한을 가진 계정만 호출 가능합니다 (`Authorization: Bearer {access_token}`, 403 시 `COMMON_403` 등 공통 권한 에러). + +### 6.1 공지/이벤트 (`/api/v1/admin/notices`) + +즉시 발송되는 공지사항/이벤트 알림을 관리합니다. + +| Method | Path | 설명 | +|---|---|---| +| `POST` | `/api/v1/admin/notices` | 공지사항/이벤트 작성 (즉시 전체 발송) | +| `GET` | `/api/v1/admin/notices` | 공지 목록 조회 (`category`, `page`, `size` 쿼리) | +| `GET` | `/api/v1/admin/notices/{noticeId}` | 공지 상세 조회 | +| `POST` | `/api/v1/admin/notices/test` | 특정 유저 대상 테스트 푸시 발송 (알림 설정 ON/OFF 무관) | + +**`POST /api/v1/admin/notices` 요청 바디** + +```json +{ + "category": "NOTICE", + "title": "서비스 점검 안내", + "body": "8/30 02:00~04:00 점검이 진행됩니다." +} +``` + +| 필드 | 타입 | 필수 | 설명 | +|---|---|---|---| +| `category` | `string` | Y | `CONTENT` \| `NOTICE` \| `EVENT` | +| `title` | `string` | Y | 알림 제목 | +| `body` | `string` | Y | 알림 본문 | + +응답은 `AdminNoticeDetailResponse` (`notificationId`, `category`, `detailCode`, `title`, `body`, `referenceId`, `createdAt`). + +**`POST /api/v1/admin/notices/test` 요청 바디** + +```json +{ + "userId": 123, + "title": "테스트 알림", + "body": "테스트 발송입니다." +} +``` + +### 6.2 예약 알림 (`/api/v1/admin/notification-schedules`) + +매일 지정된 시각에 전체 유저에게 자동 발송되는 예약 알림(`NotificationSchedule`)을 관리합니다. 매분 `NotificationScheduleDispatcher`가 `enabled=true`인 예약 중 `sendTime`(시:분)이 현재 시각과 일치하는 건을 찾아 발송하며, 같은 날 중복 발송되지 않도록 `lastSentDate`로 발송 이력을 관리합니다. + +| Method | Path | 설명 | +|---|---|---| +| `POST` | `/api/v1/admin/notification-schedules` | 예약 알림 등록 | +| `GET` | `/api/v1/admin/notification-schedules` | 예약 알림 전체 목록 조회 | +| `GET` | `/api/v1/admin/notification-schedules/{scheduleId}` | 예약 알림 상세 조회 | +| `PUT` | `/api/v1/admin/notification-schedules/{scheduleId}` | 예약 알림 수정 (제목/부제목/발송시간/on-off 전체 교체) | +| `PATCH` | `/api/v1/admin/notification-schedules/{scheduleId}/toggle` | 예약 알림 On/Off만 전환 | +| `DELETE` | `/api/v1/admin/notification-schedules/{scheduleId}` | 예약 알림 삭제 | + +**`POST` / `PUT` 요청 바디** + +```json +{ + "title": "오늘의 질문", + "subtitle": "지금 확인해보세요", + "sendTime": "19:00:00", + "enabled": true +} +``` + +| 필드 | 타입 | 필수 | 설명 | +|---|---|---|---| +| `title` | `string` | Y | 알림 제목 | +| `subtitle` | `string` | Y | 알림 부제목(본문) | +| `sendTime` | `string` (`HH:mm:ss`) | Y | 매일 발송할 시각 (KST 기준, 분 단위로 매칭) | +| `enabled` | `boolean` | Y | 활성화 여부 | + +**`PATCH .../toggle` 요청 바디** + +```json +{ + "enabled": false +} +``` + +**응답 (`AdminNotificationScheduleResponse`)** + +```json +{ + "statusCode": 200, + "data": { + "id": 3, + "title": "오늘의 질문", + "subtitle": "지금 확인해보세요", + "sendTime": "19:00:00", + "enabled": true, + "createdAt": "2026-08-20T10:00:00" + }, + "error": null +} +``` + +목록 조회(`GET /api/v1/admin/notification-schedules`) 응답은 `{ "schedules": [AdminNotificationScheduleResponse, ...] }` 형태입니다. + +예외 응답 `404 - 예약 알림 없음`: + +```json +{ + "statusCode": 404, + "data": null, + "error": { + "code": "NOTIFICATION_404_SCHEDULE", + "message": "존재하지 않는 알림 예약입니다." + } +} +``` + +--- + +## 7. 에러 코드 | Error Code | HTTP Status | 설명 | |---|:---:|---| | `COMMON_400` | `400` | 요청 파라미터가 잘못되었습니다. (예: `fcmToken`/`platform` 누락) | | `USER_404` | `404` | 존재하지 않는 사용자입니다. | | `NOTIFICATION_404` | `404` | 존재하지 않는 알림입니다. (본인 소유가 아닌 알림 포함) | +| `NOTIFICATION_404_SCHEDULE` | `404` | 존재하지 않는 예약 알림입니다. | diff --git a/src/main/java/com/swyp/picke/domain/admin/controller/AdminNotificationScheduleController.java b/src/main/java/com/swyp/picke/domain/admin/controller/AdminNotificationScheduleController.java index 24b8770..09982ec 100644 --- a/src/main/java/com/swyp/picke/domain/admin/controller/AdminNotificationScheduleController.java +++ b/src/main/java/com/swyp/picke/domain/admin/controller/AdminNotificationScheduleController.java @@ -1,6 +1,7 @@ package com.swyp.picke.domain.admin.controller; import com.swyp.picke.domain.admin.dto.notification.request.AdminNotificationScheduleRequest; +import com.swyp.picke.domain.admin.dto.notification.request.AdminNotificationScheduleToggleRequest; import com.swyp.picke.domain.admin.dto.notification.response.AdminNotificationScheduleListResponse; import com.swyp.picke.domain.admin.dto.notification.response.AdminNotificationScheduleResponse; import com.swyp.picke.domain.admin.service.AdminNotificationScheduleService; @@ -14,6 +15,7 @@ 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.PatchMapping; import org.springframework.web.bind.annotation.PutMapping; import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RequestMapping; @@ -57,6 +59,15 @@ public ApiResponse update( return ApiResponse.onSuccess(adminNotificationScheduleService.update(scheduleId, request)); } + @Operation(summary = "예약 알림 On/Off 전환") + @PatchMapping("/{scheduleId}/toggle") + public ApiResponse toggle( + @PathVariable Long scheduleId, + @RequestBody @Valid AdminNotificationScheduleToggleRequest request + ) { + return ApiResponse.onSuccess(adminNotificationScheduleService.toggle(scheduleId, request)); + } + @Operation(summary = "예약 알림 삭제") @DeleteMapping("/{scheduleId}") public ApiResponse delete(@PathVariable Long scheduleId) { diff --git a/src/main/java/com/swyp/picke/domain/admin/dto/notification/request/AdminNotificationScheduleToggleRequest.java b/src/main/java/com/swyp/picke/domain/admin/dto/notification/request/AdminNotificationScheduleToggleRequest.java new file mode 100644 index 0000000..db4b63f --- /dev/null +++ b/src/main/java/com/swyp/picke/domain/admin/dto/notification/request/AdminNotificationScheduleToggleRequest.java @@ -0,0 +1,7 @@ +package com.swyp.picke.domain.admin.dto.notification.request; + +import jakarta.validation.constraints.NotNull; + +public record AdminNotificationScheduleToggleRequest( + @NotNull Boolean enabled +) {} diff --git a/src/main/java/com/swyp/picke/domain/admin/service/AdminNotificationScheduleService.java b/src/main/java/com/swyp/picke/domain/admin/service/AdminNotificationScheduleService.java index 7e07cc4..4bc06cc 100644 --- a/src/main/java/com/swyp/picke/domain/admin/service/AdminNotificationScheduleService.java +++ b/src/main/java/com/swyp/picke/domain/admin/service/AdminNotificationScheduleService.java @@ -1,6 +1,7 @@ package com.swyp.picke.domain.admin.service; import com.swyp.picke.domain.admin.dto.notification.request.AdminNotificationScheduleRequest; +import com.swyp.picke.domain.admin.dto.notification.request.AdminNotificationScheduleToggleRequest; import com.swyp.picke.domain.admin.dto.notification.response.AdminNotificationScheduleListResponse; import com.swyp.picke.domain.admin.dto.notification.response.AdminNotificationScheduleResponse; import com.swyp.picke.domain.notification.entity.NotificationSchedule; @@ -49,6 +50,13 @@ public AdminNotificationScheduleResponse update(Long scheduleId, AdminNotificati return toResponse(schedule); } + @Transactional + public AdminNotificationScheduleResponse toggle(Long scheduleId, AdminNotificationScheduleToggleRequest request) { + NotificationSchedule schedule = getExistingSchedule(scheduleId); + schedule.changeEnabled(request.enabled()); + return toResponse(schedule); + } + @Transactional public void delete(Long scheduleId) { NotificationSchedule schedule = getExistingSchedule(scheduleId); diff --git a/src/main/java/com/swyp/picke/domain/notification/entity/NotificationSchedule.java b/src/main/java/com/swyp/picke/domain/notification/entity/NotificationSchedule.java index 6e94e7f..d17b69d 100644 --- a/src/main/java/com/swyp/picke/domain/notification/entity/NotificationSchedule.java +++ b/src/main/java/com/swyp/picke/domain/notification/entity/NotificationSchedule.java @@ -47,6 +47,10 @@ public void update(String title, String subtitle, LocalTime sendTime, boolean en this.enabled = enabled; } + public void changeEnabled(boolean enabled) { + this.enabled = enabled; + } + public boolean isDue(LocalTime now, LocalDate today) { return enabled && sendTime.getHour() == now.getHour() diff --git a/src/test/java/com/swyp/picke/domain/admin/service/AdminNotificationScheduleServiceTest.java b/src/test/java/com/swyp/picke/domain/admin/service/AdminNotificationScheduleServiceTest.java index fd6424a..b5508bf 100644 --- a/src/test/java/com/swyp/picke/domain/admin/service/AdminNotificationScheduleServiceTest.java +++ b/src/test/java/com/swyp/picke/domain/admin/service/AdminNotificationScheduleServiceTest.java @@ -7,6 +7,7 @@ import static org.mockito.Mockito.when; import com.swyp.picke.domain.admin.dto.notification.request.AdminNotificationScheduleRequest; +import com.swyp.picke.domain.admin.dto.notification.request.AdminNotificationScheduleToggleRequest; import com.swyp.picke.domain.admin.dto.notification.response.AdminNotificationScheduleListResponse; import com.swyp.picke.domain.admin.dto.notification.response.AdminNotificationScheduleResponse; import com.swyp.picke.domain.notification.entity.NotificationSchedule; @@ -106,6 +107,32 @@ void update_throws_whenNotFound() { .isInstanceOf(CustomException.class); } + @Test + @DisplayName("예약 알림의 On/Off 상태를 전환한다") + void toggle_updatesEnabledState() { + NotificationSchedule schedule = NotificationSchedule.builder() + .title("오늘의 질문") + .subtitle("지금 확인해보세요") + .sendTime(LocalTime.of(19, 0)) + .enabled(true) + .build(); + when(notificationScheduleRepository.findById(1L)).thenReturn(Optional.of(schedule)); + + AdminNotificationScheduleResponse response = + adminNotificationScheduleService.toggle(1L, new AdminNotificationScheduleToggleRequest(false)); + + assertThat(response.enabled()).isFalse(); + } + + @Test + @DisplayName("존재하지 않는 예약 알림을 전환하면 예외를 던진다") + void toggle_throws_whenNotFound() { + when(notificationScheduleRepository.findById(999L)).thenReturn(Optional.empty()); + + assertThatThrownBy(() -> adminNotificationScheduleService.toggle(999L, new AdminNotificationScheduleToggleRequest(true))) + .isInstanceOf(CustomException.class); + } + @Test @DisplayName("예약 알림을 삭제한다") void delete_removesExistingSchedule() {