diff --git a/src/main/kotlin/com/unicorn/server/domain/notification/enums/NotificationEventType.kt b/src/main/kotlin/com/unicorn/server/domain/notification/enums/NotificationEventType.kt index 9a124f7..866271b 100644 --- a/src/main/kotlin/com/unicorn/server/domain/notification/enums/NotificationEventType.kt +++ b/src/main/kotlin/com/unicorn/server/domain/notification/enums/NotificationEventType.kt @@ -27,4 +27,7 @@ enum class NotificationEventType { /** 미확인 일정 확인 요청 */ SCHEDULE_CONFIRMATION_REQUESTED, + + /** 가족에게 전하기 */ + SCHEDULE_FAMILY_NOTIFICATION_REQUESTED, } diff --git a/src/main/kotlin/com/unicorn/server/domain/notification/enums/NotificationType.kt b/src/main/kotlin/com/unicorn/server/domain/notification/enums/NotificationType.kt index a2b8aa9..2011c57 100644 --- a/src/main/kotlin/com/unicorn/server/domain/notification/enums/NotificationType.kt +++ b/src/main/kotlin/com/unicorn/server/domain/notification/enums/NotificationType.kt @@ -60,4 +60,10 @@ enum class NotificationType( settingType = NotificationSettingType.FAMILY_SCHEDULE_CHECK, defaultRouteType = NotificationRouteType.SCHEDULE_DETAIL, ), + SCHEDULE_FAMILY_NOTIFICATION_REQUESTED( + createsInbox = true, + sendsPush = true, + settingType = NotificationSettingType.FAMILY_SCHEDULE_CHECK, + defaultRouteType = NotificationRouteType.SCHEDULE_DETAIL, + ), } diff --git a/src/main/kotlin/com/unicorn/server/domain/notification/event/NotificationEventPayload.kt b/src/main/kotlin/com/unicorn/server/domain/notification/event/NotificationEventPayload.kt index 7369d91..932e286 100644 --- a/src/main/kotlin/com/unicorn/server/domain/notification/event/NotificationEventPayload.kt +++ b/src/main/kotlin/com/unicorn/server/domain/notification/event/NotificationEventPayload.kt @@ -99,3 +99,17 @@ data class ScheduleConfirmationRequestedPayload( override fun toVariables(): Map = mapOf("schedule_title" to scheduleTitle) } + +data class ScheduleFamilyNotificationPayload( + val senderName: String, + val scheduleTitle: String, + val dDay: String, +) : NotificationEventPayload { + override val eventType: NotificationEventType = NotificationEventType.SCHEDULE_FAMILY_NOTIFICATION_REQUESTED + + override fun toVariables(): Map = mapOf( + "sender_name" to senderName, + "schedule_title" to scheduleTitle, + "d_day" to dDay, + ) +} diff --git a/src/main/kotlin/com/unicorn/server/domain/schedule/event/FamilyScheduleNotificationRequestedEvent.kt b/src/main/kotlin/com/unicorn/server/domain/schedule/event/FamilyScheduleNotificationRequestedEvent.kt new file mode 100644 index 0000000..77d7522 --- /dev/null +++ b/src/main/kotlin/com/unicorn/server/domain/schedule/event/FamilyScheduleNotificationRequestedEvent.kt @@ -0,0 +1,12 @@ +package com.unicorn.server.domain.schedule.event + +import com.unicorn.server.common.domain.Event + +class FamilyScheduleNotificationRequestedEvent( + val requestId: String, + val scheduleId: String, + val circleId: String, + val senderMemberId: String, + val scheduleTitle: String, + val dDay: String, +) : Event() diff --git a/src/main/kotlin/com/unicorn/server/domain/schedule/exception/ScheduleErrorCode.kt b/src/main/kotlin/com/unicorn/server/domain/schedule/exception/ScheduleErrorCode.kt index 7e135df..570c05f 100644 --- a/src/main/kotlin/com/unicorn/server/domain/schedule/exception/ScheduleErrorCode.kt +++ b/src/main/kotlin/com/unicorn/server/domain/schedule/exception/ScheduleErrorCode.kt @@ -20,6 +20,7 @@ enum class ScheduleErrorCode( MEMO_TOO_LONG("S400_10", "Memo must not exceed 500 characters", HttpStatus.BAD_REQUEST), CONFIRMATION_NOT_SUPPORTED("S400_11", "This schedule does not support confirmation", HttpStatus.BAD_REQUEST), INVALID_CONFIRMATION_TYPE("S400_12", "Invalid confirmation type", HttpStatus.BAD_REQUEST), + FAMILY_SCHEDULE_NOTIFICATION_NOT_AVAILABLE("S400_13", "Family schedule notification is only available on or before the schedule start date", HttpStatus.BAD_REQUEST), CIRCLE_ACCESS_DENIED("S403_1", "No access to this circle", HttpStatus.FORBIDDEN), SCHEDULE_MODIFICATION_DENIED("S403_2", "Only the author or circle initiator can modify this schedule", HttpStatus.FORBIDDEN), CONFIRMATION_ACCESS_DENIED("S403_3", "Only circle members can register confirmation", HttpStatus.FORBIDDEN), diff --git a/src/main/kotlin/com/unicorn/server/domain/schedule/port/dto/RequestFamilyScheduleNotificationCommand.kt b/src/main/kotlin/com/unicorn/server/domain/schedule/port/dto/RequestFamilyScheduleNotificationCommand.kt new file mode 100644 index 0000000..d86703b --- /dev/null +++ b/src/main/kotlin/com/unicorn/server/domain/schedule/port/dto/RequestFamilyScheduleNotificationCommand.kt @@ -0,0 +1,9 @@ +package com.unicorn.server.domain.schedule.port.dto + +import com.unicorn.server.domain.schedule.vo.ScheduleId + +data class RequestFamilyScheduleNotificationCommand( + val scheduleId: ScheduleId, + val circleId: String, + val memberId: String, +) diff --git a/src/main/kotlin/com/unicorn/server/domain/schedule/port/in/RequestFamilyScheduleNotificationInPort.kt b/src/main/kotlin/com/unicorn/server/domain/schedule/port/in/RequestFamilyScheduleNotificationInPort.kt new file mode 100644 index 0000000..c7366e8 --- /dev/null +++ b/src/main/kotlin/com/unicorn/server/domain/schedule/port/in/RequestFamilyScheduleNotificationInPort.kt @@ -0,0 +1,7 @@ +package com.unicorn.server.domain.schedule.port.`in` + +import com.unicorn.server.domain.schedule.port.dto.RequestFamilyScheduleNotificationCommand + +interface RequestFamilyScheduleNotificationInPort { + fun request(command: RequestFamilyScheduleNotificationCommand) +} diff --git a/src/main/kotlin/com/unicorn/server/domain/schedule/service/FamilyScheduleNotificationService.kt b/src/main/kotlin/com/unicorn/server/domain/schedule/service/FamilyScheduleNotificationService.kt new file mode 100644 index 0000000..0a7d1bf --- /dev/null +++ b/src/main/kotlin/com/unicorn/server/domain/schedule/service/FamilyScheduleNotificationService.kt @@ -0,0 +1,54 @@ +package com.unicorn.server.domain.schedule.service + +import com.unicorn.server.common.exception.BusinessException +import com.unicorn.server.common.port.out.event.EventPublisher +import com.unicorn.server.domain.schedule.event.FamilyScheduleNotificationRequestedEvent +import com.unicorn.server.domain.schedule.exception.ScheduleErrorCode +import com.unicorn.server.domain.schedule.port.`in`.RequestFamilyScheduleNotificationInPort +import com.unicorn.server.domain.schedule.port.dto.RequestFamilyScheduleNotificationCommand +import com.unicorn.server.domain.schedule.port.out.CircleAccessOutPort +import com.unicorn.server.domain.schedule.port.out.ScheduleOutPort +import org.springframework.stereotype.Service +import org.springframework.transaction.annotation.Transactional +import java.time.LocalDate +import java.time.ZoneId +import java.util.UUID + +@Service +@Transactional +class FamilyScheduleNotificationService( + private val scheduleOutPort: ScheduleOutPort, + private val circleAccessOutPort: CircleAccessOutPort, + private val eventPublisher: EventPublisher, +) : RequestFamilyScheduleNotificationInPort { + + override fun request(command: RequestFamilyScheduleNotificationCommand) { + if (!circleAccessOutPort.isMember(command.circleId, command.memberId)) { + throw BusinessException(ScheduleErrorCode.CIRCLE_ACCESS_DENIED) + } + + val schedule = scheduleOutPort.findActiveByIdAndCircleId(command.scheduleId, command.circleId) + ?: throw BusinessException(ScheduleErrorCode.SCHEDULE_NOT_FOUND) + val dDay = schedule.computeDDay(today()) + ?: throw BusinessException(ScheduleErrorCode.FAMILY_SCHEDULE_NOTIFICATION_NOT_AVAILABLE) + + eventPublisher.publish( + FamilyScheduleNotificationRequestedEvent( + requestId = UUID.randomUUID().toString(), + scheduleId = schedule.id.value, + circleId = schedule.circleId, + senderMemberId = command.memberId, + scheduleTitle = schedule.title, + dDay = dDay.toLabel(), + ), + ) + } + + private fun Int.toLabel(): String = if (this == 0) "D-day" else "D-$this" + + private fun today(): LocalDate = LocalDate.now(KST) + + companion object { + private val KST: ZoneId = ZoneId.of("Asia/Seoul") + } +} diff --git a/src/main/kotlin/com/unicorn/server/infrastructure/adapter/in/event/schedule/ScheduleNotificationEventListener.kt b/src/main/kotlin/com/unicorn/server/infrastructure/adapter/in/event/schedule/ScheduleNotificationEventListener.kt index 4c0c9d8..637b505 100644 --- a/src/main/kotlin/com/unicorn/server/infrastructure/adapter/in/event/schedule/ScheduleNotificationEventListener.kt +++ b/src/main/kotlin/com/unicorn/server/infrastructure/adapter/in/event/schedule/ScheduleNotificationEventListener.kt @@ -9,6 +9,7 @@ import com.unicorn.server.domain.notification.event.NotificationRequestedEvent import com.unicorn.server.domain.notification.event.ScheduleCreatedPayload import com.unicorn.server.domain.notification.event.ScheduleConfirmationRequestedPayload import com.unicorn.server.domain.notification.event.ScheduleConfirmedByFamilyPayload +import com.unicorn.server.domain.notification.event.ScheduleFamilyNotificationPayload import com.unicorn.server.domain.notification.event.ScheduleReminderD1Payload import com.unicorn.server.domain.notification.event.ScheduleReminderDDayAllDayPayload import com.unicorn.server.domain.notification.event.ScheduleReminderDDayTimedPayload @@ -18,6 +19,7 @@ import com.unicorn.server.domain.notification.port.`in`.NotificationSettingInPor import com.unicorn.server.domain.schedule.event.ScheduleCreatedEvent import com.unicorn.server.domain.schedule.event.ScheduleConfirmationRequestDueEvent import com.unicorn.server.domain.schedule.event.ScheduleConfirmedEvent +import com.unicorn.server.domain.schedule.event.FamilyScheduleNotificationRequestedEvent import com.unicorn.server.domain.schedule.enums.ScheduleReminderType import com.unicorn.server.domain.schedule.event.ScheduleReminderDueEvent import com.unicorn.server.domain.schedule.port.`in`.ScheduleConfirmationStatusInPort @@ -130,6 +132,34 @@ class ScheduleNotificationEventListener( } } + @EventListener + fun handle(event: FamilyScheduleNotificationRequestedEvent) { + val members = circleMemberInPort.getCircleMembers(event.circleId) + val payload = ScheduleFamilyNotificationPayload( + senderName = members.nicknameOf(event.senderMemberId), + scheduleTitle = event.scheduleTitle, + dDay = event.dDay, + ) + + members + .asSequence() + .filter { it.active && it.memberId != event.senderMemberId } + .filter { notificationSettingInPort.getSetting(it.memberId).isEnabled(NotificationSettingType.FAMILY_SCHEDULE_CHECK) } + .forEach { member -> + val receiverDedupKey = "schedule-family-notification:${event.requestId}:${member.memberId}" + notificationPushTokenInPort.getActiveReceivable(member.memberId).forEach { pushToken -> + eventPublisher.publish( + NotificationRequestedEvent( + channel = NotificationChannel.PUSH, + receiver = pushToken.token, + payload = payload, + dedupKey = "$receiverDedupKey:token:${requireNotNull(pushToken.id).value}", + ), + ) + } + } + } + private fun List.nicknameOf(memberId: String): String = firstOrNull { it.memberId == memberId }?.nickname ?: error("Active circle member not found: memberId=$memberId") diff --git a/src/main/kotlin/com/unicorn/server/infrastructure/adapter/in/web/schedule/ScheduleApiDoc.kt b/src/main/kotlin/com/unicorn/server/infrastructure/adapter/in/web/schedule/ScheduleApiDoc.kt index ea77cc2..4afd863 100644 --- a/src/main/kotlin/com/unicorn/server/infrastructure/adapter/in/web/schedule/ScheduleApiDoc.kt +++ b/src/main/kotlin/com/unicorn/server/infrastructure/adapter/in/web/schedule/ScheduleApiDoc.kt @@ -321,6 +321,33 @@ interface ScheduleApiDoc { @PathVariable confirmationId: Long, ): ApiResponse + @Operation( + summary = "가족에게 전하기", + description = """ + 일정을 써클의 다른 활성 구성원에게 전합니다. + + **권한**: 써클 구성원(MEMBER 이상)만 요청 가능합니다. + **수신 대상**: 요청자를 제외한 활성 구성원입니다. 확인 여부와 무관하게 발송합니다. + **알림 설정**: 가족 일정 확인 알림을 ON으로 설정한 구성원에게만 푸시 요청을 생성합니다. + **발송 횟수**: 제한 없이 요청할 수 있습니다. + """, + ) + @ApiErrorCodeExamples( + ApiErrorCodeExample(codeType = CommonErrorCode::class, code = "UNAUTHORIZED"), + ApiErrorCodeExample(codeType = ScheduleErrorCode::class, code = "FAMILY_SCHEDULE_NOTIFICATION_NOT_AVAILABLE"), + ApiErrorCodeExample(codeType = ScheduleErrorCode::class, code = "CIRCLE_ACCESS_DENIED"), + ApiErrorCodeExample(codeType = ScheduleErrorCode::class, code = "SCHEDULE_NOT_FOUND"), + ) + @ApiSuccessCodeExample(Unit::class) + fun requestFamilyNotification( + @Parameter(hidden = true) + @AuthenticationPrincipal memberId: String, + @Parameter(description = "써클 ID", example = "CC202506010000000001") + @PathVariable circleId: String, + @Parameter(description = "일정 ID", example = "SC202407070000000001") + @PathVariable scheduleId: String, + ): ApiResponse + @Operation( summary = "일정 확인 종류 조회", description = """ diff --git a/src/main/kotlin/com/unicorn/server/infrastructure/adapter/in/web/schedule/ScheduleController.kt b/src/main/kotlin/com/unicorn/server/infrastructure/adapter/in/web/schedule/ScheduleController.kt index cfc1a49..94e4a1a 100644 --- a/src/main/kotlin/com/unicorn/server/infrastructure/adapter/in/web/schedule/ScheduleController.kt +++ b/src/main/kotlin/com/unicorn/server/infrastructure/adapter/in/web/schedule/ScheduleController.kt @@ -7,9 +7,12 @@ import com.unicorn.server.domain.schedule.port.`in`.DeleteScheduleInPort import com.unicorn.server.domain.schedule.port.`in`.GetScheduleDetailInPort import com.unicorn.server.domain.schedule.port.`in`.GetScheduleListInPort import com.unicorn.server.domain.schedule.port.`in`.RegisterConfirmationInPort + +import com.unicorn.server.domain.schedule.port.`in`.RequestFamilyScheduleNotificationInPort import com.unicorn.server.domain.schedule.port.`in`.UpdateScheduleInPort import com.unicorn.server.domain.schedule.port.dto.CreateScheduleCommand import com.unicorn.server.domain.schedule.port.dto.RegisterConfirmationCommand +import com.unicorn.server.domain.schedule.port.dto.RequestFamilyScheduleNotificationCommand import com.unicorn.server.domain.schedule.port.dto.UpdateScheduleCommand import com.unicorn.server.domain.schedule.vo.ScheduleId import com.unicorn.server.infrastructure.adapter.`in`.web.common.dto.ApiResponse @@ -43,6 +46,7 @@ class ScheduleController( private val getScheduleDetailInPort: GetScheduleDetailInPort, private val registerConfirmationInPort: RegisterConfirmationInPort, private val cancelConfirmationInPort: CancelConfirmationInPort, + private val requestFamilyScheduleNotificationInPort: RequestFamilyScheduleNotificationInPort, ) : ScheduleApiDoc { @PostMapping @@ -154,6 +158,22 @@ class ScheduleController( return ApiResponse.success() } + @PostMapping("/{scheduleId}/family-notifications") + override fun requestFamilyNotification( + @AuthenticationPrincipal memberId: String, + @PathVariable circleId: String, + @PathVariable scheduleId: String, + ): ApiResponse { + requestFamilyScheduleNotificationInPort.request( + RequestFamilyScheduleNotificationCommand( + scheduleId = ScheduleId.of(scheduleId), + circleId = circleId, + memberId = memberId, + ), + ) + return ApiResponse.success() + } + @GetMapping("/confirmations") override fun getConfirmationTypes( @AuthenticationPrincipal memberId: String, diff --git a/src/main/resources/db/migration/V1.0.3.7__add_family_schedule_notification_template.sql b/src/main/resources/db/migration/V1.0.3.7__add_family_schedule_notification_template.sql new file mode 100644 index 0000000..76e49cc --- /dev/null +++ b/src/main/resources/db/migration/V1.0.3.7__add_family_schedule_notification_template.sql @@ -0,0 +1,65 @@ +alter table notification_template + drop constraint if exists notification_template_event_type_check; + +alter table notification_template + add constraint notification_template_event_type_check check ( + event_type in ( + 'CIRCLE_JOIN_COMPLETED', + 'SCHEDULE_CREATED', + 'SCHEDULE_DELETED', + 'SCHEDULE_REMINDER_D7', + 'SCHEDULE_REMINDER_D1', + 'SCHEDULE_REMINDER_DDAY_ALL_DAY', + 'SCHEDULE_REMINDER_DDAY_TIMED', + 'SCHEDULE_CONFIRMED_BY_FAMILY', + 'SCHEDULE_CONFIRMATION_REQUESTED', + 'SCHEDULE_FAMILY_NOTIFICATION_REQUESTED' + ) + ); + +alter table notification + drop constraint if exists notification_event_type_check; + +alter table notification + add constraint notification_event_type_check check ( + event_type in ( + 'CIRCLE_JOIN_COMPLETED', + 'SCHEDULE_CREATED', + 'SCHEDULE_DELETED', + 'SCHEDULE_REMINDER_D7', + 'SCHEDULE_REMINDER_D1', + 'SCHEDULE_REMINDER_DDAY_ALL_DAY', + 'SCHEDULE_REMINDER_DDAY_TIMED', + 'SCHEDULE_CONFIRMED_BY_FAMILY', + 'SCHEDULE_CONFIRMATION_REQUESTED', + 'SCHEDULE_FAMILY_NOTIFICATION_REQUESTED' + ) + ); + +alter table notification_inbox_item + drop constraint if exists notification_inbox_item_notification_type_check; + +alter table notification_inbox_item + add constraint notification_inbox_item_notification_type_check check ( + notification_type in ( + 'CIRCLE_JOIN_COMPLETED', + 'SCHEDULE_CREATED', + 'SCHEDULE_DELETED', + 'SCHEDULE_REMINDER_D7', + 'SCHEDULE_REMINDER_D1', + 'SCHEDULE_REMINDER_DDAY_ALL_DAY', + 'SCHEDULE_REMINDER_DDAY_TIMED', + 'SCHEDULE_CONFIRMED_BY_FAMILY', + 'SCHEDULE_CONFIRMATION_REQUESTED', + 'SCHEDULE_FAMILY_NOTIFICATION_REQUESTED' + ) + ); + +insert into notification_template (event_type, title_template, body_template, active, created_at) +values ( + 'SCHEDULE_FAMILY_NOTIFICATION_REQUESTED', + '{sender_name}님이 알림을 보냈어요', + '{schedule_title} · {d_day}, 확인하고 같이 챙겨봐요!', + true, + current_timestamp +); diff --git a/src/test/kotlin/com/unicorn/server/domain/schedule/service/FamilyScheduleNotificationServiceTest.kt b/src/test/kotlin/com/unicorn/server/domain/schedule/service/FamilyScheduleNotificationServiceTest.kt new file mode 100644 index 0000000..3293ef0 --- /dev/null +++ b/src/test/kotlin/com/unicorn/server/domain/schedule/service/FamilyScheduleNotificationServiceTest.kt @@ -0,0 +1,159 @@ +package com.unicorn.server.domain.schedule.service + +import com.unicorn.server.common.domain.Event +import com.unicorn.server.common.exception.BusinessException +import com.unicorn.server.common.port.out.event.EventPublisher +import com.unicorn.server.domain.schedule.Schedule +import com.unicorn.server.domain.schedule.event.FamilyScheduleNotificationRequestedEvent +import com.unicorn.server.domain.schedule.exception.ScheduleErrorCode +import com.unicorn.server.domain.schedule.port.dto.RequestFamilyScheduleNotificationCommand +import com.unicorn.server.domain.schedule.port.dto.SchedulePageCursor +import com.unicorn.server.domain.schedule.port.out.CircleAccessOutPort +import com.unicorn.server.domain.schedule.port.out.ScheduleOutPort +import com.unicorn.server.domain.schedule.vo.ScheduleId +import org.assertj.core.api.Assertions.assertThat +import org.assertj.core.api.Assertions.assertThatThrownBy +import org.junit.jupiter.api.DisplayName +import org.junit.jupiter.api.Test +import java.time.LocalDate +import java.time.LocalDateTime +import java.time.LocalTime + +@DisplayName("FamilyScheduleNotificationService 단위 테스트") +class FamilyScheduleNotificationServiceTest { + private val scheduleOutPort = FakeScheduleOutPort() + private val circleAccessOutPort = FakeCircleAccessOutPort() + private val eventPublisher = RecordingEventPublisher() + private val service = FamilyScheduleNotificationService( + scheduleOutPort, + circleAccessOutPort, + eventPublisher, + ) + + @Test + @DisplayName("다가오는 일정에 가족에게 전하기를 요청하면 가족 일정 알림 이벤트를 발행한다") + fun request_withUpcomingSchedule_publishesFamilyScheduleNotificationEvent() { + circleAccessOutPort.seedMember(CIRCLE_ID, MEMBER_ID) + scheduleOutPort.seed(schedule(startDate = LocalDate.now().plusDays(3))) + + service.request(command()) + + val event = eventPublisher.events.filterIsInstance().single() + assertThat(event.requestId).isNotBlank() + assertThat(event.scheduleId).isEqualTo(SCHEDULE_ID.value) + assertThat(event.circleId).isEqualTo(CIRCLE_ID) + assertThat(event.senderMemberId).isEqualTo(MEMBER_ID) + assertThat(event.scheduleTitle).isEqualTo("제주도 여행") + assertThat(event.dDay).isEqualTo("D-3") + } + + @Test + @DisplayName("시작일이 지난 일정에 가족에게 전하기를 요청하면 사용할 수 없다는 예외가 발생한다") + fun request_withStartedSchedule_throwsNotAvailable() { + circleAccessOutPort.seedMember(CIRCLE_ID, MEMBER_ID) + scheduleOutPort.seed(schedule(startDate = LocalDate.now().minusDays(1))) + + assertThatThrownBy { service.request(command()) } + .isInstanceOf(BusinessException::class.java) + .extracting { (it as BusinessException).errorCode } + .isEqualTo(ScheduleErrorCode.FAMILY_SCHEDULE_NOTIFICATION_NOT_AVAILABLE) + } + + private fun command() = RequestFamilyScheduleNotificationCommand( + scheduleId = SCHEDULE_ID, + circleId = CIRCLE_ID, + memberId = MEMBER_ID, + ) + + private fun schedule(startDate: LocalDate): Schedule = Schedule.reconstitute( + id = SCHEDULE_ID, + circleId = CIRCLE_ID, + title = "제주도 여행", + startDate = startDate, + endDate = startDate, + startTime = null, + endTime = null, + needConfirm = false, + memo = null, + createdBy = "author", + updatedBy = "author", + createdAt = LocalDateTime.now().minusDays(1), + updatedAt = LocalDateTime.now().minusDays(1), + isDeleted = false, + ) + + private class FakeScheduleOutPort : ScheduleOutPort { + private val schedules = linkedMapOf() + + fun seed(schedule: Schedule) { + schedules[schedule.id] = schedule + } + + override fun save(schedule: Schedule): Schedule = schedule + + override fun findById(scheduleId: ScheduleId): Schedule? = schedules[scheduleId] + + override fun findActiveByIdAndCircleId(scheduleId: ScheduleId, circleId: String): Schedule? = + schedules[scheduleId]?.takeIf { it.circleId == circleId && !it.isDeleted } + + override fun findActiveByCircleId( + circleId: String, + today: LocalDate, + cursor: SchedulePageCursor?, + size: Int, + ): List = emptyList() + + override fun findActiveByStartDateAndCreatedBefore( + startDate: LocalDate, + createdBefore: LocalDateTime, + ): List = emptyList() + + override fun findActiveAllDayByStartDateAndCreatedBefore( + startDate: LocalDate, + createdBefore: LocalDateTime, + ): List = emptyList() + + override fun findActiveTimedByStartAtAndCreatedBefore( + startDate: LocalDate, + startTime: LocalTime, + createdBefore: LocalDateTime, + ): List = emptyList() + + override fun findActiveConfirmationRequiredCreatedBetween( + createdFrom: LocalDateTime, + createdBefore: LocalDateTime, + ): List = emptyList() + + override fun findUpcomingByCircleId(circleId: String, today: LocalDate, limit: Int): List = emptyList() + + override fun countActiveByCircleId(circleId: String): Long = 0L + } + + private class FakeCircleAccessOutPort : CircleAccessOutPort { + private val members = mutableSetOf>() + + fun seedMember(circleId: String, memberId: String) { + members += circleId to memberId + } + + override fun existsById(circleId: String): Boolean = true + + override fun isMember(circleId: String, memberId: String): Boolean = circleId to memberId in members + + override fun isInitiator(circleId: String, memberId: String): Boolean = false + } + + private class RecordingEventPublisher : EventPublisher { + val events = mutableListOf() + + override fun publish(event: Event) { + events += event + } + } + + companion object { + private const val CIRCLE_ID = "CC202506010000000001" + private const val MEMBER_ID = "member-1" + private val SCHEDULE_ID = ScheduleId.of("SC202407070000000001") + } +} diff --git a/src/test/kotlin/com/unicorn/server/infrastructure/adapter/in/event/schedule/ScheduleNotificationEventListenerTest.kt b/src/test/kotlin/com/unicorn/server/infrastructure/adapter/in/event/schedule/ScheduleNotificationEventListenerTest.kt index 8497e95..fb422c3 100644 --- a/src/test/kotlin/com/unicorn/server/infrastructure/adapter/in/event/schedule/ScheduleNotificationEventListenerTest.kt +++ b/src/test/kotlin/com/unicorn/server/infrastructure/adapter/in/event/schedule/ScheduleNotificationEventListenerTest.kt @@ -21,6 +21,7 @@ import com.unicorn.server.domain.notification.vo.DevicePushTokenId import com.unicorn.server.domain.schedule.event.ScheduleCreatedEvent import com.unicorn.server.domain.schedule.event.ScheduleConfirmationRequestDueEvent import com.unicorn.server.domain.schedule.event.ScheduleConfirmedEvent +import com.unicorn.server.domain.schedule.event.FamilyScheduleNotificationRequestedEvent import com.unicorn.server.domain.schedule.enums.ScheduleReminderType import com.unicorn.server.domain.schedule.event.ScheduleReminderDueEvent import com.unicorn.server.domain.schedule.port.`in`.ScheduleConfirmationStatusInPort @@ -193,6 +194,59 @@ class ScheduleNotificationEventListenerTest { assertThat(event.dedupKey).isEqualTo("schedule-confirmation-request:SC1:unconfirmed:token:1") } + @Test + @DisplayName("가족에게 전하기는 발신자를 제외한 설정 ON 활성 구성원의 모든 토큰에 알림을 요청한다") + fun handle_familyScheduleNotification_requestsPushForOtherEnabledMembers() { + val eventPublisher = RecordingEventPublisher() + val listener = ScheduleNotificationEventListener( + FakeCircleMemberInPort( + listOf( + CircleMemberDto("sender", "보낸사람", "MEMBER", true), + CircleMemberDto("receiver", "받는사람", "MEMBER", true), + CircleMemberDto("disabled", "미수신", "MEMBER", true), + ), + ), + FakeNotificationPushTokenInPort( + mapOf( + "sender" to listOf(pushToken(1, "sender-token")), + "receiver" to listOf(pushToken(2, "receiver-token-1"), pushToken(3, "receiver-token-2")), + "disabled" to listOf(pushToken(4, "disabled-token")), + ), + ), + FakeNotificationSettingInPort(disabledMembers = setOf("disabled")), + FakeScheduleConfirmationStatusInPort(), + eventPublisher, + ) + + listener.handle( + FamilyScheduleNotificationRequestedEvent( + requestId = "request-1", + scheduleId = "SC1", + circleId = "circle-1", + senderMemberId = "sender", + scheduleTitle = "제주도 여행", + dDay = "D-3", + ), + ) + + val events = eventPublisher.events.filterIsInstance() + assertThat(events).extracting { it.receiver } + .containsExactlyInAnyOrder("receiver-token-1", "receiver-token-2") + assertThat(events).allSatisfy { event -> + assertThat(event.payload.eventType) + .isEqualTo(NotificationEventType.SCHEDULE_FAMILY_NOTIFICATION_REQUESTED) + assertThat(event.payload.toVariables()) + .containsEntry("sender_name", "보낸사람") + .containsEntry("schedule_title", "제주도 여행") + .containsEntry("d_day", "D-3") + } + assertThat(events.map { it.dedupKey }) + .containsExactlyInAnyOrder( + "schedule-family-notification:request-1:receiver:token:2", + "schedule-family-notification:request-1:receiver:token:3", + ) + } + private fun pushToken(id: Long, token: String): DevicePushToken { val now = LocalDateTime.now() return DevicePushToken.reconstitute( diff --git a/src/test/kotlin/com/unicorn/server/infrastructure/adapter/in/web/schedule/ScheduleControllerTest.kt b/src/test/kotlin/com/unicorn/server/infrastructure/adapter/in/web/schedule/ScheduleControllerTest.kt index d95428f..ca20bde 100644 --- a/src/test/kotlin/com/unicorn/server/infrastructure/adapter/in/web/schedule/ScheduleControllerTest.kt +++ b/src/test/kotlin/com/unicorn/server/infrastructure/adapter/in/web/schedule/ScheduleControllerTest.kt @@ -450,6 +450,23 @@ class ScheduleControllerTest( .andExpect(jsonPath("$.errorCode").value("S400_11")) } + @Test + @DisplayName("다가오는 일정에 가족에게 전하기를 요청하면 성공한다") + fun requestFamilyNotification_withUpcomingSchedule_returnsSuccess() { + val token = memberToken(AUTHOR_ID) + val scheduleId = createSchedule( + token, + createRequestJson(startDate = today.plusDays(3), needConfirm = false), + ) + + mockMvc.perform( + post("$BASE_URL/$scheduleId/family-notifications") + .header(HttpHeaders.AUTHORIZATION, "Bearer $token"), + ) + .andExpect(status().isOk) + .andExpect(jsonPath("$.success").value(true)) + } + private fun memberToken(memberId: String): String = jwtProvider.issue(memberId, Role.MEMBER).accessToken