From faeb201e1a935e426171f83abdb9e8c72948acdc Mon Sep 17 00:00:00 2001 From: minju Date: Wed, 27 May 2026 12:09:25 +0900 Subject: [PATCH 1/4] =?UTF-8?q?feat:=20=EC=95=8C=EB=A6=BC=20=EB=8B=A8?= =?UTF-8?q?=EA=B3=84=20=EC=84=A4=EC=A0=95=EA=B3=BC=20=EC=9E=90=EB=8F=99=20?= =?UTF-8?q?=EC=95=8C=EB=A6=BC=20=EB=B0=9C=EC=86=A1=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../java/com/gachi/be/GachiBeApplication.java | 2 + .../auth/dto/request/SignupRequest.java | 4 +- .../auth/service/impl/AuthServiceImpl.java | 1 + .../repository/CalendarEventRepository.java | 3 + .../repository/ChecklistRepository.java | 16 ++ .../child/repository/ChildRepository.java | 4 +- .../pipeline/NewsletterPipelineService.java | 43 +++- .../repository/NewsletterRepository.java | 3 + .../controller/NotificationController.java | 30 +-- .../dto/response/NotificationResponse.java | 4 + .../notification/entity/Notification.java | 20 ++ .../entity/enums/NotificationLevel.java | 8 + .../entity/enums/NotificationType.java | 2 + .../repository/NotificationRepository.java | 3 + .../service/NotificationCreateCommand.java | 18 +- .../service/NotificationPushDispatcher.java | 4 +- .../service/NotificationScheduler.java | 229 ++++++++++++++++++ .../service/NotificationService.java | 29 ++- .../user/api/controller/UserController.java | 14 ++ .../request/ChangeNotificationRequest.java | 6 +- .../user/dto/response/UserMeResponse.java | 2 + .../com/gachi/be/domain/user/entity/User.java | 36 ++- .../entity/enums/NotificationPreference.java | 36 +++ .../user/repository/UserRepository.java | 3 + .../com/gachi/be/global/code/SuccessCode.java | 1 + ...6__notification_preference_and_context.sql | 63 +++++ 26 files changed, 550 insertions(+), 34 deletions(-) create mode 100644 src/main/java/com/gachi/be/domain/notification/entity/enums/NotificationLevel.java create mode 100644 src/main/java/com/gachi/be/domain/notification/service/NotificationScheduler.java create mode 100644 src/main/java/com/gachi/be/domain/user/entity/enums/NotificationPreference.java create mode 100644 src/main/resources/db/migration/V16__notification_preference_and_context.sql diff --git a/src/main/java/com/gachi/be/GachiBeApplication.java b/src/main/java/com/gachi/be/GachiBeApplication.java index 4b28892..ae853a2 100644 --- a/src/main/java/com/gachi/be/GachiBeApplication.java +++ b/src/main/java/com/gachi/be/GachiBeApplication.java @@ -3,8 +3,10 @@ import org.springframework.boot.SpringApplication; import org.springframework.boot.autoconfigure.SpringBootApplication; import org.springframework.scheduling.annotation.EnableAsync; +import org.springframework.scheduling.annotation.EnableScheduling; @EnableAsync +@EnableScheduling @SpringBootApplication public class GachiBeApplication { diff --git a/src/main/java/com/gachi/be/domain/auth/dto/request/SignupRequest.java b/src/main/java/com/gachi/be/domain/auth/dto/request/SignupRequest.java index 31de87c..44d093f 100644 --- a/src/main/java/com/gachi/be/domain/auth/dto/request/SignupRequest.java +++ b/src/main/java/com/gachi/be/domain/auth/dto/request/SignupRequest.java @@ -1,5 +1,6 @@ package com.gachi.be.domain.auth.dto.request; +import com.gachi.be.domain.user.entity.enums.NotificationPreference; import jakarta.validation.constraints.Email; import jakarta.validation.constraints.NotBlank; import jakarta.validation.constraints.NotNull; @@ -22,4 +23,5 @@ public record SignupRequest( @Pattern( regexp = "^(KO|US|ZH|VI)$", message = "지원하지 않는 언어 코드입니다. KO, US, ZH, VI 중 하나여야 합니다.") - String languageCode) {} + String languageCode, + NotificationPreference notificationPreference) {} diff --git a/src/main/java/com/gachi/be/domain/auth/service/impl/AuthServiceImpl.java b/src/main/java/com/gachi/be/domain/auth/service/impl/AuthServiceImpl.java index 21c9b9b..9e9ac54 100644 --- a/src/main/java/com/gachi/be/domain/auth/service/impl/AuthServiceImpl.java +++ b/src/main/java/com/gachi/be/domain/auth/service/impl/AuthServiceImpl.java @@ -139,6 +139,7 @@ public SignupResponse signup(SignupRequest request) { .phoneNumber(phoneNumber) .status(UserStatus.ACTIVE) .languageCode(request.languageCode()) + .notificationPreference(request.notificationPreference()) .emailVerifiedAt(now) .consentAgreedAt(now) .consentVersion(authProperties.getConsentVersion()) diff --git a/src/main/java/com/gachi/be/domain/calendar/repository/CalendarEventRepository.java b/src/main/java/com/gachi/be/domain/calendar/repository/CalendarEventRepository.java index 2b5a0f5..6191140 100644 --- a/src/main/java/com/gachi/be/domain/calendar/repository/CalendarEventRepository.java +++ b/src/main/java/com/gachi/be/domain/calendar/repository/CalendarEventRepository.java @@ -54,6 +54,9 @@ List findEventsInRange( /** 소유권 검증 포함 단건 조회. */ Optional findByIdAndUserId(Long id, Long userId); + List findByStartAtGreaterThanEqualAndStartAtLessThan( + OffsetDateTime rangeStart, OffsetDateTime rangeEnd); + /** 특정 가정통신문의 모든 일정 삭제. */ void deleteByNewsletterIdAndUserId(Long newsletterId, Long userId); diff --git a/src/main/java/com/gachi/be/domain/checklist/repository/ChecklistRepository.java b/src/main/java/com/gachi/be/domain/checklist/repository/ChecklistRepository.java index 9ec4232..0548092 100644 --- a/src/main/java/com/gachi/be/domain/checklist/repository/ChecklistRepository.java +++ b/src/main/java/com/gachi/be/domain/checklist/repository/ChecklistRepository.java @@ -2,6 +2,7 @@ import com.gachi.be.domain.checklist.entity.Checklist; import com.gachi.be.domain.checklist.entity.enums.ChecklistType; +import java.time.LocalDate; import java.util.List; import java.util.Optional; import org.springframework.data.jpa.repository.JpaRepository; @@ -25,6 +26,11 @@ public interface ChecklistRepository extends JpaRepository { /** 특정 사용자의 미완료 CHECKLIST 항목 전체 조회. */ List findByUserIdAndTypeAndCompletedFalse(Long userId, ChecklistType type); + long countByUserIdAndCompletedFalse(Long userId); + + List findByTypeAndCompletedFalseAndTargetDate( + ChecklistType type, LocalDate targetDate); + /** 특정 캘린더 일정에 연결된 CHECKLIST 타입 항목 조회. */ List findByCalendarEventIdAndTypeOrderByIdAsc( Long calendarEventId, ChecklistType type); @@ -47,4 +53,14 @@ List findIncompleteChecklistsByCalendarEventIds( /** e ventId IN 절로 한 번에 조회하여 서비스에서 Map으로 그룹핑 */ List findByCalendarEventIdInAndType(List calendarEventIds, ChecklistType type); + + @Query( + """ + SELECT c FROM Checklist c + WHERE c.calendarEventId IN :calendarEventIds + AND c.type = com.gachi.be.domain.checklist.entity.enums.ChecklistType.CHECKLIST + AND c.completed = false + """) + List findIncompleteChecklistItemsByCalendarEventIds( + @Param("calendarEventIds") List calendarEventIds); } diff --git a/src/main/java/com/gachi/be/domain/child/repository/ChildRepository.java b/src/main/java/com/gachi/be/domain/child/repository/ChildRepository.java index 9332687..10c4d86 100644 --- a/src/main/java/com/gachi/be/domain/child/repository/ChildRepository.java +++ b/src/main/java/com/gachi/be/domain/child/repository/ChildRepository.java @@ -10,9 +10,9 @@ public interface ChildRepository extends JpaRepository { long countByUserId(Long userId); - /** 특정 사용자의 활성 자녀 목록 조회 */ List findByUserIdAndDeletedAtIsNull(Long userId); - /** 특정 사용자의 특정 자녀 조회 */ Optional findByIdAndUserIdAndDeletedAtIsNull(Long id, Long userId); + + Optional findFirstByUserIdAndNameAndDeletedAtIsNull(Long userId, String name); } diff --git a/src/main/java/com/gachi/be/domain/newsletter/pipeline/NewsletterPipelineService.java b/src/main/java/com/gachi/be/domain/newsletter/pipeline/NewsletterPipelineService.java index 21e7c2a..9597c41 100644 --- a/src/main/java/com/gachi/be/domain/newsletter/pipeline/NewsletterPipelineService.java +++ b/src/main/java/com/gachi/be/domain/newsletter/pipeline/NewsletterPipelineService.java @@ -1,12 +1,18 @@ package com.gachi.be.domain.newsletter.pipeline; +import com.gachi.be.domain.child.repository.ChildRepository; import com.gachi.be.domain.newsletter.entity.Newsletter; import com.gachi.be.domain.newsletter.pipeline.ClovaOcrClient.OcrField; import com.gachi.be.domain.newsletter.pipeline.NewsletterAiAnalyzer.AiAnalysisResult; import com.gachi.be.domain.newsletter.repository.NewsletterRepository; +import com.gachi.be.domain.notification.entity.enums.NotificationLevel; +import com.gachi.be.domain.notification.entity.enums.NotificationType; +import com.gachi.be.domain.notification.service.NotificationCreateCommand; +import com.gachi.be.domain.notification.service.NotificationService; import com.gachi.be.file.config.S3Properties; import com.gachi.be.global.exception.ExternalApiException; import java.util.List; +import java.util.Map; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.scheduling.annotation.Async; @@ -34,6 +40,8 @@ public class NewsletterPipelineService { private final PapagoTranslateClient papagoTranslateClient; private final NewsletterAiAnalyzer newsletterAiAnalyzer; private final NewsletterDateCandidateService newsletterDateCandidateService; + private final NotificationService notificationService; + private final ChildRepository childRepository; @Async @Transactional @@ -179,7 +187,8 @@ public void markCompleted( .ifPresent( n -> { n.complete(ocrText, originalText, translatedText, title, summary); - newsletterRepository.save(n); + Newsletter saved = newsletterRepository.save(n); + createAnalysisCompletedNotification(saved); }); } @@ -209,6 +218,38 @@ private String failureReason(Exception e) { return e.getClass().getSimpleName() + ": " + message; } + private void createAnalysisCompletedNotification(Newsletter newsletter) { + Long childId = resolveChildId(newsletter); + notificationService.createNotification( + newsletter.getUserId(), + new NotificationCreateCommand( + NotificationType.NEWSLETTER_ANALYSIS, + "새 가정통신문 분석 완료", + newsletter.getTitle() != null && !newsletter.getTitle().isBlank() + ? newsletter.getTitle() + " 분석이 완료되었어요" + : "가정통신문 분석이 완료되었어요", + Map.of( + "newsletterId", + newsletter.getId(), + "childName", + newsletter.getChildName() != null ? newsletter.getChildName() : ""), + "newsletter-analysis:" + newsletter.getId(), + NotificationLevel.IMPORTANT, + childId, + newsletter.getChildName())); + } + + private Long resolveChildId(Newsletter newsletter) { + if (newsletter.getChildName() == null || newsletter.getChildName().isBlank()) { + return null; + } + return childRepository + .findFirstByUserIdAndNameAndDeletedAtIsNull( + newsletter.getUserId(), newsletter.getChildName()) + .map(child -> child.getId()) + .orElse(null); + } + private byte[] downloadFromS3(String fileKey) { GetObjectRequest request = GetObjectRequest.builder().bucket(s3Properties.getBucket()).key(fileKey).build(); diff --git a/src/main/java/com/gachi/be/domain/newsletter/repository/NewsletterRepository.java b/src/main/java/com/gachi/be/domain/newsletter/repository/NewsletterRepository.java index 8cc81ba..7ec5081 100644 --- a/src/main/java/com/gachi/be/domain/newsletter/repository/NewsletterRepository.java +++ b/src/main/java/com/gachi/be/domain/newsletter/repository/NewsletterRepository.java @@ -97,6 +97,9 @@ List findRecentByUserId( @Param("rangeStart") OffsetDateTime rangeStart, @Param("rangeEnd") OffsetDateTime rangeEnd); + long countByUserIdAndCreatedAtGreaterThanEqualAndCreatedAtLessThan( + Long userId, OffsetDateTime rangeStart, OffsetDateTime rangeEnd); + /** 언어 변경 시 진행중인 파이프라인 중단 처리용 쿼리 */ @Modifying @Query( 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 index 177b022..0799eb0 100644 --- 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 @@ -40,14 +40,9 @@ public class NotificationController { private final NotificationService notificationService; - /** 푸시 수신 누락을 복구하기 위해 서버에 저장된 알림 보관함을 최신순으로 조회한다. */ @Operation( summary = "알림 목록 조회", - description = - """ - React Native 앱이 푸시를 받지 못한 경우에도 이 API로 서버 보관함을 동기화할 수 있습니다. - cursor는 이전 응답의 nextCursor를 그대로 전달하고, unreadOnly=true면 미읽음 알림만 조회합니다. - """) + description = "서버에 저장된 알림을 최신순으로 조회합니다. childId를 전달하면 특정 아이의 알림만 조회합니다.") @GetMapping public ApiResponse getNotifications( @AuthenticationPrincipal Long userId, @@ -59,10 +54,13 @@ public ApiResponse getNotifications( @Max(100) Integer size, @Parameter(description = "미읽음 알림만 조회할지 여부") @RequestParam(defaultValue = "false") - boolean unreadOnly) { + boolean unreadOnly, + @Parameter(description = "아이별 알림 조회용 childId. 생략하면 전체 알림을 조회합니다.") + @RequestParam(required = false) + Long childId) { return ApiResponse.success( SuccessCode.NOTIFICATION_LIST_SUCCESS, - notificationService.getNotifications(userId, cursor, size, unreadOnly)); + notificationService.getNotifications(userId, cursor, size, unreadOnly, childId)); } @Operation(summary = "미읽음 알림 수 조회", description = "사용자 기준 미읽음 알림 개수를 반환합니다.") @@ -73,7 +71,7 @@ public ApiResponse getUnreadCount( SuccessCode.NOTIFICATION_UNREAD_COUNT_SUCCESS, notificationService.getUnreadCount(userId)); } - @Operation(summary = "단건 읽음 처리", description = "알림 상세 진입 또는 알림 탭 노출 후 단건 읽음 처리에 사용합니다.") + @Operation(summary = "단건 읽음 처리", description = "알림 클릭 후 관련 페이지로 이동할 때 호출합니다.") @PatchMapping("/{notificationId}/read") public ApiResponse markRead( @AuthenticationPrincipal Long userId, @PathVariable Long notificationId) { @@ -82,7 +80,7 @@ public ApiResponse markRead( notificationService.markRead(userId, notificationId)); } - @Operation(summary = "일괄 읽음 처리", description = "앱이 서버 보관함을 동기화한 뒤 여러 알림을 한 번에 읽음 처리합니다.") + @Operation(summary = "일괄 읽음 처리", description = "여러 알림을 한 번에 읽음 처리합니다.") @PatchMapping("/read") public ApiResponse markRead( @AuthenticationPrincipal Long userId, @Valid @RequestBody NotificationReadRequest request) { @@ -97,13 +95,7 @@ public ApiResponse markAllRead(@AuthenticationPrincipa SuccessCode.NOTIFICATION_READ_SUCCESS, notificationService.markAllRead(userId)); } - @Operation( - summary = "푸시 토큰 등록/갱신", - description = - """ - RN 앱 시작, 로그인 직후, 토큰 refresh 이벤트에서 호출합니다. - 같은 토큰이 재등록되면 기존 레코드를 활성화하고 플랫폼/디바이스 정보를 갱신합니다. - """) + @Operation(summary = "푸시 토큰 등록/갱신", description = "RN 앱 시작, 로그인 직후, 토큰 refresh 이벤트에서 호출합니다.") @PostMapping("/tokens") public ApiResponse registerPushToken( @AuthenticationPrincipal Long userId, @Valid @RequestBody PushTokenRegisterRequest request) { @@ -112,9 +104,7 @@ public ApiResponse registerPushToken( notificationService.registerPushToken(userId, request)); } - @Operation( - summary = "푸시 토큰 삭제", - description = "로그아웃, 권한 철회, 앱 삭제 전 토큰 정리 시 호출합니다. 이미 삭제된 토큰도 성공으로 처리합니다.") + @Operation(summary = "푸시 토큰 삭제", description = "로그아웃, 권한 철회, 탈퇴 시 토큰 정리에 사용합니다.") @DeleteMapping("/tokens") public ApiResponse deletePushToken( @AuthenticationPrincipal Long userId, @Valid @RequestBody PushTokenDeleteRequest request) { 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 index 1e3e7ed..eac5283 100644 --- 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 @@ -1,5 +1,6 @@ package com.gachi.be.domain.notification.dto.response; +import com.gachi.be.domain.notification.entity.enums.NotificationLevel; import com.gachi.be.domain.notification.entity.enums.NotificationType; import java.time.OffsetDateTime; import java.util.Map; @@ -7,6 +8,9 @@ public record NotificationResponse( Long id, NotificationType type, + NotificationLevel level, + Long childId, + String childName, String title, String body, Map payload, 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 index d1c38b7..33cb7e9 100644 --- a/src/main/java/com/gachi/be/domain/notification/entity/Notification.java +++ b/src/main/java/com/gachi/be/domain/notification/entity/Notification.java @@ -1,5 +1,6 @@ package com.gachi.be.domain.notification.entity; +import com.gachi.be.domain.notification.entity.enums.NotificationLevel; import com.gachi.be.domain.notification.entity.enums.NotificationType; import jakarta.persistence.Column; import jakarta.persistence.Entity; @@ -35,6 +36,16 @@ public class Notification { @Column(nullable = false, length = 40) private NotificationType type; + @Enumerated(EnumType.STRING) + @Column(nullable = false, length = 20) + private NotificationLevel level; + + @Column(name = "child_id") + private Long childId; + + @Column(name = "child_name", length = 50) + private String childName; + @Column(nullable = false, length = 120) private String title; @@ -60,12 +71,18 @@ public class Notification { public Notification( Long userId, NotificationType type, + NotificationLevel level, + Long childId, + String childName, String title, String body, String payloadJson, String dedupeKey) { this.userId = userId; this.type = type; + this.level = level != null ? level : NotificationLevel.IMPORTANT; + this.childId = childId; + this.childName = childName; this.title = title; this.body = body; this.payloadJson = payloadJson; @@ -88,6 +105,9 @@ protected void onCreate() { if (createdAt == null) { createdAt = now; } + if (level == null) { + level = NotificationLevel.IMPORTANT; + } updatedAt = now; } diff --git a/src/main/java/com/gachi/be/domain/notification/entity/enums/NotificationLevel.java b/src/main/java/com/gachi/be/domain/notification/entity/enums/NotificationLevel.java new file mode 100644 index 0000000..151878f --- /dev/null +++ b/src/main/java/com/gachi/be/domain/notification/entity/enums/NotificationLevel.java @@ -0,0 +1,8 @@ +package com.gachi.be.domain.notification.entity.enums; + +/** 사용자 알림 설정 단계에서 외부 푸시 발송 여부를 판단할 때 사용하는 중요도. */ +public enum NotificationLevel { + URGENT, + IMPORTANT, + NORMAL +} 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 index ec48977..f58e0b9 100644 --- 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 @@ -3,8 +3,10 @@ /** 앱 알림이 어떤 기능에서 만들어졌는지 구분한다. */ public enum NotificationType { NEWSLETTER_ANALYSIS, + DEADLINE_REMINDER, CALENDAR_EVENT, CHECKLIST_DUE, + WEEKLY_SUMMARY, SYSTEM, ANNOUNCEMENT } 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 index 97fa10f..9a72086 100644 --- a/src/main/java/com/gachi/be/domain/notification/repository/NotificationRepository.java +++ b/src/main/java/com/gachi/be/domain/notification/repository/NotificationRepository.java @@ -19,11 +19,14 @@ public interface NotificationRepository extends JpaRepository findInbox( @Param("userId") Long userId, @Param("cursorId") Long cursorId, + @Param("childId") Long childId, + @Param("childName") String childName, @Param("unreadOnly") boolean unreadOnly, Pageable pageable); 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 index bb709d1..6dbd218 100644 --- a/src/main/java/com/gachi/be/domain/notification/service/NotificationCreateCommand.java +++ b/src/main/java/com/gachi/be/domain/notification/service/NotificationCreateCommand.java @@ -1,12 +1,26 @@ package com.gachi.be.domain.notification.service; +import com.gachi.be.domain.notification.entity.enums.NotificationLevel; 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) {} + String dedupeKey, + NotificationLevel level, + Long childId, + String childName) { + + public NotificationCreateCommand( + NotificationType type, + String title, + String body, + Map payload, + String dedupeKey) { + this(type, title, body, payload, dedupeKey, NotificationLevel.IMPORTANT, null, null); + } +} diff --git a/src/main/java/com/gachi/be/domain/notification/service/NotificationPushDispatcher.java b/src/main/java/com/gachi/be/domain/notification/service/NotificationPushDispatcher.java index 163401e..ee741f2 100644 --- a/src/main/java/com/gachi/be/domain/notification/service/NotificationPushDispatcher.java +++ b/src/main/java/com/gachi/be/domain/notification/service/NotificationPushDispatcher.java @@ -67,8 +67,8 @@ public void dispatch(NotificationCreatedEvent event) { saveSkipped(notification, null, "사용자를 찾을 수 없어 푸시 발송을 건너뜁니다."); return; } - if (!user.isNotificationEnabled()) { - saveSkipped(notification, null, "사용자 알림 수신 설정이 꺼져 있습니다."); + if (!user.getNotificationPreference().allows(notification.getLevel())) { + saveSkipped(notification, null, "사용자 알림 단계에서 제외된 알림입니다."); return; } if (!properties.isEnabled()) { diff --git a/src/main/java/com/gachi/be/domain/notification/service/NotificationScheduler.java b/src/main/java/com/gachi/be/domain/notification/service/NotificationScheduler.java new file mode 100644 index 0000000..f75e00a --- /dev/null +++ b/src/main/java/com/gachi/be/domain/notification/service/NotificationScheduler.java @@ -0,0 +1,229 @@ +package com.gachi.be.domain.notification.service; + +import com.gachi.be.domain.calendar.entity.CalendarEvent; +import com.gachi.be.domain.calendar.repository.CalendarEventRepository; +import com.gachi.be.domain.checklist.entity.Checklist; +import com.gachi.be.domain.checklist.entity.enums.ChecklistType; +import com.gachi.be.domain.checklist.repository.ChecklistRepository; +import com.gachi.be.domain.child.repository.ChildRepository; +import com.gachi.be.domain.newsletter.entity.Newsletter; +import com.gachi.be.domain.newsletter.repository.NewsletterRepository; +import com.gachi.be.domain.notification.entity.enums.NotificationLevel; +import com.gachi.be.domain.notification.entity.enums.NotificationType; +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.Clock; +import java.time.LocalDate; +import java.time.OffsetDateTime; +import java.time.ZoneId; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.function.Function; +import java.util.stream.Collectors; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.stereotype.Component; + +/** 마감/체크리스트/주간 요약처럼 사용자의 별도 요청 없이 생성되어야 하는 알림을 만든다. */ +@Slf4j +@Component +@RequiredArgsConstructor +public class NotificationScheduler { + private static final ZoneId KST = ZoneId.of("Asia/Seoul"); + + private final CalendarEventRepository calendarEventRepository; + private final ChecklistRepository checklistRepository; + private final NewsletterRepository newsletterRepository; + private final UserRepository userRepository; + private final ChildRepository childRepository; + private final NotificationService notificationService; + private final Clock clock; + + @Scheduled(cron = "0 0 9 * * *", zone = "Asia/Seoul") + public void createDailyReminders() { + LocalDate today = LocalDate.now(clock); + createDeadlineReminders(today.plusDays(1)); + createChecklistReminders(today.plusDays(3), 3); + createChecklistReminders(today.plusDays(1), 1); + } + + @Scheduled(cron = "0 30 9 * * SUN", zone = "Asia/Seoul") + public void createWeeklySummaries() { + LocalDate today = LocalDate.now(clock); + createWeeklySummaries(today.minusDays(6), today.plusDays(1)); + } + + public void createDeadlineReminders(LocalDate targetDate) { + TimeRange range = dayRange(targetDate); + List events = + calendarEventRepository.findByStartAtGreaterThanEqualAndStartAtLessThan( + range.start(), range.end()); + + for (CalendarEvent event : events) { + Long childId = resolveChildId(event.getUserId(), event.getChildName()); + Map payload = payload(); + payload.put("calendarEventId", event.getId()); + payload.put("newsletterId", event.getNewsletterId()); + payload.put("targetDate", targetDate.toString()); + putIfPresent(payload, "childName", event.getChildName()); + + notificationService.createNotification( + event.getUserId(), + new NotificationCreateCommand( + NotificationType.DEADLINE_REMINDER, + event.getTitle() + " 마감 D-1", + "내일 마감이에요", + payload, + "deadline:" + event.getId() + ":" + targetDate, + NotificationLevel.URGENT, + childId, + event.getChildName())); + } + } + + public void createChecklistReminders(LocalDate targetDate, int daysBefore) { + createTodoReminders(targetDate, daysBefore); + createLinkedChecklistReminders(targetDate, daysBefore); + } + + private void createTodoReminders(LocalDate targetDate, int daysBefore) { + List todos = + checklistRepository.findByTypeAndCompletedFalseAndTargetDate( + ChecklistType.TODO, targetDate); + for (Checklist checklist : todos) { + Newsletter newsletter = findNewsletter(checklist.getNewsletterId()); + String childName = newsletter != null ? newsletter.getChildName() : null; + Long childId = resolveChildId(checklist.getUserId(), childName); + + Map payload = payload(); + payload.put("checklistId", checklist.getId()); + payload.put("newsletterId", checklist.getNewsletterId()); + payload.put("targetDate", targetDate.toString()); + putIfPresent(payload, "childName", childName); + + notificationService.createNotification( + checklist.getUserId(), + new NotificationCreateCommand( + NotificationType.CHECKLIST_DUE, + "미완료 할 일이 있어요", + checklist.getContent(), + payload, + "todo:" + checklist.getId() + ":d-" + daysBefore + ":" + targetDate, + NotificationLevel.IMPORTANT, + childId, + childName)); + } + } + + private void createLinkedChecklistReminders(LocalDate targetDate, int daysBefore) { + TimeRange range = dayRange(targetDate); + List events = + calendarEventRepository.findByStartAtGreaterThanEqualAndStartAtLessThan( + range.start(), range.end()); + if (events.isEmpty()) { + return; + } + + Map eventById = + events.stream().collect(Collectors.toMap(CalendarEvent::getId, Function.identity())); + List checklists = + checklistRepository.findIncompleteChecklistItemsByCalendarEventIds( + eventById.keySet().stream().toList()); + + for (Checklist checklist : checklists) { + CalendarEvent event = eventById.get(checklist.getCalendarEventId()); + if (event == null) { + continue; + } + Long childId = resolveChildId(checklist.getUserId(), event.getChildName()); + + Map payload = payload(); + payload.put("checklistId", checklist.getId()); + payload.put("calendarEventId", event.getId()); + payload.put("newsletterId", checklist.getNewsletterId()); + payload.put("targetDate", targetDate.toString()); + putIfPresent(payload, "childName", event.getChildName()); + + notificationService.createNotification( + checklist.getUserId(), + new NotificationCreateCommand( + NotificationType.CHECKLIST_DUE, + "미완료 할 일이 있어요", + checklist.getContent(), + payload, + "checklist:" + checklist.getId() + ":d-" + daysBefore + ":" + targetDate, + NotificationLevel.IMPORTANT, + childId, + event.getChildName())); + } + } + + public void createWeeklySummaries(LocalDate rangeStartDate, LocalDate rangeEndDate) { + OffsetDateTime rangeStart = rangeStartDate.atStartOfDay(KST).toOffsetDateTime(); + OffsetDateTime rangeEnd = rangeEndDate.atStartOfDay(KST).toOffsetDateTime(); + List users = userRepository.findAllByStatus(UserStatus.ACTIVE); + + for (User user : users) { + long newsletterCount = + newsletterRepository.countByUserIdAndCreatedAtGreaterThanEqualAndCreatedAtLessThan( + user.getId(), rangeStart, rangeEnd); + long incompleteCount = checklistRepository.countByUserIdAndCompletedFalse(user.getId()); + + Map payload = payload(); + payload.put("rangeStart", rangeStartDate.toString()); + payload.put("rangeEnd", rangeEndDate.minusDays(1).toString()); + payload.put("newsletterCount", newsletterCount); + payload.put("incompleteChecklistCount", incompleteCount); + + notificationService.createNotification( + user.getId(), + new NotificationCreateCommand( + NotificationType.WEEKLY_SUMMARY, + "이번 주 요약이 도착했어요", + "이번 주 가정통신문 " + newsletterCount + "개와 미완료 할 일 " + incompleteCount + "개를 확인해보세요", + payload, + "weekly-summary:" + user.getId() + ":" + rangeStartDate, + NotificationLevel.NORMAL, + null, + null)); + } + } + + private TimeRange dayRange(LocalDate targetDate) { + return new TimeRange( + targetDate.atStartOfDay(KST).toOffsetDateTime(), + targetDate.plusDays(1).atStartOfDay(KST).toOffsetDateTime()); + } + + private Newsletter findNewsletter(Long newsletterId) { + if (newsletterId == null) { + return null; + } + return newsletterRepository.findById(newsletterId).orElse(null); + } + + private Long resolveChildId(Long userId, String childName) { + if (childName == null || childName.isBlank()) { + return null; + } + return childRepository + .findFirstByUserIdAndNameAndDeletedAtIsNull(userId, childName) + .map(child -> child.getId()) + .orElse(null); + } + + private Map payload() { + return new LinkedHashMap<>(); + } + + private void putIfPresent(Map payload, String key, Object value) { + if (value != null) { + payload.put(key, value); + } + } + + private record TimeRange(OffsetDateTime start, OffsetDateTime end) {} +} 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 index 77f70c3..345d16b 100644 --- a/src/main/java/com/gachi/be/domain/notification/service/NotificationService.java +++ b/src/main/java/com/gachi/be/domain/notification/service/NotificationService.java @@ -2,6 +2,7 @@ import com.fasterxml.jackson.core.type.TypeReference; import com.fasterxml.jackson.databind.ObjectMapper; +import com.gachi.be.domain.child.repository.ChildRepository; 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; @@ -46,16 +47,18 @@ public class NotificationService { private final NotificationRepository notificationRepository; private final PushDeviceTokenRepository pushDeviceTokenRepository; private final NotificationDeliveryLogRepository notificationDeliveryLogRepository; + private final ChildRepository childRepository; private final ObjectMapper objectMapper; private final ApplicationEventPublisher eventPublisher; @Transactional(readOnly = true) public NotificationListResponse getNotifications( - Long userId, Long cursorId, Integer size, boolean unreadOnly) { + Long userId, Long cursorId, Integer size, boolean unreadOnly, Long childId) { int pageSize = normalizePageSize(size); + String childName = resolveChildName(userId, childId); List rows = notificationRepository.findInbox( - userId, cursorId, unreadOnly, PageRequest.of(0, pageSize + 1)); + userId, cursorId, childId, childName, unreadOnly, PageRequest.of(0, pageSize + 1)); boolean hasNext = rows.size() > pageSize; List page = hasNext ? rows.subList(0, pageSize) : rows; @@ -65,6 +68,12 @@ public NotificationListResponse getNotifications( page.stream().map(this::toResponse).toList(), nextCursor, hasNext); } + @Transactional(readOnly = true) + public NotificationListResponse getNotifications( + Long userId, Long cursorId, Integer size, boolean unreadOnly) { + return getNotifications(userId, cursorId, size, unreadOnly, null); + } + @Transactional(readOnly = true) public NotificationUnreadCountResponse getUnreadCount(Long userId) { return new NotificationUnreadCountResponse( @@ -166,6 +175,9 @@ public Notification createNotification(Long userId, NotificationCreateCommand co Notification.builder() .userId(userId) .type(command.type()) + .level(command.level()) + .childId(command.childId()) + .childName(normalizeOptional(command.childName())) .title(normalizeRequired(command.title())) .body(normalizeRequired(command.body())) .payloadJson(serializePayload(command.payload())) @@ -215,6 +227,9 @@ private NotificationResponse toResponse(Notification notification) { return new NotificationResponse( notification.getId(), notification.getType(), + notification.getLevel(), + notification.getChildId(), + notification.getChildName(), notification.getTitle(), notification.getBody(), deserializePayload(notification.getPayloadJson()), @@ -233,6 +248,16 @@ private PushTokenResponse toResponse(PushDeviceToken token) { token.getLastRegisteredAt()); } + private String resolveChildName(Long userId, Long childId) { + if (childId == null) { + return null; + } + return childRepository + .findByIdAndUserIdAndDeletedAtIsNull(childId, userId) + .map(child -> normalizeOptional(child.getName())) + .orElseThrow(() -> new BusinessException(ErrorCode.INVALID_INPUT_VALUE)); + } + private int normalizePageSize(Integer size) { if (size == null) { return DEFAULT_PAGE_SIZE; diff --git a/src/main/java/com/gachi/be/domain/user/api/controller/UserController.java b/src/main/java/com/gachi/be/domain/user/api/controller/UserController.java index 1c1658d..c7130d1 100644 --- a/src/main/java/com/gachi/be/domain/user/api/controller/UserController.java +++ b/src/main/java/com/gachi/be/domain/user/api/controller/UserController.java @@ -4,6 +4,7 @@ import com.gachi.be.domain.newsletter.entity.enums.NewsletterStatus; import com.gachi.be.domain.newsletter.repository.NewsletterRepository; import com.gachi.be.domain.user.dto.request.ChangeLanguageRequest; +import com.gachi.be.domain.user.dto.request.ChangeNotificationRequest; import com.gachi.be.domain.user.dto.response.UserMeResponse; import com.gachi.be.domain.user.entity.User; import com.gachi.be.domain.user.repository.UserRepository; @@ -49,6 +50,7 @@ public ApiResponse getMyInfo( user.getName(), user.getLanguageCode(), user.isNotificationEnabled(), + user.getNotificationPreference(), user.getCreatedAt())); } @@ -94,4 +96,16 @@ public ApiResponse changeLanguage( return ApiResponse.success(SuccessCode.USER_LANGUAGE_UPDATED, null); } + + @Operation(summary = "사용자 알림 설정 변경", description = "마이페이지에서 알림 수신 단계를 변경합니다.") + @PatchMapping("/me/notification") + @Transactional + public ApiResponse changeNotificationPreference( + @RequestHeader(value = "Authorization", required = false) String authorizationHeader, + @RequestBody @Valid ChangeNotificationRequest request) { + User user = authenticatedUserResolver.resolveActiveUser(authorizationHeader); + user.updateNotificationPreference(request.notificationPreference()); + userRepository.save(user); + return ApiResponse.success(SuccessCode.USER_NOTIFICATION_UPDATED, null); + } } diff --git a/src/main/java/com/gachi/be/domain/user/dto/request/ChangeNotificationRequest.java b/src/main/java/com/gachi/be/domain/user/dto/request/ChangeNotificationRequest.java index fa798c1..817f389 100644 --- a/src/main/java/com/gachi/be/domain/user/dto/request/ChangeNotificationRequest.java +++ b/src/main/java/com/gachi/be/domain/user/dto/request/ChangeNotificationRequest.java @@ -1,7 +1,9 @@ package com.gachi.be.domain.user.dto.request; +import com.gachi.be.domain.user.entity.enums.NotificationPreference; import jakarta.validation.constraints.NotNull; -/** 알림 설정 변경 요청 DTO. */ +/** 설정 화면에서 알림 수신 단계를 변경할 때 사용하는 요청 DTO. */ public record ChangeNotificationRequest( - @NotNull(message = "notificationEnabled는 필수입니다.") boolean notificationEnabled) {} + @NotNull(message = "notificationPreference는 필수입니다.") + NotificationPreference notificationPreference) {} diff --git a/src/main/java/com/gachi/be/domain/user/dto/response/UserMeResponse.java b/src/main/java/com/gachi/be/domain/user/dto/response/UserMeResponse.java index 245acb0..e32edc2 100644 --- a/src/main/java/com/gachi/be/domain/user/dto/response/UserMeResponse.java +++ b/src/main/java/com/gachi/be/domain/user/dto/response/UserMeResponse.java @@ -1,5 +1,6 @@ package com.gachi.be.domain.user.dto.response; +import com.gachi.be.domain.user.entity.enums.NotificationPreference; import java.time.LocalDateTime; /** 내 정보 조회 응답 DTO. */ @@ -10,4 +11,5 @@ public record UserMeResponse( String name, String languageCode, Boolean notificationEnabled, + NotificationPreference notificationPreference, LocalDateTime createdAt) {} diff --git a/src/main/java/com/gachi/be/domain/user/entity/User.java b/src/main/java/com/gachi/be/domain/user/entity/User.java index 3ee8483..4d980a5 100644 --- a/src/main/java/com/gachi/be/domain/user/entity/User.java +++ b/src/main/java/com/gachi/be/domain/user/entity/User.java @@ -1,5 +1,6 @@ package com.gachi.be.domain.user.entity; +import com.gachi.be.domain.user.entity.enums.NotificationPreference; import com.gachi.be.domain.user.entity.enums.UserStatus; import jakarta.persistence.Column; import jakarta.persistence.Entity; @@ -54,6 +55,10 @@ public class User { @Column(name = "notification_enabled", nullable = false) private boolean notificationEnabled; + @Enumerated(EnumType.STRING) + @Column(name = "notification_preference", nullable = false, length = 20) + private NotificationPreference notificationPreference; + @Column(name = "deleted_at") private OffsetDateTime deletedAt; @@ -88,6 +93,7 @@ public User( UserStatus status, String languageCode, Boolean notificationEnabled, + NotificationPreference notificationPreference, OffsetDateTime emailVerifiedAt, OffsetDateTime consentAgreedAt, String consentVersion, @@ -100,7 +106,11 @@ public User( this.phoneNumber = phoneNumber; this.status = status; this.languageCode = languageCode != null ? languageCode : "KO"; - this.notificationEnabled = notificationEnabled != null ? notificationEnabled : true; + this.notificationPreference = + notificationPreference != null + ? notificationPreference + : NotificationPreference.fromLegacyEnabled(notificationEnabled); + this.notificationEnabled = this.notificationPreference.isPushEnabled(); this.emailVerifiedAt = emailVerifiedAt; this.consentAgreedAt = consentAgreedAt; this.consentVersion = consentVersion; @@ -121,7 +131,25 @@ public void updateLanguage(String languageCode) { } public void updateNotificationEnabled(boolean notificationEnabled) { - this.notificationEnabled = notificationEnabled; + updateNotificationPreference(NotificationPreference.fromLegacyEnabled(notificationEnabled)); + } + + public void updateNotificationPreference(NotificationPreference notificationPreference) { + this.notificationPreference = + notificationPreference != null + ? notificationPreference + : NotificationPreference.defaultValue(); + this.notificationEnabled = this.notificationPreference.isPushEnabled(); + } + + public NotificationPreference getNotificationPreference() { + return notificationPreference != null + ? notificationPreference + : NotificationPreference.fromLegacyEnabled(notificationEnabled); + } + + public boolean isNotificationEnabled() { + return getNotificationPreference().isPushEnabled(); } @PrePersist @@ -137,6 +165,10 @@ protected void onCreate() { if (languageCode == null) { languageCode = "KO"; } + if (notificationPreference == null) { + notificationPreference = NotificationPreference.fromLegacyEnabled(notificationEnabled); + } + notificationEnabled = notificationPreference.isPushEnabled(); } @PreUpdate diff --git a/src/main/java/com/gachi/be/domain/user/entity/enums/NotificationPreference.java b/src/main/java/com/gachi/be/domain/user/entity/enums/NotificationPreference.java new file mode 100644 index 0000000..03f6524 --- /dev/null +++ b/src/main/java/com/gachi/be/domain/user/entity/enums/NotificationPreference.java @@ -0,0 +1,36 @@ +package com.gachi.be.domain.user.entity.enums; + +import com.gachi.be.domain.notification.entity.enums.NotificationLevel; + +/** 사용자가 외부 푸시로 받고 싶은 알림 범위를 나타낸다. */ +public enum NotificationPreference { + URGENT_ONLY, + IMPORTANT, + ALL, + OFF; + + public static NotificationPreference defaultValue() { + return IMPORTANT; + } + + public static NotificationPreference fromLegacyEnabled(Boolean notificationEnabled) { + if (notificationEnabled == null) { + return defaultValue(); + } + return notificationEnabled ? defaultValue() : OFF; + } + + public boolean allows(NotificationLevel level) { + NotificationLevel resolvedLevel = level != null ? level : NotificationLevel.IMPORTANT; + return switch (this) { + case ALL -> true; + case IMPORTANT -> resolvedLevel != NotificationLevel.NORMAL; + case URGENT_ONLY -> resolvedLevel == NotificationLevel.URGENT; + case OFF -> false; + }; + } + + public boolean isPushEnabled() { + return this != OFF; + } +} diff --git a/src/main/java/com/gachi/be/domain/user/repository/UserRepository.java b/src/main/java/com/gachi/be/domain/user/repository/UserRepository.java index 29de4b6..404d013 100644 --- a/src/main/java/com/gachi/be/domain/user/repository/UserRepository.java +++ b/src/main/java/com/gachi/be/domain/user/repository/UserRepository.java @@ -2,6 +2,7 @@ import com.gachi.be.domain.user.entity.User; import com.gachi.be.domain.user.entity.enums.UserStatus; +import java.util.List; import java.util.Optional; import org.springframework.data.jpa.repository.JpaRepository; @@ -15,4 +16,6 @@ public interface UserRepository extends JpaRepository { Optional findByLoginId(String loginId); Optional findByIdAndStatus(Long id, UserStatus status); + + List findAllByStatus(UserStatus status); } 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 73d999d..24de177 100644 --- a/src/main/java/com/gachi/be/global/code/SuccessCode.java +++ b/src/main/java/com/gachi/be/global/code/SuccessCode.java @@ -39,6 +39,7 @@ public enum SuccessCode { CHECKLIST_COMPLETE_SUCCESS(HttpStatus.OK, "CL2002", "체크리스트 완료 처리에 성공하였습니다."), CHECKLIST_DELETED(HttpStatus.OK, "CL2003", "체크리스트 삭제에 성공하였습니다."), USER_LANGUAGE_UPDATED(HttpStatus.OK, "USER2001", "언어 설정이 변경되었습니다."), + USER_NOTIFICATION_UPDATED(HttpStatus.OK, "USER2002", "알림 설정이 변경되었습니다."), NOTIFICATION_LIST_SUCCESS(HttpStatus.OK, "NOTI2001", "알림 목록 조회에 성공하였습니다."), NOTIFICATION_UNREAD_COUNT_SUCCESS(HttpStatus.OK, "NOTI2002", "미읽음 알림 수 조회에 성공하였습니다."), NOTIFICATION_READ_SUCCESS(HttpStatus.OK, "NOTI2003", "알림 읽음 처리에 성공하였습니다."), diff --git a/src/main/resources/db/migration/V16__notification_preference_and_context.sql b/src/main/resources/db/migration/V16__notification_preference_and_context.sql new file mode 100644 index 0000000..1ea3bb0 --- /dev/null +++ b/src/main/resources/db/migration/V16__notification_preference_and_context.sql @@ -0,0 +1,63 @@ +ALTER TABLE users + ADD COLUMN IF NOT EXISTS notification_preference VARCHAR(20); + +UPDATE users +SET notification_preference = CASE + WHEN notification_enabled = FALSE THEN 'OFF' + ELSE 'IMPORTANT' +END +WHERE notification_preference IS NULL; + +ALTER TABLE users + ALTER COLUMN notification_preference SET DEFAULT 'IMPORTANT', + ALTER COLUMN notification_preference SET NOT NULL; + +ALTER TABLE users + DROP CONSTRAINT IF EXISTS chk_users_notification_preference; + +ALTER TABLE users + ADD CONSTRAINT chk_users_notification_preference + CHECK (notification_preference IN ('URGENT_ONLY', 'IMPORTANT', 'ALL', 'OFF')); + +ALTER TABLE notifications + ADD COLUMN IF NOT EXISTS level VARCHAR(20), + ADD COLUMN IF NOT EXISTS child_id BIGINT, + ADD COLUMN IF NOT EXISTS child_name VARCHAR(50); + +UPDATE notifications +SET level = 'IMPORTANT' +WHERE level IS NULL; + +ALTER TABLE notifications + ALTER COLUMN level SET DEFAULT 'IMPORTANT', + ALTER COLUMN level SET NOT NULL; + +ALTER TABLE notifications + DROP CONSTRAINT IF EXISTS chk_notifications_type; + +ALTER TABLE notifications + ADD CONSTRAINT chk_notifications_type + CHECK (type IN ( + 'NEWSLETTER_ANALYSIS', + 'DEADLINE_REMINDER', + 'CALENDAR_EVENT', + 'CHECKLIST_DUE', + 'WEEKLY_SUMMARY', + 'SYSTEM', + 'ANNOUNCEMENT' + )); + +ALTER TABLE notifications + DROP CONSTRAINT IF EXISTS chk_notifications_level; + +ALTER TABLE notifications + ADD CONSTRAINT chk_notifications_level + CHECK (level IN ('URGENT', 'IMPORTANT', 'NORMAL')); + +CREATE INDEX IF NOT EXISTS idx_notifications_user_child_id + ON notifications (user_id, child_id, id DESC) + WHERE child_id IS NOT NULL; + +CREATE INDEX IF NOT EXISTS idx_notifications_user_child_name + ON notifications (user_id, child_name, id DESC) + WHERE child_name IS NOT NULL; From 7c59db2965a84c04d76418cb4456fbfea38515ca Mon Sep 17 00:00:00 2001 From: minju Date: Wed, 27 May 2026 12:49:52 +0900 Subject: [PATCH 2/4] =?UTF-8?q?fix:=20=EC=95=8C=EB=A6=BC=20=EC=9E=90?= =?UTF-8?q?=EB=8F=99=20=EC=83=9D=EC=84=B1=20=EC=95=88=EC=A0=95=EC=84=B1=20?= =?UTF-8?q?=EB=B3=B4=EA=B0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../auth/service/impl/AuthServiceImpl.java | 6 ++- .../repository/ChecklistRepository.java | 2 +- .../pipeline/NewsletterPipelineService.java | 10 ++++- .../repository/NotificationRepository.java | 6 ++- .../service/NotificationScheduler.java | 38 ++++++++++++++----- 5 files changed, 49 insertions(+), 13 deletions(-) diff --git a/src/main/java/com/gachi/be/domain/auth/service/impl/AuthServiceImpl.java b/src/main/java/com/gachi/be/domain/auth/service/impl/AuthServiceImpl.java index 9e9ac54..9d3fa8b 100644 --- a/src/main/java/com/gachi/be/domain/auth/service/impl/AuthServiceImpl.java +++ b/src/main/java/com/gachi/be/domain/auth/service/impl/AuthServiceImpl.java @@ -22,6 +22,7 @@ import com.gachi.be.domain.auth.service.TokenHashService; import com.gachi.be.domain.auth.service.password.PasswordStrengthEvaluator; import com.gachi.be.domain.user.entity.User; +import com.gachi.be.domain.user.entity.enums.NotificationPreference; import com.gachi.be.domain.user.entity.enums.UserStatus; import com.gachi.be.domain.user.repository.UserRepository; import com.gachi.be.global.code.ErrorCode; @@ -139,7 +140,10 @@ public SignupResponse signup(SignupRequest request) { .phoneNumber(phoneNumber) .status(UserStatus.ACTIVE) .languageCode(request.languageCode()) - .notificationPreference(request.notificationPreference()) + .notificationPreference( + request.notificationPreference() != null + ? request.notificationPreference() + : NotificationPreference.IMPORTANT) .emailVerifiedAt(now) .consentAgreedAt(now) .consentVersion(authProperties.getConsentVersion()) diff --git a/src/main/java/com/gachi/be/domain/checklist/repository/ChecklistRepository.java b/src/main/java/com/gachi/be/domain/checklist/repository/ChecklistRepository.java index 0548092..cd1be3c 100644 --- a/src/main/java/com/gachi/be/domain/checklist/repository/ChecklistRepository.java +++ b/src/main/java/com/gachi/be/domain/checklist/repository/ChecklistRepository.java @@ -26,7 +26,7 @@ public interface ChecklistRepository extends JpaRepository { /** 특정 사용자의 미완료 CHECKLIST 항목 전체 조회. */ List findByUserIdAndTypeAndCompletedFalse(Long userId, ChecklistType type); - long countByUserIdAndCompletedFalse(Long userId); + long countByUserIdAndTypeAndCompletedFalse(Long userId, ChecklistType type); List findByTypeAndCompletedFalseAndTargetDate( ChecklistType type, LocalDate targetDate); diff --git a/src/main/java/com/gachi/be/domain/newsletter/pipeline/NewsletterPipelineService.java b/src/main/java/com/gachi/be/domain/newsletter/pipeline/NewsletterPipelineService.java index 9597c41..24c51b3 100644 --- a/src/main/java/com/gachi/be/domain/newsletter/pipeline/NewsletterPipelineService.java +++ b/src/main/java/com/gachi/be/domain/newsletter/pipeline/NewsletterPipelineService.java @@ -188,7 +188,15 @@ public void markCompleted( n -> { n.complete(ocrText, originalText, translatedText, title, summary); Newsletter saved = newsletterRepository.save(n); - createAnalysisCompletedNotification(saved); + try { + createAnalysisCompletedNotification(saved); + } catch (Exception ex) { + log.warn( + "[Pipeline] 분석 완료 알림 생성 실패. newsletterId={}, error={}", + saved.getId(), + ex.getMessage(), + ex); + } }); } 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 index 9a72086..55a218f 100644 --- a/src/main/java/com/gachi/be/domain/notification/repository/NotificationRepository.java +++ b/src/main/java/com/gachi/be/domain/notification/repository/NotificationRepository.java @@ -19,7 +19,11 @@ public interface NotificationRepository extends JpaRepository findInbox( diff --git a/src/main/java/com/gachi/be/domain/notification/service/NotificationScheduler.java b/src/main/java/com/gachi/be/domain/notification/service/NotificationScheduler.java index f75e00a..aaf523b 100644 --- a/src/main/java/com/gachi/be/domain/notification/service/NotificationScheduler.java +++ b/src/main/java/com/gachi/be/domain/notification/service/NotificationScheduler.java @@ -70,7 +70,7 @@ public void createDeadlineReminders(LocalDate targetDate) { payload.put("targetDate", targetDate.toString()); putIfPresent(payload, "childName", event.getChildName()); - notificationService.createNotification( + createNotificationSafely( event.getUserId(), new NotificationCreateCommand( NotificationType.DEADLINE_REMINDER, @@ -80,7 +80,8 @@ public void createDeadlineReminders(LocalDate targetDate) { "deadline:" + event.getId() + ":" + targetDate, NotificationLevel.URGENT, childId, - event.getChildName())); + event.getChildName()), + "deadline:" + event.getId()); } } @@ -104,7 +105,7 @@ private void createTodoReminders(LocalDate targetDate, int daysBefore) { payload.put("targetDate", targetDate.toString()); putIfPresent(payload, "childName", childName); - notificationService.createNotification( + createNotificationSafely( checklist.getUserId(), new NotificationCreateCommand( NotificationType.CHECKLIST_DUE, @@ -114,7 +115,8 @@ private void createTodoReminders(LocalDate targetDate, int daysBefore) { "todo:" + checklist.getId() + ":d-" + daysBefore + ":" + targetDate, NotificationLevel.IMPORTANT, childId, - childName)); + childName), + "todo:" + checklist.getId()); } } @@ -147,7 +149,7 @@ private void createLinkedChecklistReminders(LocalDate targetDate, int daysBefore payload.put("targetDate", targetDate.toString()); putIfPresent(payload, "childName", event.getChildName()); - notificationService.createNotification( + createNotificationSafely( checklist.getUserId(), new NotificationCreateCommand( NotificationType.CHECKLIST_DUE, @@ -157,7 +159,8 @@ private void createLinkedChecklistReminders(LocalDate targetDate, int daysBefore "checklist:" + checklist.getId() + ":d-" + daysBefore + ":" + targetDate, NotificationLevel.IMPORTANT, childId, - event.getChildName())); + event.getChildName()), + "checklist:" + checklist.getId()); } } @@ -170,7 +173,9 @@ public void createWeeklySummaries(LocalDate rangeStartDate, LocalDate rangeEndDa long newsletterCount = newsletterRepository.countByUserIdAndCreatedAtGreaterThanEqualAndCreatedAtLessThan( user.getId(), rangeStart, rangeEnd); - long incompleteCount = checklistRepository.countByUserIdAndCompletedFalse(user.getId()); + long incompleteCount = + checklistRepository.countByUserIdAndTypeAndCompletedFalse( + user.getId(), ChecklistType.CHECKLIST); Map payload = payload(); payload.put("rangeStart", rangeStartDate.toString()); @@ -178,7 +183,7 @@ public void createWeeklySummaries(LocalDate rangeStartDate, LocalDate rangeEndDa payload.put("newsletterCount", newsletterCount); payload.put("incompleteChecklistCount", incompleteCount); - notificationService.createNotification( + createNotificationSafely( user.getId(), new NotificationCreateCommand( NotificationType.WEEKLY_SUMMARY, @@ -188,7 +193,8 @@ public void createWeeklySummaries(LocalDate rangeStartDate, LocalDate rangeEndDa "weekly-summary:" + user.getId() + ":" + rangeStartDate, NotificationLevel.NORMAL, null, - null)); + null), + "weekly-summary:" + user.getId()); } } @@ -225,5 +231,19 @@ private void putIfPresent(Map payload, String key, Object value) } } + private void createNotificationSafely( + Long userId, NotificationCreateCommand command, String context) { + try { + notificationService.createNotification(userId, command); + } catch (Exception e) { + log.warn( + "[Scheduler] 알림 생성 실패. context={}, userId={}, error={}", + context, + userId, + e.getMessage(), + e); + } + } + private record TimeRange(OffsetDateTime start, OffsetDateTime end) {} } From d0d5fd2bf4640a8b0b0362b7f70377bc1448c969 Mon Sep 17 00:00:00 2001 From: minju Date: Wed, 27 May 2026 15:29:46 +0900 Subject: [PATCH 3/4] =?UTF-8?q?fix:=20=EB=B6=84=EC=84=9D=20=EC=99=84?= =?UTF-8?q?=EB=A3=8C=20=EC=95=8C=EB=A6=BC=20=EC=83=9D=EC=84=B1=20=ED=8A=B8?= =?UTF-8?q?=EB=9E=9C=EC=9E=AD=EC=85=98=20=EB=B6=84=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../pipeline/NewsletterPipelineService.java | 38 ++++++++++++++----- 1 file changed, 29 insertions(+), 9 deletions(-) diff --git a/src/main/java/com/gachi/be/domain/newsletter/pipeline/NewsletterPipelineService.java b/src/main/java/com/gachi/be/domain/newsletter/pipeline/NewsletterPipelineService.java index 24c51b3..1d7b7cd 100644 --- a/src/main/java/com/gachi/be/domain/newsletter/pipeline/NewsletterPipelineService.java +++ b/src/main/java/com/gachi/be/domain/newsletter/pipeline/NewsletterPipelineService.java @@ -19,6 +19,8 @@ import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Propagation; import org.springframework.transaction.annotation.Transactional; +import org.springframework.transaction.support.TransactionSynchronization; +import org.springframework.transaction.support.TransactionSynchronizationManager; import software.amazon.awssdk.core.ResponseBytes; import software.amazon.awssdk.core.sync.RequestBody; import software.amazon.awssdk.services.s3.S3Client; @@ -188,15 +190,7 @@ public void markCompleted( n -> { n.complete(ocrText, originalText, translatedText, title, summary); Newsletter saved = newsletterRepository.save(n); - try { - createAnalysisCompletedNotification(saved); - } catch (Exception ex) { - log.warn( - "[Pipeline] 분석 완료 알림 생성 실패. newsletterId={}, error={}", - saved.getId(), - ex.getMessage(), - ex); - } + scheduleAnalysisCompletedNotification(saved); }); } @@ -226,6 +220,32 @@ private String failureReason(Exception e) { return e.getClass().getSimpleName() + ": " + message; } + private void scheduleAnalysisCompletedNotification(Newsletter newsletter) { + if (TransactionSynchronizationManager.isSynchronizationActive()) { + TransactionSynchronizationManager.registerSynchronization( + new TransactionSynchronization() { + @Override + public void afterCommit() { + createAnalysisCompletedNotificationSafely(newsletter); + } + }); + return; + } + createAnalysisCompletedNotificationSafely(newsletter); + } + + private void createAnalysisCompletedNotificationSafely(Newsletter newsletter) { + try { + createAnalysisCompletedNotification(newsletter); + } catch (Exception ex) { + log.warn( + "[Pipeline] 분석 완료 알림 생성 실패. newsletterId={}, error={}", + newsletter.getId(), + ex.getMessage(), + ex); + } + } + private void createAnalysisCompletedNotification(Newsletter newsletter) { Long childId = resolveChildId(newsletter); notificationService.createNotification( From 309cea963c110c2ab68527b069221f10898499be Mon Sep 17 00:00:00 2001 From: minju Date: Wed, 27 May 2026 15:55:00 +0900 Subject: [PATCH 4/4] =?UTF-8?q?fix:=20=EB=89=B4=EC=8A=A4=EB=A0=88=ED=84=B0?= =?UTF-8?q?=20=ED=8C=8C=EC=9D=B4=ED=94=84=EB=9D=BC=EC=9D=B8=20=EC=83=81?= =?UTF-8?q?=ED=83=9C=20=ED=8A=B8=EB=9E=9C=EC=9E=AD=EC=85=98=20=EB=B6=84?= =?UTF-8?q?=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../pipeline/NewsletterPipelineService.java | 126 +---------------- .../NewsletterPipelineStatusService.java | 132 ++++++++++++++++++ 2 files changed, 137 insertions(+), 121 deletions(-) create mode 100644 src/main/java/com/gachi/be/domain/newsletter/pipeline/NewsletterPipelineStatusService.java diff --git a/src/main/java/com/gachi/be/domain/newsletter/pipeline/NewsletterPipelineService.java b/src/main/java/com/gachi/be/domain/newsletter/pipeline/NewsletterPipelineService.java index 1d7b7cd..1ee0109 100644 --- a/src/main/java/com/gachi/be/domain/newsletter/pipeline/NewsletterPipelineService.java +++ b/src/main/java/com/gachi/be/domain/newsletter/pipeline/NewsletterPipelineService.java @@ -1,26 +1,17 @@ package com.gachi.be.domain.newsletter.pipeline; -import com.gachi.be.domain.child.repository.ChildRepository; import com.gachi.be.domain.newsletter.entity.Newsletter; import com.gachi.be.domain.newsletter.pipeline.ClovaOcrClient.OcrField; import com.gachi.be.domain.newsletter.pipeline.NewsletterAiAnalyzer.AiAnalysisResult; import com.gachi.be.domain.newsletter.repository.NewsletterRepository; -import com.gachi.be.domain.notification.entity.enums.NotificationLevel; -import com.gachi.be.domain.notification.entity.enums.NotificationType; -import com.gachi.be.domain.notification.service.NotificationCreateCommand; -import com.gachi.be.domain.notification.service.NotificationService; import com.gachi.be.file.config.S3Properties; import com.gachi.be.global.exception.ExternalApiException; import java.util.List; -import java.util.Map; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.scheduling.annotation.Async; import org.springframework.stereotype.Service; -import org.springframework.transaction.annotation.Propagation; import org.springframework.transaction.annotation.Transactional; -import org.springframework.transaction.support.TransactionSynchronization; -import org.springframework.transaction.support.TransactionSynchronizationManager; import software.amazon.awssdk.core.ResponseBytes; import software.amazon.awssdk.core.sync.RequestBody; import software.amazon.awssdk.services.s3.S3Client; @@ -42,8 +33,7 @@ public class NewsletterPipelineService { private final PapagoTranslateClient papagoTranslateClient; private final NewsletterAiAnalyzer newsletterAiAnalyzer; private final NewsletterDateCandidateService newsletterDateCandidateService; - private final NotificationService notificationService; - private final ChildRepository childRepository; + private final NewsletterPipelineStatusService newsletterPipelineStatusService; @Async @Transactional @@ -56,7 +46,7 @@ public void runPipeline(Long newsletterId) { return; } - markProcessing(newsletterId); + newsletterPipelineStatusService.markProcessing(newsletterId); log.debug("[Pipeline] PROCESSING 전환 완료. newsletterId={}", newsletterId); String tempFileKey = null; @@ -126,13 +116,13 @@ public void runPipeline(Long newsletterId) { e.getClass().getSimpleName(), e.getMessage(), e); - markFailedWithSnapshot( + newsletterPipelineStatusService.markFailedWithSnapshot( newsletterId, ocrText, originalText, translatedText, failureStage, failureReason(e)); return; } log.debug("[Pipeline][STEP7] AI 서버 분석 완료. title={}", aiResult.title()); - markCompleted( + newsletterPipelineStatusService.markCompleted( newsletterId, ocrText, originalText, @@ -149,7 +139,7 @@ public void runPipeline(Long newsletterId) { e.getClass().getSimpleName(), e.getMessage(), e); - markFailedWithSnapshot( + newsletterPipelineStatusService.markFailedWithSnapshot( newsletterId, ocrText, originalText, translatedText, failureStage, failureReason(e)); } finally { if (tempFileKey != null) { @@ -157,7 +147,6 @@ public void runPipeline(Long newsletterId) { deleteFromS3(tempFileKey); log.debug("[Pipeline] 임시 파일 삭제 완료. tempFileKey={}", tempFileKey); } catch (Exception ex) { - // 임시 파일 정리는 후처리라서 실패해도 분석 결과는 되돌리지 않는다. log.warn( "[Pipeline] 임시 파일 삭제 실패. tempFileKey={}, error={}", tempFileKey, ex.getMessage()); } @@ -165,53 +154,6 @@ public void runPipeline(Long newsletterId) { } } - @Transactional(propagation = Propagation.REQUIRES_NEW) - public void markProcessing(Long newsletterId) { - newsletterRepository - .findById(newsletterId) - .ifPresent( - n -> { - n.startProcessing(); - newsletterRepository.save(n); - }); - } - - @Transactional(propagation = Propagation.REQUIRES_NEW) - public void markCompleted( - Long newsletterId, - String ocrText, - String originalText, - String translatedText, - String title, - String summary) { - newsletterRepository - .findById(newsletterId) - .ifPresent( - n -> { - n.complete(ocrText, originalText, translatedText, title, summary); - Newsletter saved = newsletterRepository.save(n); - scheduleAnalysisCompletedNotification(saved); - }); - } - - @Transactional(propagation = Propagation.REQUIRES_NEW) - public void markFailedWithSnapshot( - Long newsletterId, - String ocrText, - String originalText, - String translatedText, - String failureStage, - String failureReason) { - newsletterRepository - .findById(newsletterId) - .ifPresent( - n -> { - n.failWithSnapshot( - ocrText, originalText, translatedText, failureStage, failureReason); - newsletterRepository.save(n); - }); - } - private String failureReason(Exception e) { String message = e.getMessage(); if (message == null || message.isBlank()) { @@ -220,64 +162,6 @@ private String failureReason(Exception e) { return e.getClass().getSimpleName() + ": " + message; } - private void scheduleAnalysisCompletedNotification(Newsletter newsletter) { - if (TransactionSynchronizationManager.isSynchronizationActive()) { - TransactionSynchronizationManager.registerSynchronization( - new TransactionSynchronization() { - @Override - public void afterCommit() { - createAnalysisCompletedNotificationSafely(newsletter); - } - }); - return; - } - createAnalysisCompletedNotificationSafely(newsletter); - } - - private void createAnalysisCompletedNotificationSafely(Newsletter newsletter) { - try { - createAnalysisCompletedNotification(newsletter); - } catch (Exception ex) { - log.warn( - "[Pipeline] 분석 완료 알림 생성 실패. newsletterId={}, error={}", - newsletter.getId(), - ex.getMessage(), - ex); - } - } - - private void createAnalysisCompletedNotification(Newsletter newsletter) { - Long childId = resolveChildId(newsletter); - notificationService.createNotification( - newsletter.getUserId(), - new NotificationCreateCommand( - NotificationType.NEWSLETTER_ANALYSIS, - "새 가정통신문 분석 완료", - newsletter.getTitle() != null && !newsletter.getTitle().isBlank() - ? newsletter.getTitle() + " 분석이 완료되었어요" - : "가정통신문 분석이 완료되었어요", - Map.of( - "newsletterId", - newsletter.getId(), - "childName", - newsletter.getChildName() != null ? newsletter.getChildName() : ""), - "newsletter-analysis:" + newsletter.getId(), - NotificationLevel.IMPORTANT, - childId, - newsletter.getChildName())); - } - - private Long resolveChildId(Newsletter newsletter) { - if (newsletter.getChildName() == null || newsletter.getChildName().isBlank()) { - return null; - } - return childRepository - .findFirstByUserIdAndNameAndDeletedAtIsNull( - newsletter.getUserId(), newsletter.getChildName()) - .map(child -> child.getId()) - .orElse(null); - } - private byte[] downloadFromS3(String fileKey) { GetObjectRequest request = GetObjectRequest.builder().bucket(s3Properties.getBucket()).key(fileKey).build(); diff --git a/src/main/java/com/gachi/be/domain/newsletter/pipeline/NewsletterPipelineStatusService.java b/src/main/java/com/gachi/be/domain/newsletter/pipeline/NewsletterPipelineStatusService.java new file mode 100644 index 0000000..83ee5ea --- /dev/null +++ b/src/main/java/com/gachi/be/domain/newsletter/pipeline/NewsletterPipelineStatusService.java @@ -0,0 +1,132 @@ +package com.gachi.be.domain.newsletter.pipeline; + +import com.gachi.be.domain.child.repository.ChildRepository; +import com.gachi.be.domain.newsletter.entity.Newsletter; +import com.gachi.be.domain.newsletter.repository.NewsletterRepository; +import com.gachi.be.domain.notification.entity.enums.NotificationLevel; +import com.gachi.be.domain.notification.entity.enums.NotificationType; +import com.gachi.be.domain.notification.service.NotificationCreateCommand; +import com.gachi.be.domain.notification.service.NotificationService; +import java.util.Map; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Propagation; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.transaction.support.TransactionSynchronization; +import org.springframework.transaction.support.TransactionSynchronizationManager; + +@Slf4j +@Service +@RequiredArgsConstructor +public class NewsletterPipelineStatusService { + + private final NewsletterRepository newsletterRepository; + private final NotificationService notificationService; + private final ChildRepository childRepository; + + @Transactional(propagation = Propagation.REQUIRES_NEW) + public void markProcessing(Long newsletterId) { + newsletterRepository + .findById(newsletterId) + .ifPresent( + newsletter -> { + newsletter.startProcessing(); + newsletterRepository.save(newsletter); + }); + } + + @Transactional(propagation = Propagation.REQUIRES_NEW) + public void markCompleted( + Long newsletterId, + String ocrText, + String originalText, + String translatedText, + String title, + String summary) { + newsletterRepository + .findById(newsletterId) + .ifPresent( + newsletter -> { + newsletter.complete(ocrText, originalText, translatedText, title, summary); + Newsletter saved = newsletterRepository.save(newsletter); + scheduleAnalysisCompletedNotification(saved); + }); + } + + @Transactional(propagation = Propagation.REQUIRES_NEW) + public void markFailedWithSnapshot( + Long newsletterId, + String ocrText, + String originalText, + String translatedText, + String failureStage, + String failureReason) { + newsletterRepository + .findById(newsletterId) + .ifPresent( + newsletter -> { + newsletter.failWithSnapshot( + ocrText, originalText, translatedText, failureStage, failureReason); + newsletterRepository.save(newsletter); + }); + } + + private void scheduleAnalysisCompletedNotification(Newsletter newsletter) { + if (TransactionSynchronizationManager.isSynchronizationActive()) { + TransactionSynchronizationManager.registerSynchronization( + new TransactionSynchronization() { + @Override + public void afterCommit() { + createAnalysisCompletedNotificationSafely(newsletter); + } + }); + return; + } + createAnalysisCompletedNotificationSafely(newsletter); + } + + private void createAnalysisCompletedNotificationSafely(Newsletter newsletter) { + try { + createAnalysisCompletedNotification(newsletter); + } catch (Exception ex) { + log.warn( + "[Pipeline] 분석 완료 알림 생성 실패. newsletterId={}, error={}", + newsletter.getId(), + ex.getMessage(), + ex); + } + } + + private void createAnalysisCompletedNotification(Newsletter newsletter) { + Long childId = resolveChildId(newsletter); + notificationService.createNotification( + newsletter.getUserId(), + new NotificationCreateCommand( + NotificationType.NEWSLETTER_ANALYSIS, + "새 가정통신문 분석 완료", + newsletter.getTitle() != null && !newsletter.getTitle().isBlank() + ? newsletter.getTitle() + " 분석이 완료되었어요" + : "가정통신문 분석이 완료되었어요", + Map.of( + "newsletterId", + newsletter.getId(), + "childName", + newsletter.getChildName() != null ? newsletter.getChildName() : ""), + "newsletter-analysis:" + newsletter.getId(), + NotificationLevel.IMPORTANT, + childId, + newsletter.getChildName())); + } + + private Long resolveChildId(Newsletter newsletter) { + if (newsletter.getChildName() == null || newsletter.getChildName().isBlank()) { + return null; + } + return childRepository + .findFirstByUserIdAndNameAndDeletedAtIsNull( + newsletter.getUserId(), newsletter.getChildName()) + .map(child -> child.getId()) + .orElse(null); + } +}