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
119 changes: 118 additions & 1 deletion docs/api-specs/notification-api.md
Original file line number Diff line number Diff line change
Expand Up @@ -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` | 존재하지 않는 예약 알림입니다. |
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.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;
Expand All @@ -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;
Expand Down Expand Up @@ -57,6 +59,15 @@ public ApiResponse<AdminNotificationScheduleResponse> update(
return ApiResponse.onSuccess(adminNotificationScheduleService.update(scheduleId, request));
}

@Operation(summary = "예약 알림 On/Off 전환")
@PatchMapping("/{scheduleId}/toggle")
public ApiResponse<AdminNotificationScheduleResponse> toggle(
@PathVariable Long scheduleId,
@RequestBody @Valid AdminNotificationScheduleToggleRequest request
) {
return ApiResponse.onSuccess(adminNotificationScheduleService.toggle(scheduleId, request));
}

@Operation(summary = "예약 알림 삭제")
@DeleteMapping("/{scheduleId}")
public ApiResponse<Void> delete(@PathVariable Long scheduleId) {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
package com.swyp.picke.domain.admin.dto.notification.request;

import jakarta.validation.constraints.NotNull;

public record AdminNotificationScheduleToggleRequest(
@NotNull Boolean enabled
) {}
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.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;
Expand Down Expand Up @@ -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);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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() {
Expand Down
Loading