diff --git a/src/main/kotlin/com/unicorn/server/domain/home/port/dto/HomeMemberDto.kt b/src/main/kotlin/com/unicorn/server/domain/home/port/dto/HomeMemberDto.kt index 5f8ff6d..0e3cbfb 100644 --- a/src/main/kotlin/com/unicorn/server/domain/home/port/dto/HomeMemberDto.kt +++ b/src/main/kotlin/com/unicorn/server/domain/home/port/dto/HomeMemberDto.kt @@ -1,5 +1,6 @@ package com.unicorn.server.domain.home.port.dto +import com.fasterxml.jackson.annotation.JsonProperty import com.unicorn.server.domain.member.enums.AvatarColor data class HomeMemberDto( @@ -7,6 +8,7 @@ data class HomeMemberDto( val nickname: String, val avatarColor: AvatarColor, val profileImageKey: String?, + @get:JsonProperty("isMe") val isMe: Boolean, val role: String, ) diff --git a/src/main/kotlin/com/unicorn/server/domain/home/port/dto/HomeView.kt b/src/main/kotlin/com/unicorn/server/domain/home/port/dto/HomeView.kt index 07c7e2d..7ec05fc 100644 --- a/src/main/kotlin/com/unicorn/server/domain/home/port/dto/HomeView.kt +++ b/src/main/kotlin/com/unicorn/server/domain/home/port/dto/HomeView.kt @@ -1,12 +1,12 @@ package com.unicorn.server.domain.home.port.dto -import com.unicorn.server.domain.schedule.port.dto.ScheduleView +import com.unicorn.server.domain.schedule.port.dto.ScheduleSummaryResult data class HomeView( val circle: HomeCircleDto, val members: List, val canInvite: Boolean, - val mainSchedule: ScheduleView?, - val schedules: List, + val mainSchedule: ScheduleSummaryResult?, + val schedules: List, val totalScheduleCount: Long, ) diff --git a/src/main/kotlin/com/unicorn/server/domain/home/service/HomeQueryService.kt b/src/main/kotlin/com/unicorn/server/domain/home/service/HomeQueryService.kt index c35175d..24e130d 100644 --- a/src/main/kotlin/com/unicorn/server/domain/home/service/HomeQueryService.kt +++ b/src/main/kotlin/com/unicorn/server/domain/home/service/HomeQueryService.kt @@ -31,13 +31,16 @@ class HomeQueryService( val visibleMembers = getVisibleMembers(circleId, requesterId) val today = LocalDate.now() + val circleIdVo = CircleId.of(circleId) + // 메인(가장 임박한 1건) + 나머지 최대 3건을 한 번의 조회로 가져와 분리한다. + val upcomingSchedules = scheduleQueryInPort.findUpcomingSchedulesByCircleId(circleIdVo, today, MAIN_AND_UPCOMING_LIMIT) return HomeView( circle = HomeCircleDto(circle.id, circle.name, circle.ownerId), members = visibleMembers, canInvite = visibleMembers.size == 1, - mainSchedule = scheduleQueryInPort.findMainScheduleByCircleId(CircleId.of(circleId), today), - schedules = scheduleQueryInPort.findUpcomingSchedulesByCircleId(CircleId.of(circleId), today, 3), - totalScheduleCount = scheduleQueryInPort.countByCircleId(CircleId.of(circleId)), + mainSchedule = upcomingSchedules.firstOrNull(), + schedules = upcomingSchedules.drop(1), + totalScheduleCount = scheduleQueryInPort.countByCircleId(circleIdVo), ) } @@ -64,4 +67,9 @@ class HomeQueryService( role = membership.role, ) } + + companion object { + // 메인 일정 1건 + 홈에 노출할 나머지 일정 3건 + private const val MAIN_AND_UPCOMING_LIMIT = 4 + } } diff --git a/src/main/kotlin/com/unicorn/server/domain/schedule/enums/ScheduleReminderType.kt b/src/main/kotlin/com/unicorn/server/domain/schedule/enums/ScheduleReminderType.kt new file mode 100644 index 0000000..f33640e --- /dev/null +++ b/src/main/kotlin/com/unicorn/server/domain/schedule/enums/ScheduleReminderType.kt @@ -0,0 +1,8 @@ +package com.unicorn.server.domain.schedule.enums + +enum class ScheduleReminderType { + D7, + D1, + DDAY_ALL_DAY, + DDAY_TIMED, +} diff --git a/src/main/kotlin/com/unicorn/server/domain/schedule/enums/UrgencyLevel.kt b/src/main/kotlin/com/unicorn/server/domain/schedule/enums/UrgencyLevel.kt new file mode 100644 index 0000000..52b7e32 --- /dev/null +++ b/src/main/kotlin/com/unicorn/server/domain/schedule/enums/UrgencyLevel.kt @@ -0,0 +1,17 @@ +package com.unicorn.server.domain.schedule.enums + +// 긴급도가 높은 레벨부터 선언한다. maxDayThreshold는 "dDay < maxDayThreshold"를 만족할 때 해당 레벨로 판단하며, +// null이면 상한이 없는 catch-all 레벨을 의미한다. 기준 변경/레벨 추가는 이 enum 수정만으로 반영된다. +enum class UrgencyLevel(val maxDayThreshold: Int?) { + URGENT(10), + NORMAL(null), + ; + + companion object { + fun from(dDay: Int?): UrgencyLevel { + if (dDay == null) return NORMAL + return entries.firstOrNull { it.maxDayThreshold != null && dDay < it.maxDayThreshold } + ?: NORMAL + } + } +} diff --git a/src/main/kotlin/com/unicorn/server/domain/schedule/event/ScheduleConfirmationRequestDueEvent.kt b/src/main/kotlin/com/unicorn/server/domain/schedule/event/ScheduleConfirmationRequestDueEvent.kt new file mode 100644 index 0000000..e7a5620 --- /dev/null +++ b/src/main/kotlin/com/unicorn/server/domain/schedule/event/ScheduleConfirmationRequestDueEvent.kt @@ -0,0 +1,10 @@ +package com.unicorn.server.domain.schedule.event + +import com.unicorn.server.common.domain.Event + +class ScheduleConfirmationRequestDueEvent( + val scheduleId: String, + val circleId: String, + val scheduleCreatorMemberId: String, + val scheduleTitle: String, +) : Event() diff --git a/src/main/kotlin/com/unicorn/server/domain/schedule/event/ScheduleConfirmedEvent.kt b/src/main/kotlin/com/unicorn/server/domain/schedule/event/ScheduleConfirmedEvent.kt new file mode 100644 index 0000000..8268fa6 --- /dev/null +++ b/src/main/kotlin/com/unicorn/server/domain/schedule/event/ScheduleConfirmedEvent.kt @@ -0,0 +1,11 @@ +package com.unicorn.server.domain.schedule.event + +import com.unicorn.server.common.domain.Event + +class ScheduleConfirmedEvent( + val scheduleId: String, + val circleId: String, + val scheduleCreatorMemberId: String, + val confirmerMemberId: String, + val scheduleTitle: String, +) : Event() diff --git a/src/main/kotlin/com/unicorn/server/domain/schedule/event/ScheduleCreatedEvent.kt b/src/main/kotlin/com/unicorn/server/domain/schedule/event/ScheduleCreatedEvent.kt new file mode 100644 index 0000000..5f9c5a8 --- /dev/null +++ b/src/main/kotlin/com/unicorn/server/domain/schedule/event/ScheduleCreatedEvent.kt @@ -0,0 +1,10 @@ +package com.unicorn.server.domain.schedule.event + +import com.unicorn.server.common.domain.Event + +class ScheduleCreatedEvent( + val scheduleId: String, + val circleId: String, + val creatorMemberId: String, + val scheduleTitle: String, +) : Event() diff --git a/src/main/kotlin/com/unicorn/server/domain/schedule/event/ScheduleDeletedEvent.kt b/src/main/kotlin/com/unicorn/server/domain/schedule/event/ScheduleDeletedEvent.kt new file mode 100644 index 0000000..dd12dee --- /dev/null +++ b/src/main/kotlin/com/unicorn/server/domain/schedule/event/ScheduleDeletedEvent.kt @@ -0,0 +1,10 @@ +package com.unicorn.server.domain.schedule.event + +import com.unicorn.server.common.domain.Event + +class ScheduleDeletedEvent( + val scheduleId: String, + val circleId: String, + val deletedByMemberId: String, + val scheduleTitle: String, +) : Event() diff --git a/src/main/kotlin/com/unicorn/server/domain/schedule/event/ScheduleReminderDueEvent.kt b/src/main/kotlin/com/unicorn/server/domain/schedule/event/ScheduleReminderDueEvent.kt new file mode 100644 index 0000000..e97d7c3 --- /dev/null +++ b/src/main/kotlin/com/unicorn/server/domain/schedule/event/ScheduleReminderDueEvent.kt @@ -0,0 +1,13 @@ +package com.unicorn.server.domain.schedule.event + +import com.unicorn.server.common.domain.Event +import com.unicorn.server.domain.schedule.enums.ScheduleReminderType +import java.time.LocalTime + +class ScheduleReminderDueEvent( + val reminderType: ScheduleReminderType, + val scheduleId: String, + val circleId: String, + val scheduleTitle: String, + val startTime: LocalTime?, +) : Event() diff --git a/src/main/kotlin/com/unicorn/server/domain/schedule/port/dto/ScheduleDetailResult.kt b/src/main/kotlin/com/unicorn/server/domain/schedule/port/dto/ScheduleDetailResult.kt index da0adc1..0f3e95c 100644 --- a/src/main/kotlin/com/unicorn/server/domain/schedule/port/dto/ScheduleDetailResult.kt +++ b/src/main/kotlin/com/unicorn/server/domain/schedule/port/dto/ScheduleDetailResult.kt @@ -1,6 +1,7 @@ package com.unicorn.server.domain.schedule.port.dto import com.unicorn.server.domain.schedule.enums.ScheduleStatus +import com.unicorn.server.domain.schedule.enums.UrgencyLevel import com.unicorn.server.domain.schedule.vo.ScheduleId import java.time.LocalDate import java.time.LocalDateTime @@ -18,6 +19,7 @@ data class ScheduleDetailResult( val memo: String?, val status: ScheduleStatus, val dDay: Int?, + val urgencyLevel: UrgencyLevel, val progressRate: Int, val confirmations: List, val myConfirmation: MyConfirmationInfo?, diff --git a/src/main/kotlin/com/unicorn/server/domain/schedule/port/dto/ScheduleSummaryResult.kt b/src/main/kotlin/com/unicorn/server/domain/schedule/port/dto/ScheduleSummaryResult.kt index 23c60e3..d71304d 100644 --- a/src/main/kotlin/com/unicorn/server/domain/schedule/port/dto/ScheduleSummaryResult.kt +++ b/src/main/kotlin/com/unicorn/server/domain/schedule/port/dto/ScheduleSummaryResult.kt @@ -1,6 +1,7 @@ package com.unicorn.server.domain.schedule.port.dto import com.unicorn.server.domain.schedule.enums.ScheduleStatus +import com.unicorn.server.domain.schedule.enums.UrgencyLevel import com.unicorn.server.domain.schedule.vo.ScheduleId import java.time.LocalDate import java.time.LocalTime @@ -16,5 +17,6 @@ data class ScheduleSummaryResult( val needConfirm: Boolean, val status: ScheduleStatus, val dDay: Int?, + val urgencyLevel: UrgencyLevel, val progressRate: Int, ) diff --git a/src/main/kotlin/com/unicorn/server/domain/schedule/port/dto/ScheduleView.kt b/src/main/kotlin/com/unicorn/server/domain/schedule/port/dto/ScheduleView.kt deleted file mode 100644 index 187a155..0000000 --- a/src/main/kotlin/com/unicorn/server/domain/schedule/port/dto/ScheduleView.kt +++ /dev/null @@ -1,12 +0,0 @@ -package com.unicorn.server.domain.schedule.port.dto - -import java.time.LocalDate - -data class ScheduleView( - val id: String, - val name: String, - val targetDate: LocalDate, - val dDay: Long, - val urgencyLevel: String, - val preparationProgress: Double, -) diff --git a/src/main/kotlin/com/unicorn/server/domain/schedule/port/in/GetSchedulesForCircleInPort.kt b/src/main/kotlin/com/unicorn/server/domain/schedule/port/in/GetSchedulesForCircleInPort.kt index 2a486c7..a413a32 100644 --- a/src/main/kotlin/com/unicorn/server/domain/schedule/port/in/GetSchedulesForCircleInPort.kt +++ b/src/main/kotlin/com/unicorn/server/domain/schedule/port/in/GetSchedulesForCircleInPort.kt @@ -1,11 +1,11 @@ package com.unicorn.server.domain.schedule.port.`in` import com.unicorn.server.domain.circle.vo.CircleId -import com.unicorn.server.domain.schedule.port.dto.ScheduleView +import com.unicorn.server.domain.schedule.port.dto.ScheduleSummaryResult import java.time.LocalDate interface GetSchedulesForCircleInPort { - fun findMainScheduleByCircleId(circleId: CircleId, today: LocalDate): ScheduleView? - fun findUpcomingSchedulesByCircleId(circleId: CircleId, today: LocalDate, limit: Int): List + // 아직 종료되지 않은(예정 + 진행 중) 일정을 임박한 순서로 최대 limit개 반환한다. 첫 항목이 가장 임박한 일정이다. + fun findUpcomingSchedulesByCircleId(circleId: CircleId, today: LocalDate, limit: Int): List fun countByCircleId(circleId: CircleId): Long } diff --git a/src/main/kotlin/com/unicorn/server/domain/schedule/port/in/ScheduleConfirmationRequestInPort.kt b/src/main/kotlin/com/unicorn/server/domain/schedule/port/in/ScheduleConfirmationRequestInPort.kt new file mode 100644 index 0000000..639bcf0 --- /dev/null +++ b/src/main/kotlin/com/unicorn/server/domain/schedule/port/in/ScheduleConfirmationRequestInPort.kt @@ -0,0 +1,7 @@ +package com.unicorn.server.domain.schedule.port.`in` + +import java.time.LocalDateTime + +interface ScheduleConfirmationRequestInPort { + fun dispatchDue(now: LocalDateTime) +} diff --git a/src/main/kotlin/com/unicorn/server/domain/schedule/port/in/ScheduleConfirmationStatusInPort.kt b/src/main/kotlin/com/unicorn/server/domain/schedule/port/in/ScheduleConfirmationStatusInPort.kt new file mode 100644 index 0000000..438d59b --- /dev/null +++ b/src/main/kotlin/com/unicorn/server/domain/schedule/port/in/ScheduleConfirmationStatusInPort.kt @@ -0,0 +1,7 @@ +package com.unicorn.server.domain.schedule.port.`in` + +import com.unicorn.server.domain.schedule.vo.ScheduleId + +interface ScheduleConfirmationStatusInPort { + fun hasConfirmed(scheduleId: ScheduleId, memberId: String): Boolean +} diff --git a/src/main/kotlin/com/unicorn/server/domain/schedule/port/in/ScheduleReminderInPort.kt b/src/main/kotlin/com/unicorn/server/domain/schedule/port/in/ScheduleReminderInPort.kt new file mode 100644 index 0000000..4950929 --- /dev/null +++ b/src/main/kotlin/com/unicorn/server/domain/schedule/port/in/ScheduleReminderInPort.kt @@ -0,0 +1,9 @@ +package com.unicorn.server.domain.schedule.port.`in` + +import java.time.LocalDateTime + +interface ScheduleReminderInPort { + fun dispatchDaily(now: LocalDateTime) + + fun dispatchTimed(now: LocalDateTime) +} diff --git a/src/main/kotlin/com/unicorn/server/domain/schedule/port/out/ScheduleOutPort.kt b/src/main/kotlin/com/unicorn/server/domain/schedule/port/out/ScheduleOutPort.kt index d971c06..f537a70 100644 --- a/src/main/kotlin/com/unicorn/server/domain/schedule/port/out/ScheduleOutPort.kt +++ b/src/main/kotlin/com/unicorn/server/domain/schedule/port/out/ScheduleOutPort.kt @@ -3,6 +3,9 @@ package com.unicorn.server.domain.schedule.port.out import com.unicorn.server.domain.schedule.Schedule import com.unicorn.server.domain.schedule.port.dto.SchedulePageCursor import com.unicorn.server.domain.schedule.vo.ScheduleId +import java.time.LocalDate +import java.time.LocalDateTime +import java.time.LocalTime interface ScheduleOutPort { fun save(schedule: Schedule): Schedule @@ -13,7 +16,37 @@ interface ScheduleOutPort { fun findActiveByCircleId( circleId: String, + today: LocalDate, cursor: SchedulePageCursor?, size: Int, ): List + + fun findActiveByStartDateAndCreatedBefore( + startDate: LocalDate, + createdBefore: LocalDateTime, + ): List + + fun findActiveAllDayByStartDateAndCreatedBefore( + startDate: LocalDate, + createdBefore: LocalDateTime, + ): List + + fun findActiveTimedByStartAtAndCreatedBefore( + startDate: LocalDate, + startTime: LocalTime, + createdBefore: LocalDateTime, + ): List + + fun findActiveConfirmationRequiredCreatedBetween( + createdFrom: LocalDateTime, + createdBefore: LocalDateTime, + ): List + + fun findUpcomingByCircleId( + circleId: String, + today: LocalDate, + limit: Int, + ): List + + fun countActiveByCircleId(circleId: String): Long } diff --git a/src/main/kotlin/com/unicorn/server/domain/schedule/service/ScheduleCommandService.kt b/src/main/kotlin/com/unicorn/server/domain/schedule/service/ScheduleCommandService.kt index 707ed96..6c23318 100644 --- a/src/main/kotlin/com/unicorn/server/domain/schedule/service/ScheduleCommandService.kt +++ b/src/main/kotlin/com/unicorn/server/domain/schedule/service/ScheduleCommandService.kt @@ -1,7 +1,10 @@ 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.Schedule +import com.unicorn.server.domain.schedule.event.ScheduleCreatedEvent +import com.unicorn.server.domain.schedule.event.ScheduleDeletedEvent import com.unicorn.server.domain.schedule.exception.ScheduleErrorCode import com.unicorn.server.domain.schedule.port.`in`.CreateScheduleInPort import com.unicorn.server.domain.schedule.port.`in`.DeleteScheduleInPort @@ -23,6 +26,7 @@ class ScheduleCommandService( private val scheduleConfirmationOutPort: ScheduleConfirmationOutPort, private val circleAccessOutPort: CircleAccessOutPort, private val scheduleIdGenerator: ScheduleIdGenerator, + private val eventPublisher: EventPublisher, ) : CreateScheduleInPort, UpdateScheduleInPort, DeleteScheduleInPort { override fun create(command: CreateScheduleCommand): ScheduleId { @@ -47,7 +51,16 @@ class ScheduleCommandService( createdBy = command.memberId, ) - return scheduleOutPort.save(schedule).id + val savedSchedule = scheduleOutPort.save(schedule) + eventPublisher.publish( + ScheduleCreatedEvent( + scheduleId = savedSchedule.id.value, + circleId = savedSchedule.circleId, + creatorMemberId = savedSchedule.createdBy, + scheduleTitle = savedSchedule.title, + ), + ) + return savedSchedule.id } override fun update(command: UpdateScheduleCommand) { @@ -97,6 +110,14 @@ class ScheduleCommandService( schedule.delete(memberId) scheduleOutPort.save(schedule) scheduleConfirmationOutPort.deleteAllByScheduleId(scheduleId) + eventPublisher.publish( + ScheduleDeletedEvent( + scheduleId = schedule.id.value, + circleId = schedule.circleId, + deletedByMemberId = memberId, + scheduleTitle = schedule.title, + ), + ) } private fun canModify(createdBy: String, circleId: String, memberId: String): Boolean = diff --git a/src/main/kotlin/com/unicorn/server/domain/schedule/service/ScheduleConfirmationRequestService.kt b/src/main/kotlin/com/unicorn/server/domain/schedule/service/ScheduleConfirmationRequestService.kt new file mode 100644 index 0000000..9002a09 --- /dev/null +++ b/src/main/kotlin/com/unicorn/server/domain/schedule/service/ScheduleConfirmationRequestService.kt @@ -0,0 +1,42 @@ +package com.unicorn.server.domain.schedule.service + +import com.unicorn.server.common.port.out.event.EventPublisher +import com.unicorn.server.domain.schedule.Schedule +import com.unicorn.server.domain.schedule.event.ScheduleConfirmationRequestDueEvent +import com.unicorn.server.domain.schedule.port.`in`.ScheduleConfirmationRequestInPort +import com.unicorn.server.domain.schedule.port.out.ScheduleOutPort +import org.springframework.stereotype.Service +import org.springframework.transaction.annotation.Transactional +import java.time.LocalDateTime +import java.time.LocalTime + +@Service +@Transactional +class ScheduleConfirmationRequestService( + private val scheduleOutPort: ScheduleOutPort, + private val eventPublisher: EventPublisher, +) : ScheduleConfirmationRequestInPort { + + override fun dispatchDue(now: LocalDateTime) { + val windowEnd = now.withSecond(0).withNano(0).minusHours(24) + val windowStart = windowEnd.minusMinutes(1) + scheduleOutPort.findActiveConfirmationRequiredCreatedBetween(windowStart, windowEnd) + .filter { it.startsAt().isAfterOrEqual(it.createdAt.plusHours(24)) } + .forEach { schedule -> + eventPublisher.publish( + ScheduleConfirmationRequestDueEvent( + scheduleId = schedule.id.value, + circleId = schedule.circleId, + scheduleCreatorMemberId = schedule.createdBy, + scheduleTitle = schedule.title, + ), + ) + } + } + + private fun Schedule.startsAt(): LocalDateTime = + LocalDateTime.of(startDate, startTime ?: LocalTime.MIDNIGHT) + + private fun LocalDateTime.isAfterOrEqual(other: LocalDateTime): Boolean = + isAfter(other) || isEqual(other) +} diff --git a/src/main/kotlin/com/unicorn/server/domain/schedule/service/ScheduleConfirmationService.kt b/src/main/kotlin/com/unicorn/server/domain/schedule/service/ScheduleConfirmationService.kt index c10331e..3540d70 100644 --- a/src/main/kotlin/com/unicorn/server/domain/schedule/service/ScheduleConfirmationService.kt +++ b/src/main/kotlin/com/unicorn/server/domain/schedule/service/ScheduleConfirmationService.kt @@ -1,14 +1,18 @@ 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.ScheduleConfirmation import com.unicorn.server.domain.schedule.enums.ConfirmationType +import com.unicorn.server.domain.schedule.event.ScheduleConfirmedEvent import com.unicorn.server.domain.schedule.exception.ScheduleErrorCode import com.unicorn.server.domain.schedule.port.`in`.RegisterConfirmationInPort +import com.unicorn.server.domain.schedule.port.`in`.ScheduleConfirmationStatusInPort import com.unicorn.server.domain.schedule.port.dto.RegisterConfirmationCommand import com.unicorn.server.domain.schedule.port.out.CircleAccessOutPort import com.unicorn.server.domain.schedule.port.out.ScheduleConfirmationOutPort import com.unicorn.server.domain.schedule.port.out.ScheduleOutPort +import com.unicorn.server.domain.schedule.vo.ScheduleId import org.springframework.stereotype.Service import org.springframework.transaction.annotation.Transactional @@ -18,7 +22,8 @@ class ScheduleConfirmationService( private val scheduleOutPort: ScheduleOutPort, private val scheduleConfirmationOutPort: ScheduleConfirmationOutPort, private val circleAccessOutPort: CircleAccessOutPort, -) : RegisterConfirmationInPort { + private val eventPublisher: EventPublisher, +) : RegisterConfirmationInPort, ScheduleConfirmationStatusInPort { override fun register(command: RegisterConfirmationCommand): ConfirmationType { if (!command.confirmationType.available) { @@ -35,6 +40,7 @@ class ScheduleConfirmationService( } val existing = scheduleConfirmationOutPort.findByScheduleIdAndMemberId(command.scheduleId, command.memberId) + val wasConfirmed = existing?.confirmationType == ConfirmationType.CONFIRMED if (existing == null) { scheduleConfirmationOutPort.save( ScheduleConfirmation.create( @@ -48,7 +54,22 @@ class ScheduleConfirmationService( existing.changeType(command.confirmationType, command.memberId) scheduleConfirmationOutPort.save(existing) } + if (command.confirmationType == ConfirmationType.CONFIRMED && !wasConfirmed) { + eventPublisher.publish( + ScheduleConfirmedEvent( + scheduleId = schedule.id.value, + circleId = schedule.circleId, + scheduleCreatorMemberId = schedule.createdBy, + confirmerMemberId = command.memberId, + scheduleTitle = schedule.title, + ), + ) + } return command.confirmationType } + + override fun hasConfirmed(scheduleId: ScheduleId, memberId: String): Boolean = + scheduleConfirmationOutPort.findByScheduleIdAndMemberId(scheduleId, memberId) + ?.confirmationType == ConfirmationType.CONFIRMED } diff --git a/src/main/kotlin/com/unicorn/server/domain/schedule/service/ScheduleQueryService.kt b/src/main/kotlin/com/unicorn/server/domain/schedule/service/ScheduleQueryService.kt index 8053118..3342583 100644 --- a/src/main/kotlin/com/unicorn/server/domain/schedule/service/ScheduleQueryService.kt +++ b/src/main/kotlin/com/unicorn/server/domain/schedule/service/ScheduleQueryService.kt @@ -2,10 +2,13 @@ package com.unicorn.server.domain.schedule.service import com.unicorn.server.common.exception.BusinessException import com.unicorn.server.common.exception.CommonErrorCode +import com.unicorn.server.domain.circle.vo.CircleId import com.unicorn.server.domain.schedule.Schedule +import com.unicorn.server.domain.schedule.enums.UrgencyLevel import com.unicorn.server.domain.schedule.exception.ScheduleErrorCode 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`.GetSchedulesForCircleInPort import com.unicorn.server.domain.schedule.port.dto.MyConfirmationInfo import com.unicorn.server.domain.schedule.port.dto.ScheduleDetailResult import com.unicorn.server.domain.schedule.port.dto.ScheduleListResult @@ -27,7 +30,7 @@ class ScheduleQueryService( private val scheduleOutPort: ScheduleOutPort, private val scheduleConfirmationOutPort: ScheduleConfirmationOutPort, private val circleAccessOutPort: CircleAccessOutPort, -) : GetScheduleListInPort, GetScheduleDetailInPort { +) : GetScheduleListInPort, GetScheduleDetailInPort, GetSchedulesForCircleInPort { override fun getList( circleId: String, @@ -44,7 +47,7 @@ class ScheduleQueryService( } val pageSize = size val pageCursor = cursor?.takeIf { it.isNotBlank() }?.let { decodeCursor(it) } - val results = scheduleOutPort.findActiveByCircleId(circleId, pageCursor, pageSize + 1) + val results = scheduleOutPort.findActiveByCircleId(circleId, today(), pageCursor, pageSize + 1) val hasNext = results.size > pageSize val schedules = results.take(pageSize) val nextCursor = if (hasNext && schedules.isNotEmpty()) { @@ -91,6 +94,7 @@ class ScheduleQueryService( memo = schedule.memo, status = schedule.computeStatus(nowDateTime()), dDay = schedule.computeDDay(today()), + urgencyLevel = UrgencyLevel.from(schedule.computeDDay(today())), progressRate = schedule.computeProgressRate(nowDateTime()), confirmations = confirmations, myConfirmation = myConfirmation, @@ -99,8 +103,20 @@ class ScheduleQueryService( ) } - private fun Schedule.toSummaryResult(): ScheduleSummaryResult = - ScheduleSummaryResult( + override fun findUpcomingSchedulesByCircleId( + circleId: CircleId, + today: LocalDate, + limit: Int, + ): List = + scheduleOutPort.findUpcomingByCircleId(circleId.value, today, limit) + .map { it.toSummaryResult() } + + override fun countByCircleId(circleId: CircleId): Long = + scheduleOutPort.countActiveByCircleId(circleId.value) + + private fun Schedule.toSummaryResult(): ScheduleSummaryResult { + val dDay = computeDDay(today()) + return ScheduleSummaryResult( scheduleId = id, title = title, startDate = startDate, @@ -110,9 +126,11 @@ class ScheduleQueryService( isAllDay = isAllDay, needConfirm = needConfirm, status = computeStatus(nowDateTime()), - dDay = computeDDay(today()), + dDay = dDay, + urgencyLevel = UrgencyLevel.from(dDay), progressRate = computeProgressRate(nowDateTime()), ) + } private fun decodeCursor(cursor: String): SchedulePageCursor = try { diff --git a/src/main/kotlin/com/unicorn/server/domain/schedule/service/ScheduleReminderService.kt b/src/main/kotlin/com/unicorn/server/domain/schedule/service/ScheduleReminderService.kt new file mode 100644 index 0000000..1b748d3 --- /dev/null +++ b/src/main/kotlin/com/unicorn/server/domain/schedule/service/ScheduleReminderService.kt @@ -0,0 +1,76 @@ +package com.unicorn.server.domain.schedule.service + +import com.unicorn.server.common.port.out.event.EventPublisher +import com.unicorn.server.domain.schedule.Schedule +import com.unicorn.server.domain.schedule.enums.ScheduleReminderType +import com.unicorn.server.domain.schedule.event.ScheduleReminderDueEvent +import com.unicorn.server.domain.schedule.port.`in`.ScheduleReminderInPort +import com.unicorn.server.domain.schedule.port.out.ScheduleOutPort +import org.springframework.stereotype.Service +import org.springframework.transaction.annotation.Transactional +import java.time.LocalDateTime +import java.time.LocalTime + +@Service +@Transactional +class ScheduleReminderService( + private val scheduleOutPort: ScheduleOutPort, + private val eventPublisher: EventPublisher, +) : ScheduleReminderInPort { + + override fun dispatchDaily(now: LocalDateTime) { + val notificationAt = now.toLocalDate().atTime(DAILY_NOTIFICATION_TIME) + publish( + type = ScheduleReminderType.D7, + schedules = scheduleOutPort.findActiveByStartDateAndCreatedBefore( + startDate = now.toLocalDate().plusDays(7), + createdBefore = notificationAt, + ), + ) + publish( + type = ScheduleReminderType.D1, + schedules = scheduleOutPort.findActiveByStartDateAndCreatedBefore( + startDate = now.toLocalDate().plusDays(1), + createdBefore = notificationAt, + ), + ) + publish( + type = ScheduleReminderType.DDAY_ALL_DAY, + schedules = scheduleOutPort.findActiveAllDayByStartDateAndCreatedBefore( + startDate = now.toLocalDate(), + createdBefore = now.toLocalDate().atStartOfDay(), + ), + ) + } + + override fun dispatchTimed(now: LocalDateTime) { + val notificationAt = now.withSecond(0).withNano(0) + val startAt = notificationAt.plusHours(1) + publish( + type = ScheduleReminderType.DDAY_TIMED, + schedules = scheduleOutPort.findActiveTimedByStartAtAndCreatedBefore( + startDate = startAt.toLocalDate(), + startTime = startAt.toLocalTime(), + createdBefore = notificationAt, + ), + ) + } + + private fun publish(type: ScheduleReminderType, schedules: List) { + schedules.forEach { schedule -> + eventPublisher.publish( + ScheduleReminderDueEvent( + reminderType = type, + scheduleId = schedule.id.value, + circleId = schedule.circleId, + scheduleTitle = schedule.title, + startTime = schedule.startTime, + ), + ) + } + } + + companion object { + private val DAILY_NOTIFICATION_TIME: LocalTime = LocalTime.of(9, 0) + } +} 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 new file mode 100644 index 0000000..4c0c9d8 --- /dev/null +++ b/src/main/kotlin/com/unicorn/server/infrastructure/adapter/in/event/schedule/ScheduleNotificationEventListener.kt @@ -0,0 +1,158 @@ +package com.unicorn.server.infrastructure.adapter.`in`.event.schedule + +import com.unicorn.server.common.port.out.event.EventPublisher +import com.unicorn.server.domain.circle.port.`in`.CircleMemberInPort +import com.unicorn.server.domain.circle.port.dto.CircleMemberDto +import com.unicorn.server.domain.notification.enums.NotificationChannel +import com.unicorn.server.domain.notification.enums.NotificationSettingType +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.ScheduleReminderD1Payload +import com.unicorn.server.domain.notification.event.ScheduleReminderDDayAllDayPayload +import com.unicorn.server.domain.notification.event.ScheduleReminderDDayTimedPayload +import com.unicorn.server.domain.notification.event.ScheduleReminderD7Payload +import com.unicorn.server.domain.notification.port.`in`.NotificationPushTokenInPort +import com.unicorn.server.domain.notification.port.`in`.NotificationSettingInPort +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.enums.ScheduleReminderType +import com.unicorn.server.domain.schedule.event.ScheduleReminderDueEvent +import com.unicorn.server.domain.schedule.port.`in`.ScheduleConfirmationStatusInPort +import com.unicorn.server.domain.schedule.vo.ScheduleId +import org.springframework.context.event.EventListener +import org.springframework.stereotype.Component +import java.time.format.DateTimeFormatter + +@Component +class ScheduleNotificationEventListener( + private val circleMemberInPort: CircleMemberInPort, + private val notificationPushTokenInPort: NotificationPushTokenInPort, + private val notificationSettingInPort: NotificationSettingInPort, + private val scheduleConfirmationStatusInPort: ScheduleConfirmationStatusInPort, + private val eventPublisher: EventPublisher, +) { + @EventListener + fun handle(event: ScheduleCreatedEvent) { + val members = circleMemberInPort.getCircleMembers(event.circleId) + val actorName = members.nicknameOf(event.creatorMemberId) + val payload = ScheduleCreatedPayload(actorName, event.scheduleTitle) + + members + .asSequence() + .filter { it.active } + .forEach { member -> + val receiverDedupKey = "schedule-created:${event.scheduleId}:${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}", + ), + ) + } + } + } + + @EventListener + fun handle(event: ScheduleReminderDueEvent) { + val payload = event.toPayload() + val settingType = event.reminderType.toSettingType() + + circleMemberInPort.getCircleMembers(event.circleId) + .asSequence() + .filter { it.active } + .filter { notificationSettingInPort.getSetting(it.memberId).isEnabled(settingType) } + .forEach { member -> + val receiverDedupKey = "schedule-reminder:${event.reminderType.name.lowercase()}:${event.scheduleId}:${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}", + ), + ) + } + } + } + + @EventListener + fun handle(event: ScheduleConfirmedEvent) { + if (event.scheduleCreatorMemberId == event.confirmerMemberId) { + return + } + if (!notificationSettingInPort.getSetting(event.scheduleCreatorMemberId) + .isEnabled(NotificationSettingType.FAMILY_SCHEDULE_CHECK)) { + return + } + + val confirmerName = circleMemberInPort.getCircleMembers(event.circleId).nicknameOf(event.confirmerMemberId) + val payload = ScheduleConfirmedByFamilyPayload(confirmerName, event.scheduleTitle) + val receiverDedupKey = "schedule-confirmed:${event.scheduleId}:${event.confirmerMemberId}:${event.scheduleCreatorMemberId}" + notificationPushTokenInPort.getActiveReceivable(event.scheduleCreatorMemberId).forEach { pushToken -> + eventPublisher.publish( + NotificationRequestedEvent( + channel = NotificationChannel.PUSH, + receiver = pushToken.token, + payload = payload, + dedupKey = "$receiverDedupKey:token:${requireNotNull(pushToken.id).value}", + ), + ) + } + } + + @EventListener + fun handle(event: ScheduleConfirmationRequestDueEvent) { + val payload = ScheduleConfirmationRequestedPayload(event.scheduleTitle) + circleMemberInPort.getCircleMembers(event.circleId) + .asSequence() + .filter { it.active && it.memberId != event.scheduleCreatorMemberId } + .filter { notificationSettingInPort.getSetting(it.memberId).isEnabled(NotificationSettingType.FAMILY_SCHEDULE_CHECK) } + .filterNot { scheduleConfirmationStatusInPort.hasConfirmed(ScheduleId.of(event.scheduleId), it.memberId) } + .forEach { member -> + val receiverDedupKey = "schedule-confirmation-request:${event.scheduleId}:${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") + + private fun ScheduleReminderDueEvent.toPayload() = when (reminderType) { + ScheduleReminderType.D7 -> ScheduleReminderD7Payload(scheduleTitle) + ScheduleReminderType.D1 -> ScheduleReminderD1Payload(scheduleTitle) + ScheduleReminderType.DDAY_ALL_DAY -> ScheduleReminderDDayAllDayPayload(scheduleTitle) + ScheduleReminderType.DDAY_TIMED -> ScheduleReminderDDayTimedPayload( + scheduleTitle = scheduleTitle, + startTime = requireNotNull(startTime) { "Timed reminder requires start time" }.format(TIME_FORMATTER), + ) + } + + private fun ScheduleReminderType.toSettingType(): NotificationSettingType = when (this) { + ScheduleReminderType.D7 -> NotificationSettingType.D7 + ScheduleReminderType.D1 -> NotificationSettingType.D1 + ScheduleReminderType.DDAY_ALL_DAY, + ScheduleReminderType.DDAY_TIMED + -> NotificationSettingType.D_DAY + } + + companion object { + private val TIME_FORMATTER: DateTimeFormatter = DateTimeFormatter.ofPattern("HH:mm") + } +} diff --git a/src/main/kotlin/com/unicorn/server/infrastructure/adapter/in/scheduler/schedule/ScheduleReminderScheduler.kt b/src/main/kotlin/com/unicorn/server/infrastructure/adapter/in/scheduler/schedule/ScheduleReminderScheduler.kt new file mode 100644 index 0000000..03d510a --- /dev/null +++ b/src/main/kotlin/com/unicorn/server/infrastructure/adapter/in/scheduler/schedule/ScheduleReminderScheduler.kt @@ -0,0 +1,34 @@ +package com.unicorn.server.infrastructure.adapter.`in`.scheduler.schedule + +import com.unicorn.server.domain.schedule.port.`in`.ScheduleConfirmationRequestInPort +import com.unicorn.server.domain.schedule.port.`in`.ScheduleReminderInPort +import org.springframework.scheduling.annotation.Scheduled +import org.springframework.stereotype.Component +import java.time.LocalDateTime +import java.time.ZoneId + +@Component +class ScheduleReminderScheduler( + private val scheduleReminderInPort: ScheduleReminderInPort, + private val scheduleConfirmationRequestInPort: ScheduleConfirmationRequestInPort, +) { + @Scheduled(cron = "0 0 9 * * *", zone = KST_ID) + fun dispatchDaily() { + scheduleReminderInPort.dispatchDaily(LocalDateTime.now(KST)) + } + + @Scheduled(cron = "0 * * * * *", zone = KST_ID) + fun dispatchTimed() { + scheduleReminderInPort.dispatchTimed(LocalDateTime.now(KST)) + } + + @Scheduled(cron = "0 * * * * *", zone = KST_ID) + fun dispatchConfirmationRequest() { + scheduleConfirmationRequestInPort.dispatchDue(LocalDateTime.now(KST)) + } + + companion object { + private const val KST_ID = "Asia/Seoul" + private val KST: ZoneId = ZoneId.of(KST_ID) + } +} diff --git a/src/main/kotlin/com/unicorn/server/infrastructure/adapter/in/web/circle/dto/CircleHomeResponse.kt b/src/main/kotlin/com/unicorn/server/infrastructure/adapter/in/web/circle/dto/CircleHomeResponse.kt index 945292d..25bf247 100644 --- a/src/main/kotlin/com/unicorn/server/infrastructure/adapter/in/web/circle/dto/CircleHomeResponse.kt +++ b/src/main/kotlin/com/unicorn/server/infrastructure/adapter/in/web/circle/dto/CircleHomeResponse.kt @@ -1,13 +1,14 @@ package com.unicorn.server.infrastructure.adapter.`in`.web.circle.dto import com.unicorn.server.domain.home.port.dto.HomeView +import com.unicorn.server.infrastructure.adapter.`in`.web.schedule.dto.ScheduleSummaryResponse data class CircleHomeResponse( val circle: CircleSummaryResponse, val members: List, val canInvite: Boolean, - val mainSchedule: Any?, - val schedules: List, + val mainSchedule: ScheduleSummaryResponse?, + val schedules: List, val totalScheduleCount: Long, ) { companion object { @@ -15,8 +16,8 @@ data class CircleHomeResponse( circle = CircleSummaryResponse.from(view.circle), members = view.members.map { CircleMemberResponse.from(it, serverUrl) }, canInvite = view.canInvite, - mainSchedule = view.mainSchedule, - schedules = view.schedules, + mainSchedule = view.mainSchedule?.let { ScheduleSummaryResponse.from(it) }, + schedules = view.schedules.map { ScheduleSummaryResponse.from(it) }, totalScheduleCount = view.totalScheduleCount, ) } diff --git a/src/main/kotlin/com/unicorn/server/infrastructure/adapter/in/web/circle/dto/CircleMemberResponse.kt b/src/main/kotlin/com/unicorn/server/infrastructure/adapter/in/web/circle/dto/CircleMemberResponse.kt index 56cb7ff..0cf5a57 100644 --- a/src/main/kotlin/com/unicorn/server/infrastructure/adapter/in/web/circle/dto/CircleMemberResponse.kt +++ b/src/main/kotlin/com/unicorn/server/infrastructure/adapter/in/web/circle/dto/CircleMemberResponse.kt @@ -1,5 +1,6 @@ package com.unicorn.server.infrastructure.adapter.`in`.web.circle.dto +import com.fasterxml.jackson.annotation.JsonProperty import com.unicorn.server.domain.home.port.dto.HomeMemberDto import com.unicorn.server.infrastructure.adapter.`in`.web.member.dto.AvatarColorResponse import io.swagger.v3.oas.annotations.media.Schema @@ -23,6 +24,7 @@ data class CircleMemberResponse( val profileImageUrl: String?, @field:Schema(description = "요청자 본인 여부", example = "true") + @get:JsonProperty("isMe") val isMe: Boolean, @field:Schema(description = "써클 내 역할", example = "MEMBER") diff --git a/src/main/kotlin/com/unicorn/server/infrastructure/adapter/in/web/home/HomeApiDoc.kt b/src/main/kotlin/com/unicorn/server/infrastructure/adapter/in/web/home/HomeApiDoc.kt index b1bf642..d109df7 100644 --- a/src/main/kotlin/com/unicorn/server/infrastructure/adapter/in/web/home/HomeApiDoc.kt +++ b/src/main/kotlin/com/unicorn/server/infrastructure/adapter/in/web/home/HomeApiDoc.kt @@ -25,7 +25,8 @@ interface HomeApiDoc { - 요청자가 해당 써클의 활성 구성원이 아니면 403을 반환합니다. - 회원 탈퇴한 구성원은 응답에서 자동 제외됩니다. - 구성원 정보에는 프로필 이미지 URL(`profileImageUrl`)과 아바타 색상(`avatarColor.code`, `avatarColor.hex`)이 포함됩니다. - - 일정 도메인은 현재 no-op stub이라 `mainSchedule`은 null, `schedules`는 빈 배열입니다. + - `mainSchedule`은 오늘 이후 가장 임박한 일정 1건이며, 예정된 일정이 없으면 null입니다. + - `schedules`는 `mainSchedule`을 제외한 이후 일정들을 임박한 순서로 담습니다. """, ) @ApiErrorCodeExamples( 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 7d63131..ea77cc2 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 @@ -159,6 +159,9 @@ interface ScheduleApiDoc { **권한**: 써클 구성원(MEMBER 이상)만 조회 가능합니다. + **조회 범위**: 아직 종료되지 않은 일정만 반환합니다. (endDate가 오늘(KST) 이상) + - 종료일이 지난 일정은 목록에서 제외됩니다. + **정렬 순서**: startDate ASC → startTime ASC → scheduleId ASC - 종일 일정(startTime=null)은 해당 날짜의 00:00 기준으로 정렬합니다. @@ -172,6 +175,7 @@ interface ScheduleApiDoc { - status: UPCOMING(시작 전) / IN_PROGRESS(진행 중) / COMPLETED(종료), KST 현재 시각 기준 - isAllDay: startTime/endTime이 모두 null이면 true - dDay: startDate와 오늘(KST) 간의 양수 차이. 진행 중이거나 과거 일정은 null + - urgencyLevel: 긴급도. dDay가 10 미만이면 URGENT, 10 이상이거나 dDay가 null(진행 중/종료)이면 NORMAL - progressRate: 0~100 정수. 시작 전=0, 종료 후=100, 진행 중=경과 시간 비율 """, ) @@ -212,6 +216,10 @@ interface ScheduleApiDoc { - 오늘 기준 startDate까지 남은 일수 (양수) - 진행 중이거나 시작일이 이미 지난 경우 null + **urgencyLevel (긴급도)** + - URGENT: dDay가 10 미만 + - NORMAL: dDay가 10 이상이거나, 진행 중/종료되어 dDay가 null인 경우 + **progressRate 계산** - 시작 전: 0 - 종료 후: 100 diff --git a/src/main/kotlin/com/unicorn/server/infrastructure/adapter/in/web/schedule/dto/ScheduleDetailResponse.kt b/src/main/kotlin/com/unicorn/server/infrastructure/adapter/in/web/schedule/dto/ScheduleDetailResponse.kt index 93a5fa7..0cc8cb6 100644 --- a/src/main/kotlin/com/unicorn/server/infrastructure/adapter/in/web/schedule/dto/ScheduleDetailResponse.kt +++ b/src/main/kotlin/com/unicorn/server/infrastructure/adapter/in/web/schedule/dto/ScheduleDetailResponse.kt @@ -2,6 +2,7 @@ package com.unicorn.server.infrastructure.adapter.`in`.web.schedule.dto import com.fasterxml.jackson.annotation.JsonProperty import com.unicorn.server.domain.schedule.enums.ScheduleStatus +import com.unicorn.server.domain.schedule.enums.UrgencyLevel import com.unicorn.server.domain.schedule.port.dto.ScheduleDetailResult import io.swagger.v3.oas.annotations.media.Schema import java.time.LocalDate @@ -71,6 +72,17 @@ data class ScheduleDetailResponse( ) val dDay: Int?, + @field:Schema( + description = """ + 긴급도. dDay 기준으로 계산됩니다. + - URGENT: dDay가 10 미만 + - NORMAL: dDay가 10 이상이거나, 진행 중/종료되어 dDay가 없는 경우 + """, + example = "NORMAL", + allowableValues = ["URGENT", "NORMAL"], + ) + val urgencyLevel: UrgencyLevel, + @field:Schema( description = """ 진행률 (0~100 정수). KST 현재 시각 기준으로 계산됩니다. @@ -125,6 +137,7 @@ data class ScheduleDetailResponse( needConfirm = result.needConfirm, status = result.status, dDay = result.dDay, + urgencyLevel = result.urgencyLevel, progressRate = result.progressRate, memo = result.memo, confirmations = result.confirmations.map { ConfirmationCountResponse.from(it) }, diff --git a/src/main/kotlin/com/unicorn/server/infrastructure/adapter/in/web/schedule/dto/ScheduleSummaryResponse.kt b/src/main/kotlin/com/unicorn/server/infrastructure/adapter/in/web/schedule/dto/ScheduleSummaryResponse.kt index 94ae2c0..f7a0214 100644 --- a/src/main/kotlin/com/unicorn/server/infrastructure/adapter/in/web/schedule/dto/ScheduleSummaryResponse.kt +++ b/src/main/kotlin/com/unicorn/server/infrastructure/adapter/in/web/schedule/dto/ScheduleSummaryResponse.kt @@ -2,6 +2,7 @@ package com.unicorn.server.infrastructure.adapter.`in`.web.schedule.dto import com.fasterxml.jackson.annotation.JsonProperty import com.unicorn.server.domain.schedule.enums.ScheduleStatus +import com.unicorn.server.domain.schedule.enums.UrgencyLevel import com.unicorn.server.domain.schedule.port.dto.ScheduleSummaryResult import io.swagger.v3.oas.annotations.media.Schema import java.time.LocalDate @@ -70,6 +71,17 @@ data class ScheduleSummaryResponse( ) val dDay: Int?, + @field:Schema( + description = """ + 긴급도. dDay 기준으로 계산됩니다. + - URGENT: dDay가 10 미만 + - NORMAL: dDay가 10 이상이거나, 진행 중/종료되어 dDay가 없는 경우 + """, + example = "NORMAL", + allowableValues = ["URGENT", "NORMAL"], + ) + val urgencyLevel: UrgencyLevel, + @field:Schema( description = """ 진행률 (0~100 정수). KST 현재 시각 기준으로 계산됩니다. @@ -93,6 +105,7 @@ data class ScheduleSummaryResponse( needConfirm = result.needConfirm, status = result.status, dDay = result.dDay, + urgencyLevel = result.urgencyLevel, progressRate = result.progressRate, ) } diff --git a/src/main/kotlin/com/unicorn/server/infrastructure/adapter/out/persistence/schedule/ScheduleJpaRepository.kt b/src/main/kotlin/com/unicorn/server/infrastructure/adapter/out/persistence/schedule/ScheduleJpaRepository.kt index e6011e5..61aaff1 100644 --- a/src/main/kotlin/com/unicorn/server/infrastructure/adapter/out/persistence/schedule/ScheduleJpaRepository.kt +++ b/src/main/kotlin/com/unicorn/server/infrastructure/adapter/out/persistence/schedule/ScheduleJpaRepository.kt @@ -5,6 +5,7 @@ import org.springframework.data.jpa.repository.JpaRepository import org.springframework.data.jpa.repository.Query import org.springframework.data.repository.query.Param import java.time.LocalDate +import java.time.LocalDateTime import java.time.LocalTime interface ScheduleJpaRepository : JpaRepository { @@ -17,6 +18,7 @@ interface ScheduleJpaRepository : JpaRepository { FROM schedule WHERE circle_id = :circleId AND del_yn = 'N' + AND end_date >= :today ORDER BY start_date ASC, start_time_sort ASC, schedule_id ASC @@ -26,6 +28,7 @@ interface ScheduleJpaRepository : JpaRepository { ) fun findFirstPage( @Param("circleId") circleId: String, + @Param("today") today: LocalDate, @Param("size") size: Int, ): List @@ -35,6 +38,7 @@ interface ScheduleJpaRepository : JpaRepository { FROM schedule WHERE circle_id = :circleId AND del_yn = 'N' + AND end_date >= :today AND ( start_date > :cursorDate OR (start_date = :cursorDate AND start_time_sort > :cursorTime) @@ -49,9 +53,97 @@ interface ScheduleJpaRepository : JpaRepository { ) fun findAfterCursor( @Param("circleId") circleId: String, + @Param("today") today: LocalDate, @Param("cursorDate") cursorDate: LocalDate, @Param("cursorTime") cursorTime: LocalTime, @Param("cursorId") cursorId: String, @Param("size") size: Int, ): List + + @Query( + value = """ + SELECT * + FROM schedule + WHERE del_yn = 'N' + AND start_date = :startDate + AND created_at < :createdBefore + """, + nativeQuery = true, + ) + fun findActiveByStartDateAndCreatedBefore( + @Param("startDate") startDate: LocalDate, + @Param("createdBefore") createdBefore: LocalDateTime, + ): List + + @Query( + value = """ + SELECT * + FROM schedule + WHERE del_yn = 'N' + AND start_date = :startDate + AND start_time IS NULL + AND created_at < :createdBefore + """, + nativeQuery = true, + ) + fun findActiveAllDayByStartDateAndCreatedBefore( + @Param("startDate") startDate: LocalDate, + @Param("createdBefore") createdBefore: LocalDateTime, + ): List + + @Query( + value = """ + SELECT * + FROM schedule + WHERE del_yn = 'N' + AND start_date = :startDate + AND start_time = :startTime + AND created_at < :createdBefore + """, + nativeQuery = true, + ) + fun findActiveTimedByStartAtAndCreatedBefore( + @Param("startDate") startDate: LocalDate, + @Param("startTime") startTime: LocalTime, + @Param("createdBefore") createdBefore: LocalDateTime, + ): List + + @Query( + value = """ + SELECT * + FROM schedule + WHERE del_yn = 'N' + AND need_confirm = 'Y' + AND created_at >= :createdFrom + AND created_at < :createdBefore + """, + nativeQuery = true, + ) + fun findActiveConfirmationRequiredCreatedBetween( + @Param("createdFrom") createdFrom: LocalDateTime, + @Param("createdBefore") createdBefore: LocalDateTime, + ): List + + // end_date >= today 조건으로 아직 종료되지 않은(예정 + 진행 중) 일정을 임박한 순서로 조회한다. + @Query( + value = """ + SELECT * + FROM schedule + WHERE circle_id = :circleId + AND del_yn = 'N' + AND end_date >= :today + ORDER BY start_date ASC, + start_time_sort ASC, + schedule_id ASC + LIMIT :limit + """, + nativeQuery = true, + ) + fun findUpcoming( + @Param("circleId") circleId: String, + @Param("today") today: LocalDate, + @Param("limit") limit: Int, + ): List + + fun countByCircleIdAndDelYn(circleId: String, delYn: String = "N"): Long } diff --git a/src/main/kotlin/com/unicorn/server/infrastructure/adapter/out/persistence/schedule/SchedulePersistenceAdapter.kt b/src/main/kotlin/com/unicorn/server/infrastructure/adapter/out/persistence/schedule/SchedulePersistenceAdapter.kt index 03fa0fd..c1faaf6 100644 --- a/src/main/kotlin/com/unicorn/server/infrastructure/adapter/out/persistence/schedule/SchedulePersistenceAdapter.kt +++ b/src/main/kotlin/com/unicorn/server/infrastructure/adapter/out/persistence/schedule/SchedulePersistenceAdapter.kt @@ -7,6 +7,8 @@ import com.unicorn.server.domain.schedule.port.out.ScheduleOutPort import com.unicorn.server.domain.schedule.vo.ScheduleId import com.unicorn.server.infrastructure.adapter.out.persistence.schedule.entity.ScheduleEntity import org.springframework.transaction.annotation.Transactional +import java.time.LocalDate +import java.time.LocalDateTime import java.time.LocalTime @PersistenceAdapter @@ -36,14 +38,16 @@ class SchedulePersistenceAdapter( @Transactional(readOnly = true) override fun findActiveByCircleId( circleId: String, + today: LocalDate, cursor: SchedulePageCursor?, size: Int, ): List { val entities = if (cursor == null) { - scheduleJpaRepository.findFirstPage(circleId, size) + scheduleJpaRepository.findFirstPage(circleId, today, size) } else { scheduleJpaRepository.findAfterCursor( circleId = circleId, + today = today, cursorDate = cursor.startDate, cursorTime = cursor.startTime ?: LocalTime.MIDNIGHT, cursorId = cursor.scheduleId.value, @@ -53,4 +57,45 @@ class SchedulePersistenceAdapter( return entities.map { it.toDomain() } } + + @Transactional(readOnly = true) + override fun findActiveByStartDateAndCreatedBefore( + startDate: LocalDate, + createdBefore: LocalDateTime, + ): List = + scheduleJpaRepository.findActiveByStartDateAndCreatedBefore(startDate, createdBefore).map { it.toDomain() } + + @Transactional(readOnly = true) + override fun findActiveAllDayByStartDateAndCreatedBefore( + startDate: LocalDate, + createdBefore: LocalDateTime, + ): List = + scheduleJpaRepository.findActiveAllDayByStartDateAndCreatedBefore(startDate, createdBefore).map { it.toDomain() } + + @Transactional(readOnly = true) + override fun findActiveTimedByStartAtAndCreatedBefore( + startDate: LocalDate, + startTime: LocalTime, + createdBefore: LocalDateTime, + ): List = + scheduleJpaRepository.findActiveTimedByStartAtAndCreatedBefore(startDate, startTime, createdBefore).map { it.toDomain() } + + @Transactional(readOnly = true) + override fun findActiveConfirmationRequiredCreatedBetween( + createdFrom: LocalDateTime, + createdBefore: LocalDateTime, + ): List = + scheduleJpaRepository.findActiveConfirmationRequiredCreatedBetween(createdFrom, createdBefore).map { it.toDomain() } + + @Transactional(readOnly = true) + override fun findUpcomingByCircleId( + circleId: String, + today: LocalDate, + limit: Int, + ): List = + scheduleJpaRepository.findUpcoming(circleId, today, limit).map { it.toDomain() } + + @Transactional(readOnly = true) + override fun countActiveByCircleId(circleId: String): Long = + scheduleJpaRepository.countByCircleIdAndDelYn(circleId) } diff --git a/src/main/kotlin/com/unicorn/server/infrastructure/adapter/out/persistence/schedule/entity/ScheduleEntity.kt b/src/main/kotlin/com/unicorn/server/infrastructure/adapter/out/persistence/schedule/entity/ScheduleEntity.kt index 7d4a49e..eba0e52 100644 --- a/src/main/kotlin/com/unicorn/server/infrastructure/adapter/out/persistence/schedule/entity/ScheduleEntity.kt +++ b/src/main/kotlin/com/unicorn/server/infrastructure/adapter/out/persistence/schedule/entity/ScheduleEntity.kt @@ -17,6 +17,9 @@ import java.time.LocalTime name = "schedule", indexes = [ Index(name = "idx_schedule_circle_sort", columnList = "circle_id, del_yn, start_date, start_time_sort, schedule_id"), + Index(name = "idx_schedule_reminder_daily", columnList = "del_yn, start_date, created_at"), + Index(name = "idx_schedule_reminder_timed", columnList = "del_yn, start_date, start_time, created_at"), + Index(name = "idx_schedule_confirmation_request", columnList = "del_yn, need_confirm, created_at"), ], ) class ScheduleEntity protected constructor() { diff --git a/src/main/kotlin/com/unicorn/server/infrastructure/adapter/out/schedule/NoOpScheduleQueryAdapter.kt b/src/main/kotlin/com/unicorn/server/infrastructure/adapter/out/schedule/NoOpScheduleQueryAdapter.kt deleted file mode 100644 index 286c380..0000000 --- a/src/main/kotlin/com/unicorn/server/infrastructure/adapter/out/schedule/NoOpScheduleQueryAdapter.kt +++ /dev/null @@ -1,14 +0,0 @@ -package com.unicorn.server.infrastructure.adapter.out.schedule - -import com.unicorn.server.domain.circle.vo.CircleId -import com.unicorn.server.domain.schedule.port.`in`.GetSchedulesForCircleInPort -import com.unicorn.server.domain.schedule.port.dto.ScheduleView -import org.springframework.stereotype.Component -import java.time.LocalDate - -@Component -class NoOpScheduleQueryAdapter : GetSchedulesForCircleInPort { - override fun findMainScheduleByCircleId(circleId: CircleId, today: LocalDate): ScheduleView? = null - override fun findUpcomingSchedulesByCircleId(circleId: CircleId, today: LocalDate, limit: Int): List = emptyList() - override fun countByCircleId(circleId: CircleId): Long = 0L -} diff --git a/src/main/resources/db/migration/V1.0.3.5__add_schedule_reminder_indexes.sql b/src/main/resources/db/migration/V1.0.3.5__add_schedule_reminder_indexes.sql new file mode 100644 index 0000000..7a10379 --- /dev/null +++ b/src/main/resources/db/migration/V1.0.3.5__add_schedule_reminder_indexes.sql @@ -0,0 +1,5 @@ +create index idx_schedule_reminder_daily + on schedule (del_yn, start_date, created_at); + +create index idx_schedule_reminder_timed + on schedule (del_yn, start_date, start_time, created_at); diff --git a/src/main/resources/db/migration/V1.0.3.6__add_schedule_confirmation_request_index.sql b/src/main/resources/db/migration/V1.0.3.6__add_schedule_confirmation_request_index.sql new file mode 100644 index 0000000..d21adc4 --- /dev/null +++ b/src/main/resources/db/migration/V1.0.3.6__add_schedule_confirmation_request_index.sql @@ -0,0 +1,2 @@ +create index idx_schedule_confirmation_request + on schedule (del_yn, need_confirm, created_at); diff --git a/src/test/kotlin/com/unicorn/server/domain/home/HomeQueryServiceTest.kt b/src/test/kotlin/com/unicorn/server/domain/home/HomeQueryServiceTest.kt index 1bf414d..5229e9d 100644 --- a/src/test/kotlin/com/unicorn/server/domain/home/HomeQueryServiceTest.kt +++ b/src/test/kotlin/com/unicorn/server/domain/home/HomeQueryServiceTest.kt @@ -82,7 +82,6 @@ class HomeQueryServiceTest { assertThat(members).hasSize(2) assertThat(members.first { it.memberId == friendId }.profileImageKey).isEqualTo("images/profile/friend3.png") assertThat(members.first { it.memberId == friendId }.avatarColor).isEqualTo(AvatarColor.TEAL_200) - assertThat(scheduleQueryInPort.mainCalled).isFalse() assertThat(scheduleQueryInPort.listCalled).isFalse() assertThat(scheduleQueryInPort.countCalled).isFalse() } @@ -123,12 +122,10 @@ class HomeQueryServiceTest { } private class FakeScheduleQueryInPort : GetSchedulesForCircleInPort { - var mainCalled = false var listCalled = false var countCalled = false - override fun findMainScheduleByCircleId(circleId: CircleId, today: LocalDate) = null.also { mainCalled = true } - override fun findUpcomingSchedulesByCircleId(circleId: CircleId, today: LocalDate, limit: Int) = emptyList().also { listCalled = true } + override fun findUpcomingSchedulesByCircleId(circleId: CircleId, today: LocalDate, limit: Int) = emptyList().also { listCalled = true } override fun countByCircleId(circleId: CircleId): Long = 0L.also { countCalled = true } } } diff --git a/src/test/kotlin/com/unicorn/server/domain/schedule/service/CancelConfirmationServiceTest.kt b/src/test/kotlin/com/unicorn/server/domain/schedule/service/CancelConfirmationServiceTest.kt index 769246c..9a92ff9 100644 --- a/src/test/kotlin/com/unicorn/server/domain/schedule/service/CancelConfirmationServiceTest.kt +++ b/src/test/kotlin/com/unicorn/server/domain/schedule/service/CancelConfirmationServiceTest.kt @@ -115,9 +115,39 @@ class CancelConfirmationServiceTest { override fun findActiveByCircleId( circleId: String, + today: LocalDate, cursor: SchedulePageCursor?, size: Int, ): List = emptyList() + + override fun findActiveByStartDateAndCreatedBefore( + startDate: java.time.LocalDate, + createdBefore: java.time.LocalDateTime, + ): List = error("not used") + + override fun findActiveAllDayByStartDateAndCreatedBefore( + startDate: java.time.LocalDate, + createdBefore: java.time.LocalDateTime, + ): List = error("not used") + + override fun findActiveTimedByStartAtAndCreatedBefore( + startDate: java.time.LocalDate, + startTime: java.time.LocalTime, + createdBefore: java.time.LocalDateTime, + ): List = error("not used") + + override fun findActiveConfirmationRequiredCreatedBetween( + createdFrom: java.time.LocalDateTime, + createdBefore: java.time.LocalDateTime, + ): List = error("not used") + + override fun findUpcomingByCircleId( + circleId: String, + today: LocalDate, + limit: Int, + ): List = emptyList() + + override fun countActiveByCircleId(circleId: String): Long = 0L } private class FakeScheduleConfirmationOutPort : ScheduleConfirmationOutPort { diff --git a/src/test/kotlin/com/unicorn/server/domain/schedule/service/ScheduleCommandServiceTest.kt b/src/test/kotlin/com/unicorn/server/domain/schedule/service/ScheduleCommandServiceTest.kt index 03883d0..4a0f21b 100644 --- a/src/test/kotlin/com/unicorn/server/domain/schedule/service/ScheduleCommandServiceTest.kt +++ b/src/test/kotlin/com/unicorn/server/domain/schedule/service/ScheduleCommandServiceTest.kt @@ -1,8 +1,12 @@ package com.unicorn.server.domain.schedule.service import com.unicorn.server.common.exception.BusinessException +import com.unicorn.server.common.domain.Event +import com.unicorn.server.common.port.out.event.EventPublisher import com.unicorn.server.domain.schedule.Schedule import com.unicorn.server.domain.schedule.ScheduleConfirmation +import com.unicorn.server.domain.schedule.event.ScheduleCreatedEvent +import com.unicorn.server.domain.schedule.event.ScheduleDeletedEvent import com.unicorn.server.domain.schedule.exception.ScheduleErrorCode import com.unicorn.server.domain.schedule.port.dto.ConfirmationCountResult import com.unicorn.server.domain.schedule.port.dto.CreateScheduleCommand @@ -29,11 +33,13 @@ class ScheduleCommandServiceTest { private val confirmationOutPort = FakeScheduleConfirmationOutPort() private val circleAccessOutPort = FakeCircleAccessOutPort() private val scheduleIdGenerator = FakeScheduleIdGenerator() + private val eventPublisher = RecordingEventPublisher() private val scheduleCommandService = ScheduleCommandService( scheduleOutPort, confirmationOutPort, circleAccessOutPort, scheduleIdGenerator, + eventPublisher, ) @Test @@ -48,6 +54,11 @@ class ScheduleCommandServiceTest { assertThat(scheduleId.value).startsWith("SC") assertThat(scheduleOutPort.saved).hasSize(1) assertThat(scheduleOutPort.saved.single().title).isEqualTo("제주도 여행") + val event = eventPublisher.events.filterIsInstance().single() + assertThat(event.scheduleId).isEqualTo(scheduleId.value) + assertThat(event.circleId).isEqualTo(CIRCLE_ID) + assertThat(event.creatorMemberId).isEqualTo(MEMBER_ID) + assertThat(event.scheduleTitle).isEqualTo("제주도 여행") } @Test @@ -176,6 +187,9 @@ class ScheduleCommandServiceTest { assertThat(scheduleOutPort.findById(SCHEDULE_ID)?.isDeleted).isTrue() assertThat(confirmationOutPort.deletedScheduleIds).containsExactly(SCHEDULE_ID) + val event = eventPublisher.events.filterIsInstance().single() + assertThat(event.scheduleId).isEqualTo(SCHEDULE_ID.value) + assertThat(event.deletedByMemberId).isEqualTo(MEMBER_ID) } private fun createCommand(): CreateScheduleCommand = @@ -239,9 +253,45 @@ class ScheduleCommandServiceTest { override fun findActiveByCircleId( circleId: String, + today: LocalDate, cursor: SchedulePageCursor?, size: Int, - ): List = store.values.filter { it.circleId == circleId && !it.isDeleted }.take(size) + ): List = store.values + .filter { it.circleId == circleId && !it.isDeleted && !it.endDate.isBefore(today) } + .take(size) + + override fun findActiveByStartDateAndCreatedBefore( + startDate: LocalDate, + createdBefore: LocalDateTime, + ): List = error("not used") + + override fun findActiveAllDayByStartDateAndCreatedBefore( + startDate: LocalDate, + createdBefore: LocalDateTime, + ): List = error("not used") + + override fun findActiveTimedByStartAtAndCreatedBefore( + startDate: LocalDate, + startTime: LocalTime, + createdBefore: LocalDateTime, + ): List = error("not used") + + override fun findActiveConfirmationRequiredCreatedBetween( + createdFrom: LocalDateTime, + createdBefore: LocalDateTime, + ): List = error("not used") + + override fun findUpcomingByCircleId( + circleId: String, + today: LocalDate, + limit: Int, + ): List = + store.values + .filter { it.circleId == circleId && !it.isDeleted && !it.endDate.isBefore(today) } + .take(limit) + + override fun countActiveByCircleId(circleId: String): Long = + store.values.count { it.circleId == circleId && !it.isDeleted }.toLong() } private class FakeScheduleConfirmationOutPort : ScheduleConfirmationOutPort { @@ -301,6 +351,14 @@ class ScheduleCommandServiceTest { override fun isInitiator(circleId: String, memberId: String): Boolean = circleId to memberId in initiators } + 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" diff --git a/src/test/kotlin/com/unicorn/server/domain/schedule/service/ScheduleConfirmationRequestServiceTest.kt b/src/test/kotlin/com/unicorn/server/domain/schedule/service/ScheduleConfirmationRequestServiceTest.kt new file mode 100644 index 0000000..d848c04 --- /dev/null +++ b/src/test/kotlin/com/unicorn/server/domain/schedule/service/ScheduleConfirmationRequestServiceTest.kt @@ -0,0 +1,90 @@ +package com.unicorn.server.domain.schedule.service + +import com.unicorn.server.common.domain.Event +import com.unicorn.server.common.port.out.event.EventPublisher +import com.unicorn.server.domain.schedule.Schedule +import com.unicorn.server.domain.schedule.event.ScheduleConfirmationRequestDueEvent +import com.unicorn.server.domain.schedule.port.dto.SchedulePageCursor +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.junit.jupiter.api.DisplayName +import org.junit.jupiter.api.Test +import java.time.LocalDate +import java.time.LocalDateTime +import java.time.LocalTime + +@DisplayName("ScheduleConfirmationRequestService 단위 테스트") +class ScheduleConfirmationRequestServiceTest { + private val scheduleOutPort = FakeScheduleOutPort() + private val eventPublisher = RecordingEventPublisher() + private val service = ScheduleConfirmationRequestService(scheduleOutPort, eventPublisher) + + @Test + @DisplayName("생성 후 24시간이 지나고 시작까지 24시간 이상 남은 확인 필요 일정에 요청 이벤트를 발행한다") + fun dispatchDue_withEligibleSchedule_publishesConfirmationRequestEvent() { + val now = LocalDateTime.of(2026, 8, 11, 10, 1) + scheduleOutPort.schedules += listOf( + schedule("SC-ELIGIBLE", LocalDateTime.of(2026, 8, 10, 10, 0, 30), LocalDateTime.of(2026, 8, 11, 12, 0)), + schedule("SC-TOO-SOON", LocalDateTime.of(2026, 8, 10, 10, 0, 30), LocalDateTime.of(2026, 8, 11, 9, 0)), + ) + + service.dispatchDue(now) + + val event = eventPublisher.events.filterIsInstance().single() + assertThat(event.scheduleId).isEqualTo("SC-ELIGIBLE") + assertThat(event.scheduleCreatorMemberId).isEqualTo("creator") + } + + private fun schedule(id: String, createdAt: LocalDateTime, startAt: LocalDateTime): Schedule = + Schedule.reconstitute( + id = ScheduleId.of(id), + circleId = "circle-1", + title = "일정 $id", + startDate = startAt.toLocalDate(), + endDate = startAt.toLocalDate(), + startTime = startAt.toLocalTime(), + endTime = startAt.toLocalTime().plusHours(1), + needConfirm = true, + memo = null, + createdBy = "creator", + updatedBy = "creator", + createdAt = createdAt, + updatedAt = createdAt, + isDeleted = false, + ) + + private class FakeScheduleOutPort : ScheduleOutPort { + val schedules = mutableListOf() + + override fun save(schedule: Schedule): Schedule = schedule + override fun findById(scheduleId: ScheduleId): Schedule? = schedules.firstOrNull { it.id == scheduleId } + override fun findActiveByIdAndCircleId(scheduleId: ScheduleId, circleId: String): Schedule? = + schedules.firstOrNull { it.id == scheduleId && it.circleId == circleId && !it.isDeleted } + override fun findActiveByCircleId( + circleId: String, + today: LocalDate, + cursor: SchedulePageCursor?, + size: Int, + ): List = error("not used") + override fun findUpcomingByCircleId(circleId: String, today: LocalDate, limit: Int): List = error("not used") + override fun countActiveByCircleId(circleId: String): Long = error("not used") + override fun findActiveByStartDateAndCreatedBefore(startDate: LocalDate, createdBefore: LocalDateTime): List = error("not used") + override fun findActiveAllDayByStartDateAndCreatedBefore(startDate: LocalDate, createdBefore: LocalDateTime): List = error("not used") + override fun findActiveTimedByStartAtAndCreatedBefore(startDate: LocalDate, startTime: LocalTime, createdBefore: LocalDateTime): List = error("not used") + override fun findActiveConfirmationRequiredCreatedBetween( + createdFrom: LocalDateTime, + createdBefore: LocalDateTime, + ): List = schedules.filter { + !it.isDeleted && it.needConfirm && !it.createdAt.isBefore(createdFrom) && it.createdAt.isBefore(createdBefore) + } + } + + private class RecordingEventPublisher : EventPublisher { + val events = mutableListOf() + + override fun publish(event: Event) { + events += event + } + } +} diff --git a/src/test/kotlin/com/unicorn/server/domain/schedule/service/ScheduleConfirmationServiceTest.kt b/src/test/kotlin/com/unicorn/server/domain/schedule/service/ScheduleConfirmationServiceTest.kt index 6548d02..afec10c 100644 --- a/src/test/kotlin/com/unicorn/server/domain/schedule/service/ScheduleConfirmationServiceTest.kt +++ b/src/test/kotlin/com/unicorn/server/domain/schedule/service/ScheduleConfirmationServiceTest.kt @@ -1,9 +1,12 @@ package com.unicorn.server.domain.schedule.service import com.unicorn.server.common.exception.BusinessException +import com.unicorn.server.common.domain.Event +import com.unicorn.server.common.port.out.event.EventPublisher import com.unicorn.server.domain.schedule.Schedule import com.unicorn.server.domain.schedule.ScheduleConfirmation import com.unicorn.server.domain.schedule.enums.ConfirmationType +import com.unicorn.server.domain.schedule.event.ScheduleConfirmedEvent import com.unicorn.server.domain.schedule.exception.ScheduleErrorCode import com.unicorn.server.domain.schedule.port.dto.ConfirmationCountResult import com.unicorn.server.domain.schedule.port.dto.RegisterConfirmationCommand @@ -26,10 +29,12 @@ class ScheduleConfirmationServiceTest { private val scheduleOutPort = FakeScheduleOutPort() private val confirmationOutPort = FakeScheduleConfirmationOutPort() private val circleAccessOutPort = FakeCircleAccessOutPort() + private val eventPublisher = RecordingEventPublisher() private val scheduleConfirmationService = ScheduleConfirmationService( scheduleOutPort, confirmationOutPort, circleAccessOutPort, + eventPublisher, ) @Test @@ -44,6 +49,9 @@ class ScheduleConfirmationServiceTest { assertThat(type).isEqualTo(ConfirmationType.CONFIRMED) assertThat(confirmationOutPort.saved).hasSize(1) assertThat(confirmationOutPort.saved.single().memberId).isEqualTo(MEMBER_ID) + val event = eventPublisher.events.filterIsInstance().single() + assertThat(event.scheduleCreatorMemberId).isEqualTo("author") + assertThat(event.confirmerMemberId).isEqualTo(MEMBER_ID) } @Test @@ -160,9 +168,39 @@ class ScheduleConfirmationServiceTest { override fun findActiveByCircleId( circleId: String, + today: LocalDate, cursor: SchedulePageCursor?, size: Int, ): List = emptyList() + + override fun findActiveByStartDateAndCreatedBefore( + startDate: java.time.LocalDate, + createdBefore: java.time.LocalDateTime, + ): List = error("not used") + + override fun findActiveAllDayByStartDateAndCreatedBefore( + startDate: java.time.LocalDate, + createdBefore: java.time.LocalDateTime, + ): List = error("not used") + + override fun findActiveTimedByStartAtAndCreatedBefore( + startDate: java.time.LocalDate, + startTime: java.time.LocalTime, + createdBefore: java.time.LocalDateTime, + ): List = error("not used") + + override fun findActiveConfirmationRequiredCreatedBetween( + createdFrom: java.time.LocalDateTime, + createdBefore: java.time.LocalDateTime, + ): List = error("not used") + + override fun findUpcomingByCircleId( + circleId: String, + today: LocalDate, + limit: Int, + ): List = emptyList() + + override fun countActiveByCircleId(circleId: String): Long = 0L } private class FakeScheduleConfirmationOutPort : ScheduleConfirmationOutPort { @@ -211,6 +249,14 @@ class ScheduleConfirmationServiceTest { 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 val SCHEDULE_ID = ScheduleId.of("SC202407070000000001") diff --git a/src/test/kotlin/com/unicorn/server/domain/schedule/service/ScheduleQueryServiceTest.kt b/src/test/kotlin/com/unicorn/server/domain/schedule/service/ScheduleQueryServiceTest.kt index bf2abd5..7d959b0 100644 --- a/src/test/kotlin/com/unicorn/server/domain/schedule/service/ScheduleQueryServiceTest.kt +++ b/src/test/kotlin/com/unicorn/server/domain/schedule/service/ScheduleQueryServiceTest.kt @@ -185,15 +185,50 @@ class ScheduleQueryServiceTest { override fun findActiveByCircleId( circleId: String, + today: LocalDate, cursor: SchedulePageCursor?, size: Int, ): List = store.values - .filter { it.circleId == circleId && !it.isDeleted } + .filter { it.circleId == circleId && !it.isDeleted && !it.endDate.isBefore(today) } .sortedWith(compareBy { it.startDate }.thenBy { it.startTime ?: LocalTime.MIDNIGHT }.thenBy { it.id.value }) .dropWhile { cursor != null && !isAfterCursor(it, cursor) } .take(size) + override fun findActiveByStartDateAndCreatedBefore( + startDate: java.time.LocalDate, + createdBefore: java.time.LocalDateTime, + ): List = error("not used") + + override fun findActiveAllDayByStartDateAndCreatedBefore( + startDate: java.time.LocalDate, + createdBefore: java.time.LocalDateTime, + ): List = error("not used") + + override fun findActiveTimedByStartAtAndCreatedBefore( + startDate: java.time.LocalDate, + startTime: LocalTime, + createdBefore: java.time.LocalDateTime, + ): List = error("not used") + + override fun findActiveConfirmationRequiredCreatedBetween( + createdFrom: java.time.LocalDateTime, + createdBefore: java.time.LocalDateTime, + ): List = error("not used") + + override fun findUpcomingByCircleId( + circleId: String, + today: LocalDate, + limit: Int, + ): List = + store.values + .filter { it.circleId == circleId && !it.isDeleted && !it.endDate.isBefore(today) } + .sortedWith(compareBy { it.startDate }.thenBy { it.startTime ?: LocalTime.MIDNIGHT }.thenBy { it.id.value }) + .take(limit) + + override fun countActiveByCircleId(circleId: String): Long = + store.values.count { it.circleId == circleId && !it.isDeleted }.toLong() + private fun isAfterCursor(schedule: Schedule, cursor: SchedulePageCursor): Boolean { val dateCompare = schedule.startDate.compareTo(cursor.startDate) if (dateCompare != 0) return dateCompare > 0 diff --git a/src/test/kotlin/com/unicorn/server/domain/schedule/service/ScheduleReminderServiceTest.kt b/src/test/kotlin/com/unicorn/server/domain/schedule/service/ScheduleReminderServiceTest.kt new file mode 100644 index 0000000..080b1d8 --- /dev/null +++ b/src/test/kotlin/com/unicorn/server/domain/schedule/service/ScheduleReminderServiceTest.kt @@ -0,0 +1,151 @@ +package com.unicorn.server.domain.schedule.service + +import com.unicorn.server.common.domain.Event +import com.unicorn.server.common.port.out.event.EventPublisher +import com.unicorn.server.domain.schedule.Schedule +import com.unicorn.server.domain.schedule.enums.ScheduleReminderType +import com.unicorn.server.domain.schedule.event.ScheduleReminderDueEvent +import com.unicorn.server.domain.schedule.port.dto.SchedulePageCursor +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.junit.jupiter.api.DisplayName +import org.junit.jupiter.api.Test +import java.time.LocalDate +import java.time.LocalDateTime +import java.time.LocalTime + +@DisplayName("ScheduleReminderService 단위 테스트") +class ScheduleReminderServiceTest { + private val scheduleOutPort = FakeScheduleOutPort() + private val eventPublisher = RecordingEventPublisher() + private val service = ScheduleReminderService(scheduleOutPort, eventPublisher) + + @Test + @DisplayName("오전 9시 리마인드 실행 시 D-7, D-1, 종일 D-day 일정을 각각 이벤트로 발행한다") + fun dispatchDaily_withEligibleSchedules_publishesDailyReminderEvents() { + val now = LocalDateTime.of(2026, 8, 10, 9, 0) + scheduleOutPort.schedules += listOf( + schedule("SC-D7", now.toLocalDate().plusDays(7), null, now.minusDays(1)), + schedule("SC-D1", now.toLocalDate().plusDays(1), null, now.minusDays(1)), + schedule("SC-DDAY", now.toLocalDate(), null, now.minusDays(1)), + schedule("SC-DDAY-TODAY", now.toLocalDate(), null, now.minusHours(1)), + schedule("SC-LATE", now.toLocalDate().plusDays(7), null, now.plusMinutes(1)), + schedule("SC-DELETED", now.toLocalDate().plusDays(1), null, now.minusDays(1), isDeleted = true), + ) + + service.dispatchDaily(now) + + val events = eventPublisher.events.filterIsInstance() + assertThat(events.map { it.reminderType to it.scheduleId }) + .containsExactlyInAnyOrder( + ScheduleReminderType.D7 to "SC-D7", + ScheduleReminderType.D1 to "SC-D1", + ScheduleReminderType.DDAY_ALL_DAY to "SC-DDAY", + ) + } + + @Test + @DisplayName("시간 일정은 시작 1시간 전에 생성 시각 조건을 만족할 때만 D-day 이벤트를 발행한다") + fun dispatchTimed_withEligibleTimedSchedule_publishesTimedReminderEvent() { + val now = LocalDateTime.of(2026, 8, 10, 23, 30) + val startAt = now.plusHours(1) + scheduleOutPort.schedules += listOf( + schedule("SC-TIMED", startAt.toLocalDate(), startAt.toLocalTime(), now.minusHours(1)), + schedule("SC-TOO-LATE", startAt.toLocalDate(), startAt.toLocalTime(), now.plusMinutes(1)), + ) + + service.dispatchTimed(now) + + val event = eventPublisher.events.filterIsInstance().single() + assertThat(event.reminderType).isEqualTo(ScheduleReminderType.DDAY_TIMED) + assertThat(event.scheduleId).isEqualTo("SC-TIMED") + assertThat(event.startTime).isEqualTo(LocalTime.of(0, 30)) + } + + private fun schedule( + id: String, + startDate: LocalDate, + startTime: LocalTime?, + createdAt: LocalDateTime, + isDeleted: Boolean = false, + ): Schedule = Schedule.reconstitute( + id = ScheduleId.of(id), + circleId = "circle-1", + title = "일정 $id", + startDate = startDate, + endDate = startDate, + startTime = startTime, + endTime = startTime?.plusHours(1), + needConfirm = false, + memo = null, + createdBy = "creator", + updatedBy = "creator", + createdAt = createdAt, + updatedAt = createdAt, + isDeleted = isDeleted, + ) + + private class FakeScheduleOutPort : ScheduleOutPort { + val schedules = mutableListOf() + + override fun save(schedule: Schedule): Schedule = schedule + + override fun findById(scheduleId: ScheduleId): Schedule? = + schedules.firstOrNull { it.id == scheduleId } + + override fun findActiveByIdAndCircleId(scheduleId: ScheduleId, circleId: String): Schedule? = + schedules.firstOrNull { it.id == scheduleId && it.circleId == circleId && !it.isDeleted } + + override fun findActiveByCircleId( + circleId: String, + today: LocalDate, + cursor: SchedulePageCursor?, + size: Int, + ): List = error("not used") + + override fun findUpcomingByCircleId(circleId: String, today: LocalDate, limit: Int): List = + error("not used") + + override fun countActiveByCircleId(circleId: String): Long = error("not used") + + override fun findActiveByStartDateAndCreatedBefore( + startDate: LocalDate, + createdBefore: LocalDateTime, + ): List = + schedules.filter { !it.isDeleted && it.startDate == startDate && it.createdAt.isBefore(createdBefore) } + + override fun findActiveAllDayByStartDateAndCreatedBefore( + startDate: LocalDate, + createdBefore: LocalDateTime, + ): List = + schedules.filter { + !it.isDeleted && it.isAllDay && it.startDate == startDate && it.createdAt.isBefore(createdBefore) + } + + override fun findActiveTimedByStartAtAndCreatedBefore( + startDate: LocalDate, + startTime: LocalTime, + createdBefore: LocalDateTime, + ): List = + schedules.filter { + !it.isDeleted && + it.startDate == startDate && + it.startTime == startTime && + it.createdAt.isBefore(createdBefore) + } + + override fun findActiveConfirmationRequiredCreatedBetween( + createdFrom: LocalDateTime, + createdBefore: LocalDateTime, + ): List = error("not used") + } + + private class RecordingEventPublisher : EventPublisher { + val events = mutableListOf() + + override fun publish(event: Event) { + events += event + } + } +} 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 new file mode 100644 index 0000000..8497e95 --- /dev/null +++ b/src/test/kotlin/com/unicorn/server/infrastructure/adapter/in/event/schedule/ScheduleNotificationEventListenerTest.kt @@ -0,0 +1,265 @@ +package com.unicorn.server.infrastructure.adapter.`in`.event.schedule + +import com.unicorn.server.common.domain.Event +import com.unicorn.server.common.port.out.event.EventPublisher +import com.unicorn.server.domain.circle.port.`in`.CircleMemberInPort +import com.unicorn.server.domain.circle.port.dto.CircleMemberDto +import com.unicorn.server.domain.circle.port.dto.CircleSummary +import com.unicorn.server.domain.circle.port.dto.JoinCircleResult +import com.unicorn.server.domain.notification.DevicePushToken +import com.unicorn.server.domain.notification.NotificationSetting +import com.unicorn.server.domain.notification.enums.DevicePlatform +import com.unicorn.server.domain.notification.enums.NotificationChannel +import com.unicorn.server.domain.notification.enums.NotificationEventType +import com.unicorn.server.domain.notification.event.NotificationRequestedEvent +import com.unicorn.server.domain.notification.event.ScheduleCreatedPayload +import com.unicorn.server.domain.notification.port.`in`.NotificationPushTokenInPort +import com.unicorn.server.domain.notification.port.`in`.NotificationSettingInPort +import com.unicorn.server.domain.notification.port.dto.RegisterPushTokenCommand +import com.unicorn.server.domain.notification.port.dto.UpdateNotificationSettingCommand +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.enums.ScheduleReminderType +import com.unicorn.server.domain.schedule.event.ScheduleReminderDueEvent +import com.unicorn.server.domain.schedule.port.`in`.ScheduleConfirmationStatusInPort +import com.unicorn.server.domain.schedule.vo.ScheduleId +import org.assertj.core.api.Assertions.assertThat +import org.junit.jupiter.api.DisplayName +import org.junit.jupiter.api.Test +import java.time.LocalDateTime + +@DisplayName("ScheduleNotificationEventListener 단위 테스트") +class ScheduleNotificationEventListenerTest { + @Test + @DisplayName("일정 생성 시 작성자를 포함한 활성 구성원의 모든 활성 토큰에 기존 알림 요청 이벤트를 발행한다") + fun handle_scheduleCreated_publishesNotificationRequestForEveryActiveToken() { + val circleMemberInPort = FakeCircleMemberInPort( + listOf( + CircleMemberDto("creator", "유니콘", "MEMBER", true), + CircleMemberDto("family", "가족", "MEMBER", true), + CircleMemberDto("inactive", "비활성", "MEMBER", false), + ), + ) + val notificationPushTokenInPort = FakeNotificationPushTokenInPort( + mapOf( + "creator" to listOf(pushToken(1, "creator-token")), + "family" to listOf(pushToken(2, "family-token-1"), pushToken(3, "family-token-2")), + "inactive" to listOf(pushToken(4, "inactive-token")), + ), + ) + val eventPublisher = RecordingEventPublisher() + val listener = ScheduleNotificationEventListener( + circleMemberInPort, + notificationPushTokenInPort, + FakeNotificationSettingInPort(), + FakeScheduleConfirmationStatusInPort(), + eventPublisher, + ) + + listener.handle( + ScheduleCreatedEvent( + scheduleId = "SC1", + circleId = "circle-1", + creatorMemberId = "creator", + scheduleTitle = "제주도 여행", + ), + ) + + val events = eventPublisher.events.filterIsInstance() + assertThat(events).extracting { it.receiver } + .containsExactlyInAnyOrder("creator-token", "family-token-1", "family-token-2") + assertThat(events).allSatisfy { event -> + assertThat(event.channel).isEqualTo(NotificationChannel.PUSH) + assertThat(event.payload.eventType).isEqualTo(NotificationEventType.SCHEDULE_CREATED) + assertThat(event.payload).isEqualTo(ScheduleCreatedPayload("유니콘", "제주도 여행")) + } + assertThat(events.map { it.dedupKey }).doesNotHaveDuplicates() + } + + @Test + @DisplayName("D-7 리마인드는 D-7 설정이 ON인 활성 구성원의 토큰에만 알림을 요청한다") + fun handle_d7Reminder_requestsPushOnlyForMembersWithD7Enabled() { + val circleMemberInPort = FakeCircleMemberInPort( + listOf( + CircleMemberDto("enabled", "수신", "MEMBER", true), + CircleMemberDto("disabled", "미수신", "MEMBER", true), + CircleMemberDto("inactive", "비활성", "MEMBER", false), + ), + ) + val notificationPushTokenInPort = FakeNotificationPushTokenInPort( + mapOf( + "enabled" to listOf(pushToken(1, "enabled-token")), + "disabled" to listOf(pushToken(2, "disabled-token")), + "inactive" to listOf(pushToken(3, "inactive-token")), + ), + ) + val eventPublisher = RecordingEventPublisher() + val listener = ScheduleNotificationEventListener( + circleMemberInPort, + notificationPushTokenInPort, + FakeNotificationSettingInPort(disabledMembers = setOf("disabled")), + FakeScheduleConfirmationStatusInPort(), + eventPublisher, + ) + + listener.handle( + ScheduleReminderDueEvent( + reminderType = ScheduleReminderType.D7, + scheduleId = "SC1", + circleId = "circle-1", + scheduleTitle = "제주도 여행", + startTime = null, + ), + ) + + val events = eventPublisher.events.filterIsInstance() + val event = events.single() + assertThat(event.receiver).isEqualTo("enabled-token") + assertThat(event.channel).isEqualTo(NotificationChannel.PUSH) + assertThat(event.payload.toVariables()).containsEntry("schedule_title", "제주도 여행") + assertThat(event.dedupKey).isEqualTo("schedule-reminder:d7:SC1:enabled:token:1") + } + + @Test + @DisplayName("다른 구성원이 확인했어요를 누르면 작성자 설정이 ON일 때 작성자 토큰에 알림을 요청한다") + fun handle_scheduleConfirmed_requestsPushForScheduleCreator() { + val eventPublisher = RecordingEventPublisher() + val listener = ScheduleNotificationEventListener( + FakeCircleMemberInPort( + listOf( + CircleMemberDto("creator", "작성자", "MEMBER", true), + CircleMemberDto("confirmer", "가족", "MEMBER", true), + ), + ), + FakeNotificationPushTokenInPort(mapOf("creator" to listOf(pushToken(1, "creator-token")))), + FakeNotificationSettingInPort(), + FakeScheduleConfirmationStatusInPort(), + eventPublisher, + ) + + listener.handle( + ScheduleConfirmedEvent( + scheduleId = "SC1", + circleId = "circle-1", + scheduleCreatorMemberId = "creator", + confirmerMemberId = "confirmer", + scheduleTitle = "제주도 여행", + ), + ) + + val event = eventPublisher.events.filterIsInstance().single() + assertThat(event.receiver).isEqualTo("creator-token") + assertThat(event.payload.toVariables()).containsEntry("member_name", "가족") + assertThat(event.dedupKey).isEqualTo("schedule-confirmed:SC1:confirmer:creator:token:1") + } + + @Test + @DisplayName("24시간 미확인 요청은 작성자와 이미 확인한 구성원을 제외하고 설정 ON 구성원에게 요청한다") + fun handle_confirmationRequestDue_requestsPushForUnconfirmedMembers() { + val eventPublisher = RecordingEventPublisher() + val listener = ScheduleNotificationEventListener( + FakeCircleMemberInPort( + listOf( + CircleMemberDto("creator", "작성자", "MEMBER", true), + CircleMemberDto("unconfirmed", "미확인", "MEMBER", true), + CircleMemberDto("confirmed", "확인", "MEMBER", true), + ), + ), + FakeNotificationPushTokenInPort( + mapOf( + "unconfirmed" to listOf(pushToken(1, "unconfirmed-token")), + "confirmed" to listOf(pushToken(2, "confirmed-token")), + ), + ), + FakeNotificationSettingInPort(), + FakeScheduleConfirmationStatusInPort(confirmedMembers = setOf("confirmed")), + eventPublisher, + ) + + listener.handle( + ScheduleConfirmationRequestDueEvent( + scheduleId = "SC1", + circleId = "circle-1", + scheduleCreatorMemberId = "creator", + scheduleTitle = "제주도 여행", + ), + ) + + val event = eventPublisher.events.filterIsInstance().single() + assertThat(event.receiver).isEqualTo("unconfirmed-token") + assertThat(event.payload.toVariables()).containsEntry("schedule_title", "제주도 여행") + assertThat(event.dedupKey).isEqualTo("schedule-confirmation-request:SC1:unconfirmed:token:1") + } + + private fun pushToken(id: Long, token: String): DevicePushToken { + val now = LocalDateTime.now() + return DevicePushToken.reconstitute( + id = DevicePushTokenId.of(id), + memberId = "member-$id", + installationId = "installation-$id", + token = token, + platform = DevicePlatform.IOS, + active = true, + lastSeenAt = now, + invalidatedAt = null, + createdAt = now, + updatedAt = now, + ) + } + + private class FakeCircleMemberInPort( + private val members: List, + ) : CircleMemberInPort { + override fun getCircleMembers(circleId: String): List = members + override fun join(circleId: String, memberId: String): JoinCircleResult = error("not used") + override fun leave(circleId: String, memberId: String) = error("not used") + override fun isCircleMember(circleId: String, memberId: String): Boolean = error("not used") + override fun transferInitiator(circleId: String, currentInitiatorId: String, newInitiatorId: String): CircleSummary = error("not used") + override fun handleMemberWithdrawal(memberId: String) = error("not used") + } + + private class FakeNotificationPushTokenInPort( + private val tokensByMemberId: Map>, + ) : NotificationPushTokenInPort { + override fun getActiveReceivable(memberId: String): List = tokensByMemberId[memberId].orEmpty() + override fun register(memberId: String, command: RegisterPushTokenCommand): DevicePushToken = error("not used") + override fun deactivate(memberId: String, tokenId: Long) = error("not used") + } + + private class FakeNotificationSettingInPort( + private val disabledMembers: Set = emptySet(), + ) : NotificationSettingInPort { + override fun getSetting(memberId: String): NotificationSetting { + val enabled = memberId !in disabledMembers + val now = LocalDateTime.now() + return NotificationSetting.reconstitute( + memberId = memberId, + d7Enabled = enabled, + d1Enabled = enabled, + dDayEnabled = enabled, + familyScheduleCheckEnabled = enabled, + createdAt = now, + updatedAt = now, + ) + } + + override fun updateSetting(memberId: String, command: UpdateNotificationSettingCommand): NotificationSetting = + error("not used") + } + + private class FakeScheduleConfirmationStatusInPort( + private val confirmedMembers: Set = emptySet(), + ) : ScheduleConfirmationStatusInPort { + override fun hasConfirmed(scheduleId: ScheduleId, memberId: String): Boolean = memberId in confirmedMembers + } + + private class RecordingEventPublisher : EventPublisher { + val events = mutableListOf() + + override fun publish(event: Event) { + events += event + } + } +} 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 01e92e2..d95428f 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 @@ -1,6 +1,10 @@ package com.unicorn.server.infrastructure.adapter.`in`.web.schedule import com.fasterxml.jackson.databind.ObjectMapper +import com.unicorn.server.domain.circle.port.`in`.CircleMemberInPort +import com.unicorn.server.domain.circle.port.dto.CircleMemberDto +import com.unicorn.server.domain.circle.port.dto.CircleSummary +import com.unicorn.server.domain.circle.port.dto.JoinCircleResult import com.unicorn.server.domain.member.enums.Role import com.unicorn.server.domain.schedule.port.out.CircleAccessOutPort import com.unicorn.server.infrastructure.adapter.out.persistence.schedule.ScheduleConfirmationJpaRepository @@ -58,6 +62,36 @@ class ScheduleControllerTest( override fun isInitiator(circleId: String, memberId: String): Boolean = circleId == CIRCLE_ID && memberId == INITIATOR_ID } + + @Bean + @Primary + fun fakeCircleMemberInPort(): CircleMemberInPort = object : CircleMemberInPort { + override fun getCircleMembers(circleId: String): List = + if (circleId == CIRCLE_ID) { + listOf( + CircleMemberDto(AUTHOR_ID, "작성자", "MEMBER", true), + CircleMemberDto(INITIATOR_ID, "initiator", "INITIATOR", true), + CircleMemberDto(OTHER_MEMBER_ID, "구성원", "MEMBER", true), + ) + } else { + emptyList() + } + + override fun join(circleId: String, memberId: String): JoinCircleResult = error("not used") + + override fun leave(circleId: String, memberId: String) = error("not used") + + override fun isCircleMember(circleId: String, memberId: String): Boolean = + circleId == CIRCLE_ID && memberId in setOf(AUTHOR_ID, INITIATOR_ID, OTHER_MEMBER_ID) + + override fun transferInitiator( + circleId: String, + currentInitiatorId: String, + newInitiatorId: String, + ): CircleSummary = error("not used") + + override fun handleMemberWithdrawal(memberId: String) = error("not used") + } } @BeforeEach diff --git a/src/test/kotlin/com/unicorn/server/infrastructure/adapter/out/persistence/schedule/SchedulePersistenceAdapterTest.kt b/src/test/kotlin/com/unicorn/server/infrastructure/adapter/out/persistence/schedule/SchedulePersistenceAdapterTest.kt index ffe84d6..d85d382 100644 --- a/src/test/kotlin/com/unicorn/server/infrastructure/adapter/out/persistence/schedule/SchedulePersistenceAdapterTest.kt +++ b/src/test/kotlin/com/unicorn/server/infrastructure/adapter/out/persistence/schedule/SchedulePersistenceAdapterTest.kt @@ -90,8 +90,9 @@ class SchedulePersistenceAdapterTest( schedule(circleId = "CC000000000000000104", title = "세 번째", startDate = LocalDate.of(2024, 8, 3), startTime = LocalTime.of(9, 0)), ) - val firstPage = schedulePersistenceAdapter.findActiveByCircleId("CC000000000000000104", null, 2) - val secondPage = schedulePersistenceAdapter.findActiveByCircleId("CC000000000000000104", SchedulePageCursor.from(second), 2) + val today = LocalDate.of(2024, 8, 1) + val firstPage = schedulePersistenceAdapter.findActiveByCircleId("CC000000000000000104", today, null, 2) + val secondPage = schedulePersistenceAdapter.findActiveByCircleId("CC000000000000000104", today, SchedulePageCursor.from(second), 2) assertThat(firstPage.map { it.id }).containsExactly(first.id, second.id) assertThat(secondPage.map { it.id }).containsExactly(third.id)