diff --git a/.github/workflows/docker-be.yml b/.github/workflows/docker-be.yml index ba8ec87..f467a9b 100644 --- a/.github/workflows/docker-be.yml +++ b/.github/workflows/docker-be.yml @@ -37,16 +37,16 @@ jobs: steps: - name: Checkout - uses: actions/checkout@v6 + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 - name: Setup Java - uses: actions/setup-java@v5 + uses: actions/setup-java@c1e323688fd81a25caa38c78aa6df2d33d3e20d9 # v4 with: distribution: temurin java-version: "17" - name: Setup Gradle - uses: gradle/actions/setup-gradle@v5 + uses: gradle/actions/setup-gradle@4c125117fe7c5aed11272ec4213f602f012f89f2 # v5 - name: Make gradlew executable run: chmod +x gradlew @@ -64,10 +64,10 @@ jobs: steps: - name: Checkout - uses: actions/checkout@v6 + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 - name: Login Docker Hub - uses: docker/login-action@v4 + uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee # v4 with: username: ${{ secrets.DOCKERHUB_USERNAME }} password: ${{ secrets.DOCKERHUB_TOKEN }} @@ -84,7 +84,7 @@ jobs: fi - name: Build and Push - uses: docker/build-push-action@v7 + uses: docker/build-push-action@f9f3042f7e2789586610d6e8b85c8f03e5195baf # v7 with: context: . push: true diff --git a/deploy/docker-compose.yml b/deploy/docker-compose.yml index c357c30..95ee5c4 100644 --- a/deploy/docker-compose.yml +++ b/deploy/docker-compose.yml @@ -93,6 +93,7 @@ services: AUTH_EMAIL_SUBJECT: ${AUTH_EMAIL_SUBJECT:-[GACHI] Email verification code} AUTH_RATE_LIMIT_EMAIL_HMAC_SECRET: ${AUTH_RATE_LIMIT_EMAIL_HMAC_SECRET} AUTH_RATE_LIMIT_TRUSTED_PROXIES: ${AUTH_RATE_LIMIT_TRUSTED_PROXIES:-127.0.0.1,::1} + MANAGEMENT_HEALTH_MAIL_ENABLED: ${MANAGEMENT_HEALTH_MAIL_ENABLED:-false} CLOVA_OCR_API_URL: ${CLOVA_OCR_API_URL} CLOVA_OCR_SECRET_KEY: ${CLOVA_OCR_SECRET_KEY} PAPAGO_CLIENT_ID: ${PAPAGO_CLIENT_ID} diff --git a/docs/error-code.md b/docs/error-code.md index 890315f..72a89d2 100644 --- a/docs/error-code.md +++ b/docs/error-code.md @@ -2,7 +2,7 @@ 상세 에러 코드는 아래 Google Sheets를 단일 원본으로 관리합니다. -- Google Sheets: [Error Code Single Source](https://docs.google.com/spreadsheets/d/1sUG_JJsVl6a8nR6k8jO_b7UrIEZzuvQdPB6S2S6fqgo/edit?gid=1519705696#gid=1519705696) +- Google Sheets: [Error Code Single Source](https://docs.google.com/spreadsheets/d/1sUG_JJsVl6a8nR6k8jO_b7UrIEZzuvQdPB6S2S6fqgo/edit?gid=1155198711#gid=1155198711) ## 운영 원칙 diff --git a/src/main/java/com/gachi/be/domain/notification/api/controller/NotificationController.java b/src/main/java/com/gachi/be/domain/notification/api/controller/NotificationController.java new file mode 100644 index 0000000..177b022 --- /dev/null +++ b/src/main/java/com/gachi/be/domain/notification/api/controller/NotificationController.java @@ -0,0 +1,124 @@ +package com.gachi.be.domain.notification.api.controller; + +import com.gachi.be.domain.notification.dto.request.NotificationReadRequest; +import com.gachi.be.domain.notification.dto.request.PushTokenDeleteRequest; +import com.gachi.be.domain.notification.dto.request.PushTokenRegisterRequest; +import com.gachi.be.domain.notification.dto.response.NotificationListResponse; +import com.gachi.be.domain.notification.dto.response.NotificationReadResponse; +import com.gachi.be.domain.notification.dto.response.NotificationUnreadCountResponse; +import com.gachi.be.domain.notification.dto.response.PushTokenResponse; +import com.gachi.be.domain.notification.service.NotificationService; +import com.gachi.be.global.api.ApiResponse; +import com.gachi.be.global.code.SuccessCode; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.security.SecurityRequirement; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.validation.Valid; +import jakarta.validation.constraints.Max; +import jakarta.validation.constraints.Min; +import lombok.RequiredArgsConstructor; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.validation.annotation.Validated; +import org.springframework.web.bind.annotation.DeleteMapping; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PatchMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +@Tag(name = "Notification", description = "알림 보관함 및 푸시 토큰 API") +@SecurityRequirement(name = "bearerAuth") +@RestController +@RequiredArgsConstructor +@Validated +@RequestMapping("/api/v1/notifications") +public class NotificationController { + + private final NotificationService notificationService; + + /** 푸시 수신 누락을 복구하기 위해 서버에 저장된 알림 보관함을 최신순으로 조회한다. */ + @Operation( + summary = "알림 목록 조회", + description = + """ + React Native 앱이 푸시를 받지 못한 경우에도 이 API로 서버 보관함을 동기화할 수 있습니다. + cursor는 이전 응답의 nextCursor를 그대로 전달하고, unreadOnly=true면 미읽음 알림만 조회합니다. + """) + @GetMapping + public ApiResponse getNotifications( + @AuthenticationPrincipal Long userId, + @Parameter(description = "다음 페이지 조회용 커서. 이전 응답의 nextCursor") @RequestParam(required = false) + Long cursor, + @Parameter(description = "조회 크기. 기본 20, 최대 100") + @RequestParam(defaultValue = "20") + @Min(1) + @Max(100) + Integer size, + @Parameter(description = "미읽음 알림만 조회할지 여부") @RequestParam(defaultValue = "false") + boolean unreadOnly) { + return ApiResponse.success( + SuccessCode.NOTIFICATION_LIST_SUCCESS, + notificationService.getNotifications(userId, cursor, size, unreadOnly)); + } + + @Operation(summary = "미읽음 알림 수 조회", description = "사용자 기준 미읽음 알림 개수를 반환합니다.") + @GetMapping("/unread-count") + public ApiResponse getUnreadCount( + @AuthenticationPrincipal Long userId) { + return ApiResponse.success( + SuccessCode.NOTIFICATION_UNREAD_COUNT_SUCCESS, notificationService.getUnreadCount(userId)); + } + + @Operation(summary = "단건 읽음 처리", description = "알림 상세 진입 또는 알림 탭 노출 후 단건 읽음 처리에 사용합니다.") + @PatchMapping("/{notificationId}/read") + public ApiResponse markRead( + @AuthenticationPrincipal Long userId, @PathVariable Long notificationId) { + return ApiResponse.success( + SuccessCode.NOTIFICATION_READ_SUCCESS, + notificationService.markRead(userId, notificationId)); + } + + @Operation(summary = "일괄 읽음 처리", description = "앱이 서버 보관함을 동기화한 뒤 여러 알림을 한 번에 읽음 처리합니다.") + @PatchMapping("/read") + public ApiResponse markRead( + @AuthenticationPrincipal Long userId, @Valid @RequestBody NotificationReadRequest request) { + return ApiResponse.success( + SuccessCode.NOTIFICATION_READ_SUCCESS, notificationService.markRead(userId, request)); + } + + @Operation(summary = "전체 읽음 처리", description = "현재 사용자의 미읽음 알림을 모두 읽음 처리합니다.") + @PatchMapping("/read-all") + public ApiResponse markAllRead(@AuthenticationPrincipal Long userId) { + return ApiResponse.success( + SuccessCode.NOTIFICATION_READ_SUCCESS, notificationService.markAllRead(userId)); + } + + @Operation( + summary = "푸시 토큰 등록/갱신", + description = + """ + RN 앱 시작, 로그인 직후, 토큰 refresh 이벤트에서 호출합니다. + 같은 토큰이 재등록되면 기존 레코드를 활성화하고 플랫폼/디바이스 정보를 갱신합니다. + """) + @PostMapping("/tokens") + public ApiResponse registerPushToken( + @AuthenticationPrincipal Long userId, @Valid @RequestBody PushTokenRegisterRequest request) { + return ApiResponse.success( + SuccessCode.NOTIFICATION_PUSH_TOKEN_REGISTERED, + notificationService.registerPushToken(userId, request)); + } + + @Operation( + summary = "푸시 토큰 삭제", + description = "로그아웃, 권한 철회, 앱 삭제 전 토큰 정리 시 호출합니다. 이미 삭제된 토큰도 성공으로 처리합니다.") + @DeleteMapping("/tokens") + public ApiResponse deletePushToken( + @AuthenticationPrincipal Long userId, @Valid @RequestBody PushTokenDeleteRequest request) { + notificationService.deletePushToken(userId, request); + return ApiResponse.success(SuccessCode.NOTIFICATION_PUSH_TOKEN_DELETED, null); + } +} diff --git a/src/main/java/com/gachi/be/domain/notification/dto/request/NotificationReadRequest.java b/src/main/java/com/gachi/be/domain/notification/dto/request/NotificationReadRequest.java new file mode 100644 index 0000000..e60d2fe --- /dev/null +++ b/src/main/java/com/gachi/be/domain/notification/dto/request/NotificationReadRequest.java @@ -0,0 +1,9 @@ +package com.gachi.be.domain.notification.dto.request; + +import jakarta.validation.constraints.NotEmpty; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Size; +import java.util.List; + +public record NotificationReadRequest( + @NotEmpty @Size(max = 100) List<@NotNull Long> notificationIds) {} diff --git a/src/main/java/com/gachi/be/domain/notification/dto/request/PushTokenDeleteRequest.java b/src/main/java/com/gachi/be/domain/notification/dto/request/PushTokenDeleteRequest.java new file mode 100644 index 0000000..a34a00a --- /dev/null +++ b/src/main/java/com/gachi/be/domain/notification/dto/request/PushTokenDeleteRequest.java @@ -0,0 +1,6 @@ +package com.gachi.be.domain.notification.dto.request; + +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Size; + +public record PushTokenDeleteRequest(@NotBlank @Size(max = 512) String token) {} diff --git a/src/main/java/com/gachi/be/domain/notification/dto/request/PushTokenRegisterRequest.java b/src/main/java/com/gachi/be/domain/notification/dto/request/PushTokenRegisterRequest.java new file mode 100644 index 0000000..4c38911 --- /dev/null +++ b/src/main/java/com/gachi/be/domain/notification/dto/request/PushTokenRegisterRequest.java @@ -0,0 +1,12 @@ +package com.gachi.be.domain.notification.dto.request; + +import com.gachi.be.domain.notification.entity.enums.PushPlatform; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Size; + +public record PushTokenRegisterRequest( + @NotNull PushPlatform platform, + @NotBlank @Size(max = 512) String token, + @Size(max = 128) String deviceId, + @Size(max = 50) String appVersion) {} diff --git a/src/main/java/com/gachi/be/domain/notification/dto/response/NotificationListResponse.java b/src/main/java/com/gachi/be/domain/notification/dto/response/NotificationListResponse.java new file mode 100644 index 0000000..5b7f333 --- /dev/null +++ b/src/main/java/com/gachi/be/domain/notification/dto/response/NotificationListResponse.java @@ -0,0 +1,6 @@ +package com.gachi.be.domain.notification.dto.response; + +import java.util.List; + +public record NotificationListResponse( + List notifications, Long nextCursor, boolean hasNext) {} diff --git a/src/main/java/com/gachi/be/domain/notification/dto/response/NotificationReadResponse.java b/src/main/java/com/gachi/be/domain/notification/dto/response/NotificationReadResponse.java new file mode 100644 index 0000000..5e78462 --- /dev/null +++ b/src/main/java/com/gachi/be/domain/notification/dto/response/NotificationReadResponse.java @@ -0,0 +1,3 @@ +package com.gachi.be.domain.notification.dto.response; + +public record NotificationReadResponse(int readCount) {} diff --git a/src/main/java/com/gachi/be/domain/notification/dto/response/NotificationResponse.java b/src/main/java/com/gachi/be/domain/notification/dto/response/NotificationResponse.java new file mode 100644 index 0000000..1e3e7ed --- /dev/null +++ b/src/main/java/com/gachi/be/domain/notification/dto/response/NotificationResponse.java @@ -0,0 +1,15 @@ +package com.gachi.be.domain.notification.dto.response; + +import com.gachi.be.domain.notification.entity.enums.NotificationType; +import java.time.OffsetDateTime; +import java.util.Map; + +public record NotificationResponse( + Long id, + NotificationType type, + String title, + String body, + Map payload, + boolean read, + OffsetDateTime readAt, + OffsetDateTime createdAt) {} diff --git a/src/main/java/com/gachi/be/domain/notification/dto/response/NotificationUnreadCountResponse.java b/src/main/java/com/gachi/be/domain/notification/dto/response/NotificationUnreadCountResponse.java new file mode 100644 index 0000000..e978013 --- /dev/null +++ b/src/main/java/com/gachi/be/domain/notification/dto/response/NotificationUnreadCountResponse.java @@ -0,0 +1,3 @@ +package com.gachi.be.domain.notification.dto.response; + +public record NotificationUnreadCountResponse(long unreadCount) {} diff --git a/src/main/java/com/gachi/be/domain/notification/dto/response/PushTokenResponse.java b/src/main/java/com/gachi/be/domain/notification/dto/response/PushTokenResponse.java new file mode 100644 index 0000000..457871f --- /dev/null +++ b/src/main/java/com/gachi/be/domain/notification/dto/response/PushTokenResponse.java @@ -0,0 +1,12 @@ +package com.gachi.be.domain.notification.dto.response; + +import com.gachi.be.domain.notification.entity.enums.PushPlatform; +import java.time.OffsetDateTime; + +public record PushTokenResponse( + Long id, + PushPlatform platform, + String deviceId, + String appVersion, + boolean enabled, + OffsetDateTime lastRegisteredAt) {} diff --git a/src/main/java/com/gachi/be/domain/notification/entity/Notification.java b/src/main/java/com/gachi/be/domain/notification/entity/Notification.java new file mode 100644 index 0000000..d1c38b7 --- /dev/null +++ b/src/main/java/com/gachi/be/domain/notification/entity/Notification.java @@ -0,0 +1,98 @@ +package com.gachi.be.domain.notification.entity; + +import com.gachi.be.domain.notification.entity.enums.NotificationType; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.EnumType; +import jakarta.persistence.Enumerated; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.PrePersist; +import jakarta.persistence.PreUpdate; +import jakarta.persistence.Table; +import java.time.OffsetDateTime; +import lombok.AccessLevel; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +/** 푸시 수신 실패에도 앱이 다시 조회할 수 있는 사용자별 알림 보관함 엔티티. */ +@Getter +@Entity +@Table(name = "notifications") +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class Notification { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(name = "user_id", nullable = false) + private Long userId; + + @Enumerated(EnumType.STRING) + @Column(nullable = false, length = 40) + private NotificationType type; + + @Column(nullable = false, length = 120) + private String title; + + @Column(nullable = false, columnDefinition = "TEXT") + private String body; + + @Column(name = "payload_json", columnDefinition = "TEXT") + private String payloadJson; + + @Column(name = "dedupe_key", length = 255) + private String dedupeKey; + + @Column(name = "read_at") + private OffsetDateTime readAt; + + @Column(name = "created_at", nullable = false, updatable = false) + private OffsetDateTime createdAt; + + @Column(name = "updated_at", nullable = false) + private OffsetDateTime updatedAt; + + @Builder + public Notification( + Long userId, + NotificationType type, + String title, + String body, + String payloadJson, + String dedupeKey) { + this.userId = userId; + this.type = type; + this.title = title; + this.body = body; + this.payloadJson = payloadJson; + this.dedupeKey = dedupeKey; + } + + public boolean isRead() { + return readAt != null; + } + + public void markRead() { + if (readAt == null) { + readAt = OffsetDateTime.now(); + } + } + + @PrePersist + protected void onCreate() { + OffsetDateTime now = OffsetDateTime.now(); + if (createdAt == null) { + createdAt = now; + } + updatedAt = now; + } + + @PreUpdate + protected void onUpdate() { + updatedAt = OffsetDateTime.now(); + } +} diff --git a/src/main/java/com/gachi/be/domain/notification/entity/NotificationDeliveryLog.java b/src/main/java/com/gachi/be/domain/notification/entity/NotificationDeliveryLog.java new file mode 100644 index 0000000..a49e919 --- /dev/null +++ b/src/main/java/com/gachi/be/domain/notification/entity/NotificationDeliveryLog.java @@ -0,0 +1,91 @@ +package com.gachi.be.domain.notification.entity; + +import com.gachi.be.domain.notification.entity.enums.NotificationDeliveryStatus; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.EnumType; +import jakarta.persistence.Enumerated; +import jakarta.persistence.FetchType; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.ManyToOne; +import jakarta.persistence.PrePersist; +import jakarta.persistence.PreUpdate; +import jakarta.persistence.Table; +import java.time.OffsetDateTime; +import lombok.AccessLevel; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +/** 푸시 발송 시도별 성공/실패 원인을 남기는 추적 엔티티. */ +@Getter +@Entity +@Table(name = "notification_delivery_logs") +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class NotificationDeliveryLog { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @ManyToOne(fetch = FetchType.LAZY, optional = false) + @JoinColumn(name = "notification_id", nullable = false) + private Notification notification; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "push_device_token_id") + private PushDeviceToken pushDeviceToken; + + @Enumerated(EnumType.STRING) + @Column(nullable = false, length = 20) + private NotificationDeliveryStatus status; + + @Column(name = "provider_message_id", length = 255) + private String providerMessageId; + + @Column(name = "failure_reason", columnDefinition = "TEXT") + private String failureReason; + + @Column(name = "attempted_at", nullable = false) + private OffsetDateTime attemptedAt; + + @Column(name = "created_at", nullable = false, updatable = false) + private OffsetDateTime createdAt; + + @Column(name = "updated_at", nullable = false) + private OffsetDateTime updatedAt; + + @Builder + public NotificationDeliveryLog( + Notification notification, + PushDeviceToken pushDeviceToken, + NotificationDeliveryStatus status, + String providerMessageId, + String failureReason) { + this.notification = notification; + this.pushDeviceToken = pushDeviceToken; + this.status = status; + this.providerMessageId = providerMessageId; + this.failureReason = failureReason; + } + + @PrePersist + protected void onCreate() { + OffsetDateTime now = OffsetDateTime.now(); + if (createdAt == null) { + createdAt = now; + } + if (attemptedAt == null) { + attemptedAt = now; + } + updatedAt = now; + } + + @PreUpdate + protected void onUpdate() { + updatedAt = OffsetDateTime.now(); + } +} diff --git a/src/main/java/com/gachi/be/domain/notification/entity/PushDeviceToken.java b/src/main/java/com/gachi/be/domain/notification/entity/PushDeviceToken.java new file mode 100644 index 0000000..b6b722e --- /dev/null +++ b/src/main/java/com/gachi/be/domain/notification/entity/PushDeviceToken.java @@ -0,0 +1,115 @@ +package com.gachi.be.domain.notification.entity; + +import com.gachi.be.domain.notification.entity.enums.PushPlatform; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.EnumType; +import jakarta.persistence.Enumerated; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.PrePersist; +import jakarta.persistence.PreUpdate; +import jakarta.persistence.Table; +import java.time.OffsetDateTime; +import lombok.AccessLevel; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +/** 사용자의 React Native 앱 푸시 토큰을 저장하고 재등록을 흡수하는 엔티티. */ +@Getter +@Entity +@Table(name = "push_device_tokens") +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class PushDeviceToken { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(name = "user_id", nullable = false) + private Long userId; + + @Enumerated(EnumType.STRING) + @Column(nullable = false, length = 20) + private PushPlatform platform; + + @Column(nullable = false, length = 512) + private String token; + + @Column(name = "token_hash", nullable = false, length = 64) + private String tokenHash; + + @Column(name = "device_id", length = 128) + private String deviceId; + + @Column(name = "app_version", length = 50) + private String appVersion; + + @Column(nullable = false) + private boolean enabled; + + @Column(name = "last_registered_at", nullable = false) + private OffsetDateTime lastRegisteredAt; + + @Column(name = "deleted_at") + private OffsetDateTime deletedAt; + + @Column(name = "created_at", nullable = false, updatable = false) + private OffsetDateTime createdAt; + + @Column(name = "updated_at", nullable = false) + private OffsetDateTime updatedAt; + + @Builder + public PushDeviceToken( + Long userId, + PushPlatform platform, + String token, + String tokenHash, + String deviceId, + String appVersion) { + this.userId = userId; + this.platform = platform; + this.token = token; + this.tokenHash = tokenHash; + this.deviceId = deviceId; + this.appVersion = appVersion; + this.enabled = true; + } + + public void refresh( + PushPlatform platform, String token, String tokenHash, String deviceId, String appVersion) { + this.platform = platform; + this.token = token; + this.tokenHash = tokenHash; + this.deviceId = deviceId; + this.appVersion = appVersion; + this.enabled = true; + this.deletedAt = null; + this.lastRegisteredAt = OffsetDateTime.now(); + } + + public void softDelete() { + this.enabled = false; + this.deletedAt = OffsetDateTime.now(); + } + + @PrePersist + protected void onCreate() { + OffsetDateTime now = OffsetDateTime.now(); + if (createdAt == null) { + createdAt = now; + } + if (lastRegisteredAt == null) { + lastRegisteredAt = now; + } + updatedAt = now; + } + + @PreUpdate + protected void onUpdate() { + updatedAt = OffsetDateTime.now(); + } +} diff --git a/src/main/java/com/gachi/be/domain/notification/entity/enums/NotificationDeliveryStatus.java b/src/main/java/com/gachi/be/domain/notification/entity/enums/NotificationDeliveryStatus.java new file mode 100644 index 0000000..85b6c1f --- /dev/null +++ b/src/main/java/com/gachi/be/domain/notification/entity/enums/NotificationDeliveryStatus.java @@ -0,0 +1,9 @@ +package com.gachi.be.domain.notification.entity.enums; + +/** 푸시 발송 시도 결과를 추적하기 위한 상태값. */ +public enum NotificationDeliveryStatus { + PENDING, + SENT, + FAILED, + SKIPPED +} diff --git a/src/main/java/com/gachi/be/domain/notification/entity/enums/NotificationType.java b/src/main/java/com/gachi/be/domain/notification/entity/enums/NotificationType.java new file mode 100644 index 0000000..ec48977 --- /dev/null +++ b/src/main/java/com/gachi/be/domain/notification/entity/enums/NotificationType.java @@ -0,0 +1,10 @@ +package com.gachi.be.domain.notification.entity.enums; + +/** 앱 알림이 어떤 기능에서 만들어졌는지 구분한다. */ +public enum NotificationType { + NEWSLETTER_ANALYSIS, + CALENDAR_EVENT, + CHECKLIST_DUE, + SYSTEM, + ANNOUNCEMENT +} diff --git a/src/main/java/com/gachi/be/domain/notification/entity/enums/PushPlatform.java b/src/main/java/com/gachi/be/domain/notification/entity/enums/PushPlatform.java new file mode 100644 index 0000000..265dbda --- /dev/null +++ b/src/main/java/com/gachi/be/domain/notification/entity/enums/PushPlatform.java @@ -0,0 +1,8 @@ +package com.gachi.be.domain.notification.entity.enums; + +/** React Native 앱에서 등록할 수 있는 푸시 토큰 제공자/플랫폼. */ +public enum PushPlatform { + IOS, + ANDROID, + EXPO +} diff --git a/src/main/java/com/gachi/be/domain/notification/repository/NotificationDeliveryLogRepository.java b/src/main/java/com/gachi/be/domain/notification/repository/NotificationDeliveryLogRepository.java new file mode 100644 index 0000000..f47f985 --- /dev/null +++ b/src/main/java/com/gachi/be/domain/notification/repository/NotificationDeliveryLogRepository.java @@ -0,0 +1,7 @@ +package com.gachi.be.domain.notification.repository; + +import com.gachi.be.domain.notification.entity.NotificationDeliveryLog; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface NotificationDeliveryLogRepository + extends JpaRepository {} diff --git a/src/main/java/com/gachi/be/domain/notification/repository/NotificationRepository.java b/src/main/java/com/gachi/be/domain/notification/repository/NotificationRepository.java new file mode 100644 index 0000000..97fa10f --- /dev/null +++ b/src/main/java/com/gachi/be/domain/notification/repository/NotificationRepository.java @@ -0,0 +1,48 @@ +package com.gachi.be.domain.notification.repository; + +import com.gachi.be.domain.notification.entity.Notification; +import java.time.OffsetDateTime; +import java.util.Collection; +import java.util.List; +import java.util.Optional; +import org.springframework.data.domain.Pageable; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Modifying; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; + +public interface NotificationRepository extends JpaRepository { + + @Query( + """ + SELECT n FROM Notification n + WHERE n.userId = :userId + AND (:cursorId IS NULL OR n.id < :cursorId) + AND (:unreadOnly = false OR n.readAt IS NULL) + ORDER BY n.id DESC + """) + List findInbox( + @Param("userId") Long userId, + @Param("cursorId") Long cursorId, + @Param("unreadOnly") boolean unreadOnly, + Pageable pageable); + + Optional findByIdAndUserId(Long id, Long userId); + + Optional findByUserIdAndDedupeKey(Long userId, String dedupeKey); + + List findAllByUserIdAndIdIn(Long userId, Collection ids); + + long countByUserIdAndReadAtIsNull(Long userId); + + @Modifying(clearAutomatically = true, flushAutomatically = true) + @Query( + """ + UPDATE Notification n + SET n.readAt = :readAt, + n.updatedAt = :readAt + WHERE n.userId = :userId + AND n.readAt IS NULL + """) + int markAllReadByUserId(@Param("userId") Long userId, @Param("readAt") OffsetDateTime readAt); +} diff --git a/src/main/java/com/gachi/be/domain/notification/repository/PushDeviceTokenRepository.java b/src/main/java/com/gachi/be/domain/notification/repository/PushDeviceTokenRepository.java new file mode 100644 index 0000000..29c371b --- /dev/null +++ b/src/main/java/com/gachi/be/domain/notification/repository/PushDeviceTokenRepository.java @@ -0,0 +1,14 @@ +package com.gachi.be.domain.notification.repository; + +import com.gachi.be.domain.notification.entity.PushDeviceToken; +import java.util.List; +import java.util.Optional; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface PushDeviceTokenRepository extends JpaRepository { + Optional findByUserIdAndTokenHash(Long userId, String tokenHash); + + Optional findByIdAndUserId(Long id, Long userId); + + List findAllByUserIdAndEnabledTrueAndDeletedAtIsNull(Long userId); +} diff --git a/src/main/java/com/gachi/be/domain/notification/service/NotificationCreateCommand.java b/src/main/java/com/gachi/be/domain/notification/service/NotificationCreateCommand.java new file mode 100644 index 0000000..bb709d1 --- /dev/null +++ b/src/main/java/com/gachi/be/domain/notification/service/NotificationCreateCommand.java @@ -0,0 +1,12 @@ +package com.gachi.be.domain.notification.service; + +import com.gachi.be.domain.notification.entity.enums.NotificationType; +import java.util.Map; + +/** 다른 도메인에서 사용자 알림을 만들 때 넘기는 최소 입력값. */ +public record NotificationCreateCommand( + NotificationType type, + String title, + String body, + Map payload, + String dedupeKey) {} diff --git a/src/main/java/com/gachi/be/domain/notification/service/NotificationDeliveryResultCommand.java b/src/main/java/com/gachi/be/domain/notification/service/NotificationDeliveryResultCommand.java new file mode 100644 index 0000000..13d53d1 --- /dev/null +++ b/src/main/java/com/gachi/be/domain/notification/service/NotificationDeliveryResultCommand.java @@ -0,0 +1,11 @@ +package com.gachi.be.domain.notification.service; + +import com.gachi.be.domain.notification.entity.enums.NotificationDeliveryStatus; + +/** 외부 푸시 제공자 발송 결과를 저장할 때 사용하는 입력값. */ +public record NotificationDeliveryResultCommand( + Long notificationId, + Long pushDeviceTokenId, + NotificationDeliveryStatus status, + String providerMessageId, + String failureReason) {} diff --git a/src/main/java/com/gachi/be/domain/notification/service/NotificationService.java b/src/main/java/com/gachi/be/domain/notification/service/NotificationService.java new file mode 100644 index 0000000..3ae6e16 --- /dev/null +++ b/src/main/java/com/gachi/be/domain/notification/service/NotificationService.java @@ -0,0 +1,300 @@ +package com.gachi.be.domain.notification.service; + +import com.fasterxml.jackson.core.type.TypeReference; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.gachi.be.domain.notification.dto.request.NotificationReadRequest; +import com.gachi.be.domain.notification.dto.request.PushTokenDeleteRequest; +import com.gachi.be.domain.notification.dto.request.PushTokenRegisterRequest; +import com.gachi.be.domain.notification.dto.response.NotificationListResponse; +import com.gachi.be.domain.notification.dto.response.NotificationReadResponse; +import com.gachi.be.domain.notification.dto.response.NotificationResponse; +import com.gachi.be.domain.notification.dto.response.NotificationUnreadCountResponse; +import com.gachi.be.domain.notification.dto.response.PushTokenResponse; +import com.gachi.be.domain.notification.entity.Notification; +import com.gachi.be.domain.notification.entity.NotificationDeliveryLog; +import com.gachi.be.domain.notification.entity.PushDeviceToken; +import com.gachi.be.domain.notification.entity.enums.NotificationDeliveryStatus; +import com.gachi.be.domain.notification.repository.NotificationDeliveryLogRepository; +import com.gachi.be.domain.notification.repository.NotificationRepository; +import com.gachi.be.domain.notification.repository.PushDeviceTokenRepository; +import com.gachi.be.global.code.ErrorCode; +import com.gachi.be.global.exception.BusinessException; +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.time.OffsetDateTime; +import java.util.Collections; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import lombok.RequiredArgsConstructor; +import org.springframework.dao.DataIntegrityViolationException; +import org.springframework.data.domain.PageRequest; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.util.StringUtils; + +/** 알림 보관함, 읽음 상태, 푸시 토큰 생명주기를 관리한다. */ +@Service +@RequiredArgsConstructor +public class NotificationService { + private static final int DEFAULT_PAGE_SIZE = 20; + private static final int MAX_PAGE_SIZE = 100; + private static final TypeReference> PAYLOAD_TYPE = new TypeReference<>() {}; + + private final NotificationRepository notificationRepository; + private final PushDeviceTokenRepository pushDeviceTokenRepository; + private final NotificationDeliveryLogRepository notificationDeliveryLogRepository; + private final ObjectMapper objectMapper; + + @Transactional(readOnly = true) + public NotificationListResponse getNotifications( + Long userId, Long cursorId, Integer size, boolean unreadOnly) { + int pageSize = normalizePageSize(size); + List rows = + notificationRepository.findInbox( + userId, cursorId, unreadOnly, PageRequest.of(0, pageSize + 1)); + + boolean hasNext = rows.size() > pageSize; + List page = hasNext ? rows.subList(0, pageSize) : rows; + Long nextCursor = hasNext && !page.isEmpty() ? page.get(page.size() - 1).getId() : null; + + return new NotificationListResponse( + page.stream().map(this::toResponse).toList(), nextCursor, hasNext); + } + + @Transactional(readOnly = true) + public NotificationUnreadCountResponse getUnreadCount(Long userId) { + return new NotificationUnreadCountResponse( + notificationRepository.countByUserIdAndReadAtIsNull(userId)); + } + + @Transactional + public NotificationReadResponse markRead(Long userId, Long notificationId) { + Notification notification = + notificationRepository + .findByIdAndUserId(notificationId, userId) + .orElseThrow(() -> new BusinessException(ErrorCode.NOTIFICATION_NOT_FOUND)); + boolean wasUnread = !notification.isRead(); + notification.markRead(); + return new NotificationReadResponse(wasUnread ? 1 : 0); + } + + @Transactional + public NotificationReadResponse markRead(Long userId, NotificationReadRequest request) { + List notifications = + notificationRepository.findAllByUserIdAndIdIn(userId, request.notificationIds()); + int readCount = 0; + for (Notification notification : notifications) { + if (!notification.isRead()) { + notification.markRead(); + readCount++; + } + } + return new NotificationReadResponse(readCount); + } + + @Transactional + public NotificationReadResponse markAllRead(Long userId) { + return new NotificationReadResponse( + notificationRepository.markAllReadByUserId(userId, OffsetDateTime.now())); + } + + @Transactional + public PushTokenResponse registerPushToken(Long userId, PushTokenRegisterRequest request) { + if (request == null || request.platform() == null) { + throw new BusinessException(ErrorCode.INVALID_INPUT_VALUE); + } + String token = requireToken(request.token()); + String tokenHash = sha256Hex(token); + PushDeviceToken tokenEntity = + pushDeviceTokenRepository + .findByUserIdAndTokenHash(userId, tokenHash) + .map( + existing -> { + String deviceId = + preserveExistingIfBlank(request.deviceId(), existing.getDeviceId()); + String appVersion = + preserveExistingIfBlank(request.appVersion(), existing.getAppVersion()); + existing.refresh(request.platform(), token, tokenHash, deviceId, appVersion); + return existing; + }) + .orElseGet( + () -> + PushDeviceToken.builder() + .userId(userId) + .platform(request.platform()) + .token(token) + .tokenHash(tokenHash) + .deviceId(normalizeOptional(request.deviceId())) + .appVersion(normalizeOptional(request.appVersion())) + .build()); + + return toResponse(pushDeviceTokenRepository.save(tokenEntity)); + } + + @Transactional + public void deletePushToken(Long userId, PushTokenDeleteRequest request) { + if (request == null) { + throw new BusinessException(ErrorCode.INVALID_INPUT_VALUE); + } + String tokenHash = sha256Hex(requireToken(request.token())); + pushDeviceTokenRepository + .findByUserIdAndTokenHash(userId, tokenHash) + .ifPresent(PushDeviceToken::softDelete); + } + + @Transactional + public Notification createNotification(Long userId, NotificationCreateCommand command) { + if (command == null + || command.type() == null + || !StringUtils.hasText(command.title()) + || !StringUtils.hasText(command.body())) { + throw new BusinessException(ErrorCode.INVALID_INPUT_VALUE); + } + String dedupeKey = normalizeOptional(command.dedupeKey()); + if (StringUtils.hasText(dedupeKey)) { + var existing = notificationRepository.findByUserIdAndDedupeKey(userId, dedupeKey); + if (existing.isPresent()) { + return existing.get(); + } + } + + Notification notification = + Notification.builder() + .userId(userId) + .type(command.type()) + .title(normalizeRequired(command.title())) + .body(normalizeRequired(command.body())) + .payloadJson(serializePayload(command.payload())) + .dedupeKey(dedupeKey) + .build(); + + try { + return notificationRepository.save(notification); + } catch (DataIntegrityViolationException e) { + // 동시에 같은 알림을 생성해도 사용자에게 중복 노출되지 않도록 DB unique 제약을 한 번 더 신뢰한다. + if (StringUtils.hasText(dedupeKey)) { + return notificationRepository + .findByUserIdAndDedupeKey(userId, dedupeKey) + .orElseThrow(() -> e); + } + throw e; + } + } + + @Transactional + public void recordDeliveryResult(NotificationDeliveryResultCommand command) { + Notification notification = + notificationRepository + .findById(command.notificationId()) + .orElseThrow(() -> new BusinessException(ErrorCode.NOTIFICATION_NOT_FOUND)); + PushDeviceToken pushDeviceToken = null; + if (command.pushDeviceTokenId() != null) { + pushDeviceToken = + pushDeviceTokenRepository.findById(command.pushDeviceTokenId()).orElse(null); + } + + notificationDeliveryLogRepository.save( + NotificationDeliveryLog.builder() + .notification(notification) + .pushDeviceToken(pushDeviceToken) + .status( + command.status() != null ? command.status() : NotificationDeliveryStatus.PENDING) + .providerMessageId(normalizeOptional(command.providerMessageId())) + .failureReason(normalizeOptional(command.failureReason())) + .build()); + } + + private NotificationResponse toResponse(Notification notification) { + return new NotificationResponse( + notification.getId(), + notification.getType(), + notification.getTitle(), + notification.getBody(), + deserializePayload(notification.getPayloadJson()), + notification.isRead(), + notification.getReadAt(), + notification.getCreatedAt()); + } + + private PushTokenResponse toResponse(PushDeviceToken token) { + return new PushTokenResponse( + token.getId(), + token.getPlatform(), + token.getDeviceId(), + token.getAppVersion(), + token.isEnabled(), + token.getLastRegisteredAt()); + } + + private int normalizePageSize(Integer size) { + if (size == null) { + return DEFAULT_PAGE_SIZE; + } + if (size < 1) { + return DEFAULT_PAGE_SIZE; + } + return Math.min(size, MAX_PAGE_SIZE); + } + + private String serializePayload(Map payload) { + if (payload == null || payload.isEmpty()) { + return null; + } + try { + return objectMapper.writeValueAsString(payload); + } catch (Exception e) { + throw new BusinessException(ErrorCode.INVALID_INPUT_VALUE); + } + } + + private Map deserializePayload(String payloadJson) { + if (!StringUtils.hasText(payloadJson)) { + return Collections.emptyMap(); + } + try { + return objectMapper.readValue(payloadJson, PAYLOAD_TYPE); + } catch (Exception e) { + Map fallback = new LinkedHashMap<>(); + fallback.put("raw", payloadJson); + return fallback; + } + } + + private String normalizeRequired(String value) { + return value == null ? "" : value.trim(); + } + + private String normalizeOptional(String value) { + String normalized = normalizeRequired(value); + return StringUtils.hasText(normalized) ? normalized : null; + } + + private String requireToken(String token) { + String normalized = normalizeRequired(token); + if (!StringUtils.hasText(normalized)) { + throw new BusinessException(ErrorCode.INVALID_INPUT_VALUE); + } + return normalized; + } + + private String preserveExistingIfBlank(String requestedValue, String existingValue) { + String normalized = normalizeOptional(requestedValue); + return normalized != null ? normalized : existingValue; + } + + private String sha256Hex(String value) { + try { + MessageDigest digest = MessageDigest.getInstance("SHA-256"); + byte[] hash = digest.digest(value.getBytes(StandardCharsets.UTF_8)); + StringBuilder hex = new StringBuilder(hash.length * 2); + for (byte b : hash) { + hex.append(String.format("%02x", b)); + } + return hex.toString(); + } catch (NoSuchAlgorithmException e) { + throw new IllegalStateException("SHA-256 algorithm is not available", e); + } + } +} diff --git a/src/main/java/com/gachi/be/global/code/ErrorCode.java b/src/main/java/com/gachi/be/global/code/ErrorCode.java index 44445cf..140be78 100644 --- a/src/main/java/com/gachi/be/global/code/ErrorCode.java +++ b/src/main/java/com/gachi/be/global/code/ErrorCode.java @@ -229,6 +229,13 @@ public enum ErrorCode { "언어 코드 유효성 검사 실패", ErrorLogLevel.WARN), + NOTIFICATION_NOT_FOUND( + HttpStatus.NOT_FOUND, + "NOTI4041", + "알림을 찾을 수 없습니다.", + "notificationId에 해당하는 알림이 없거나 소유권이 일치하지 않음", + ErrorLogLevel.INFO), + EXTERNAL_API_ERROR( HttpStatus.BAD_GATEWAY, "EXT5021", diff --git a/src/main/java/com/gachi/be/global/code/SuccessCode.java b/src/main/java/com/gachi/be/global/code/SuccessCode.java index b9e7277..73d999d 100644 --- a/src/main/java/com/gachi/be/global/code/SuccessCode.java +++ b/src/main/java/com/gachi/be/global/code/SuccessCode.java @@ -38,7 +38,12 @@ public enum SuccessCode { CHECKLIST_TODAY_SUCCESS(HttpStatus.OK, "CL2001", "오늘 마감 체크리스트 조회에 성공하였습니다."), CHECKLIST_COMPLETE_SUCCESS(HttpStatus.OK, "CL2002", "체크리스트 완료 처리에 성공하였습니다."), CHECKLIST_DELETED(HttpStatus.OK, "CL2003", "체크리스트 삭제에 성공하였습니다."), - USER_LANGUAGE_UPDATED(HttpStatus.OK, "USER2001", "언어 설정이 변경되었습니다."); + USER_LANGUAGE_UPDATED(HttpStatus.OK, "USER2001", "언어 설정이 변경되었습니다."), + NOTIFICATION_LIST_SUCCESS(HttpStatus.OK, "NOTI2001", "알림 목록 조회에 성공하였습니다."), + NOTIFICATION_UNREAD_COUNT_SUCCESS(HttpStatus.OK, "NOTI2002", "미읽음 알림 수 조회에 성공하였습니다."), + NOTIFICATION_READ_SUCCESS(HttpStatus.OK, "NOTI2003", "알림 읽음 처리에 성공하였습니다."), + NOTIFICATION_PUSH_TOKEN_REGISTERED(HttpStatus.OK, "NOTI2004", "푸시 토큰 등록에 성공하였습니다."), + NOTIFICATION_PUSH_TOKEN_DELETED(HttpStatus.OK, "NOTI2005", "푸시 토큰 삭제에 성공하였습니다."); private final HttpStatus httpStatus; private final String code; diff --git a/src/main/resources/db/migration/V14__notification_api.sql b/src/main/resources/db/migration/V14__notification_api.sql new file mode 100644 index 0000000..5c2b3ee --- /dev/null +++ b/src/main/resources/db/migration/V14__notification_api.sql @@ -0,0 +1,79 @@ +CREATE TABLE IF NOT EXISTS notifications ( + id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, + user_id BIGINT NOT NULL, + type VARCHAR(40) NOT NULL, + title VARCHAR(120) NOT NULL, + body TEXT NOT NULL, + payload_json TEXT NULL, + dedupe_key VARCHAR(255) NULL, + read_at TIMESTAMPTZ NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, + CONSTRAINT fk_notifications_user + FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE, + CONSTRAINT chk_notifications_type + CHECK (type IN ('NEWSLETTER_ANALYSIS','CALENDAR_EVENT','CHECKLIST_DUE','SYSTEM','ANNOUNCEMENT')) +); + +CREATE INDEX IF NOT EXISTS idx_notifications_user_id_id + ON notifications (user_id, id DESC); + +CREATE INDEX IF NOT EXISTS idx_notifications_user_unread + ON notifications (user_id, id DESC) + WHERE read_at IS NULL; + +CREATE UNIQUE INDEX IF NOT EXISTS uq_notifications_user_dedupe_key + ON notifications (user_id, dedupe_key) + WHERE dedupe_key IS NOT NULL; + +CREATE TABLE IF NOT EXISTS push_device_tokens ( + id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, + user_id BIGINT NOT NULL, + platform VARCHAR(20) NOT NULL, + token VARCHAR(512) NOT NULL, + token_hash VARCHAR(64) NOT NULL, + device_id VARCHAR(128) NULL, + app_version VARCHAR(50) NULL, + enabled BOOLEAN NOT NULL DEFAULT TRUE, + last_registered_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, + deleted_at TIMESTAMPTZ NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, + CONSTRAINT fk_push_device_tokens_user + FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE, + CONSTRAINT chk_push_device_tokens_platform + CHECK (platform IN ('IOS','ANDROID','EXPO')), + CONSTRAINT uk_push_device_tokens_user_token_hash + UNIQUE (user_id, token_hash) +); + +CREATE INDEX IF NOT EXISTS idx_push_device_tokens_user_active + ON push_device_tokens (user_id, enabled, deleted_at); + +CREATE INDEX IF NOT EXISTS idx_push_device_tokens_device_id + ON push_device_tokens (user_id, device_id) + WHERE device_id IS NOT NULL; + +CREATE TABLE IF NOT EXISTS notification_delivery_logs ( + id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, + notification_id BIGINT NOT NULL, + push_device_token_id BIGINT NULL, + status VARCHAR(20) NOT NULL, + provider_message_id VARCHAR(255) NULL, + failure_reason TEXT NULL, + attempted_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, + created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, + CONSTRAINT fk_notification_delivery_logs_notification + FOREIGN KEY (notification_id) REFERENCES notifications (id) ON DELETE CASCADE, + CONSTRAINT fk_notification_delivery_logs_push_device_token + FOREIGN KEY (push_device_token_id) REFERENCES push_device_tokens (id) ON DELETE SET NULL, + CONSTRAINT chk_notification_delivery_logs_status + CHECK (status IN ('PENDING','SENT','FAILED','SKIPPED')) +); + +CREATE INDEX IF NOT EXISTS idx_notification_delivery_logs_notification + ON notification_delivery_logs (notification_id, attempted_at DESC); + +CREATE INDEX IF NOT EXISTS idx_notification_delivery_logs_status + ON notification_delivery_logs (status, attempted_at DESC); diff --git a/src/test/java/com/gachi/be/domain/notification/api/controller/NotificationControllerIntegrationTest.java b/src/test/java/com/gachi/be/domain/notification/api/controller/NotificationControllerIntegrationTest.java new file mode 100644 index 0000000..5b7c472 --- /dev/null +++ b/src/test/java/com/gachi/be/domain/notification/api/controller/NotificationControllerIntegrationTest.java @@ -0,0 +1,305 @@ +package com.gachi.be.domain.notification.api.controller; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.springframework.security.test.web.servlet.setup.SecurityMockMvcConfigurers.springSecurity; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.delete; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.patch; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.gachi.be.domain.auth.service.JwtTokenProvider; +import com.gachi.be.domain.notification.entity.Notification; +import com.gachi.be.domain.notification.entity.enums.NotificationType; +import com.gachi.be.domain.notification.repository.NotificationRepository; +import com.gachi.be.domain.notification.repository.PushDeviceTokenRepository; +import com.gachi.be.domain.notification.service.NotificationCreateCommand; +import com.gachi.be.domain.notification.service.NotificationService; +import com.gachi.be.domain.user.entity.User; +import com.gachi.be.domain.user.entity.enums.UserStatus; +import com.gachi.be.domain.user.repository.UserRepository; +import java.time.OffsetDateTime; +import java.util.List; +import java.util.Map; +import java.util.concurrent.atomic.AtomicInteger; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.http.MediaType; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.MvcResult; +import org.springframework.test.web.servlet.setup.MockMvcBuilders; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.web.context.WebApplicationContext; + +@SpringBootTest +@ActiveProfiles("test") +@Transactional +class NotificationControllerIntegrationTest { + private static final AtomicInteger PHONE_SEQUENCE = new AtomicInteger(7000); + + private final ObjectMapper objectMapper = new ObjectMapper(); + private MockMvc mockMvc; + + @Autowired private WebApplicationContext webApplicationContext; + @Autowired private UserRepository userRepository; + @Autowired private JwtTokenProvider jwtTokenProvider; + @Autowired private NotificationService notificationService; + @Autowired private NotificationRepository notificationRepository; + @Autowired private PushDeviceTokenRepository pushDeviceTokenRepository; + + @BeforeEach + void setUp() { + mockMvc = + MockMvcBuilders.webAppContextSetup(webApplicationContext).apply(springSecurity()).build(); + } + + @Test + void notificationInboxSupportsCursorUnreadCountAndReadState() throws Exception { + User user = createActiveUser("notification_parent"); + String token = issueBearerToken(user); + + Notification first = + notificationService.createNotification( + user.getId(), + new NotificationCreateCommand( + NotificationType.NEWSLETTER_ANALYSIS, + "analysis complete", + "first body", + Map.of("newsletterId", 10L), + "newsletter:10:completed")); + Notification duplicated = + notificationService.createNotification( + user.getId(), + new NotificationCreateCommand( + NotificationType.NEWSLETTER_ANALYSIS, + "analysis complete again", + "duplicated body", + Map.of("newsletterId", 10L), + "newsletter:10:completed")); + Notification second = + notificationService.createNotification( + user.getId(), + new NotificationCreateCommand( + NotificationType.CALENDAR_EVENT, + "calendar registered", + "second body", + Map.of("calendarEventId", 20L), + "calendar:20:registered")); + + assertThat(duplicated.getId()).isEqualTo(first.getId()); + assertThat(notificationRepository.countByUserIdAndReadAtIsNull(user.getId())).isEqualTo(2); + + MvcResult firstPage = + mockMvc + .perform(get("/api/v1/notifications").header("Authorization", token).param("size", "1")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value("NOTI2001")) + .andExpect(jsonPath("$.result.notifications.length()").value(1)) + .andExpect(jsonPath("$.result.notifications[0].id").value(second.getId())) + .andExpect(jsonPath("$.result.notifications[0].payload.calendarEventId").value(20)) + .andExpect(jsonPath("$.result.hasNext").value(true)) + .andReturn(); + + Long nextCursor = readBody(firstPage).path("result").path("nextCursor").asLong(); + + mockMvc + .perform( + get("/api/v1/notifications") + .header("Authorization", token) + .param("cursor", String.valueOf(nextCursor)) + .param("size", "10")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.result.notifications.length()").value(1)) + .andExpect(jsonPath("$.result.notifications[0].id").value(first.getId())) + .andExpect(jsonPath("$.result.hasNext").value(false)); + + mockMvc + .perform(get("/api/v1/notifications/unread-count").header("Authorization", token)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value("NOTI2002")) + .andExpect(jsonPath("$.result.unreadCount").value(2)); + + mockMvc + .perform( + patch("/api/v1/notifications/{notificationId}/read", second.getId()) + .header("Authorization", token)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.result.readCount").value(1)); + + mockMvc + .perform( + patch("/api/v1/notifications/read") + .header("Authorization", token) + .contentType(MediaType.APPLICATION_JSON) + .content( + objectMapper.writeValueAsString( + Map.of("notificationIds", List.of(first.getId()))))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.result.readCount").value(1)); + + mockMvc + .perform(get("/api/v1/notifications/unread-count").header("Authorization", token)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.result.unreadCount").value(0)); + } + + @Test + void pushTokenCanBeRegisteredDeletedAndReRegistered() throws Exception { + User user = createActiveUser("push_token_parent"); + String bearerToken = issueBearerToken(user); + Map registerBody = + Map.of( + "platform", "EXPO", + "token", "ExpoPushToken[test-token]", + "deviceId", "device-1", + "appVersion", "1.0.0"); + + MvcResult created = + mockMvc + .perform( + post("/api/v1/notifications/tokens") + .header("Authorization", bearerToken) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(registerBody))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value("NOTI2004")) + .andExpect(jsonPath("$.result.platform").value("EXPO")) + .andExpect(jsonPath("$.result.enabled").value(true)) + .andReturn(); + + long tokenId = readBody(created).path("result").path("id").asLong(); + + mockMvc + .perform( + delete("/api/v1/notifications/tokens") + .header("Authorization", bearerToken) + .contentType(MediaType.APPLICATION_JSON) + .content( + objectMapper.writeValueAsString(Map.of("token", "ExpoPushToken[test-token]")))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value("NOTI2005")); + + assertThat(pushDeviceTokenRepository.findByIdAndUserId(tokenId, user.getId())) + .get() + .extracting("enabled") + .isEqualTo(false); + + mockMvc + .perform( + post("/api/v1/notifications/tokens") + .header("Authorization", bearerToken) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(registerBody))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.result.id").value(tokenId)) + .andExpect(jsonPath("$.result.enabled").value(true)) + .andExpect(jsonPath("$.result.deviceId").value("device-1")); + } + + @Test + void pushTokenReRegistrationKeepsExistingDeviceIdWhenDeviceIdIsMissing() throws Exception { + User user = createActiveUser("push_token_keep_device"); + String bearerToken = issueBearerToken(user); + + mockMvc + .perform( + post("/api/v1/notifications/tokens") + .header("Authorization", bearerToken) + .contentType(MediaType.APPLICATION_JSON) + .content( + objectMapper.writeValueAsString( + Map.of( + "platform", + "EXPO", + "token", + "ExpoPushToken[keep-device]", + "deviceId", + "device-keep", + "appVersion", + "1.0.0")))) + .andExpect(status().isOk()); + + mockMvc + .perform( + post("/api/v1/notifications/tokens") + .header("Authorization", bearerToken) + .contentType(MediaType.APPLICATION_JSON) + .content( + objectMapper.writeValueAsString( + Map.of( + "platform", + "EXPO", + "token", + "ExpoPushToken[keep-device]", + "appVersion", + "1.0.1")))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.result.deviceId").value("device-keep")) + .andExpect(jsonPath("$.result.appVersion").value("1.0.1")); + } + + @Test + void pushTokenRegistrationRejectsBlankToken() throws Exception { + User user = createActiveUser("push_token_blank"); + String bearerToken = issueBearerToken(user); + + mockMvc + .perform( + post("/api/v1/notifications/tokens") + .header("Authorization", bearerToken) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(Map.of("platform", "EXPO", "token", " ")))) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.code").value("COMMON4001")); + } + + @Test + void readingOtherUsersNotificationReturnsNotFound() throws Exception { + User owner = createActiveUser("notification_owner"); + User other = createActiveUser("notification_other"); + String otherToken = issueBearerToken(other); + Notification notification = + notificationService.createNotification( + owner.getId(), + new NotificationCreateCommand( + NotificationType.SYSTEM, "system", "body", Map.of(), "system:owner")); + + mockMvc + .perform( + patch("/api/v1/notifications/{notificationId}/read", notification.getId()) + .header("Authorization", otherToken)) + .andExpect(status().isNotFound()) + .andExpect(jsonPath("$.code").value("NOTI4041")); + } + + private JsonNode readBody(MvcResult result) throws Exception { + return objectMapper.readTree(result.getResponse().getContentAsString()); + } + + private String issueBearerToken(User user) { + return "Bearer " + jwtTokenProvider.issueAccessToken(user).getToken(); + } + + private User createActiveUser(String postfix) { + OffsetDateTime now = OffsetDateTime.now(); + return userRepository.saveAndFlush( + User.builder() + .name("parent-" + postfix) + .email(postfix + "@gachi.com") + .loginId("login_" + postfix) + .passwordHash("encoded-password") + .phoneNumber("0107777" + String.format("%04d", PHONE_SEQUENCE.getAndIncrement())) + .status(UserStatus.ACTIVE) + .emailVerifiedAt(now) + .consentAgreedAt(now) + .consentVersion("2026-04-v1") + .passwordUpdatedAt(now) + .build()); + } +}