From ac3e07244ab8b2c2ceba130014ddb1bceca2c402 Mon Sep 17 00:00:00 2001 From: junyong Date: Sat, 18 Jul 2026 16:13:47 +0900 Subject: [PATCH 1/7] =?UTF-8?q?fix[SAIO-164]:=20dev/main=EC=97=90=EC=84=9C?= =?UTF-8?q?=20=EC=82=AC=EB=9D=BC=EC=A7=84=20=EC=9D=BC=EC=A0=95-=ED=99=88?= =?UTF-8?q?=20=EC=97=B0=EB=8F=99=20=EB=B3=80=EA=B2=BD=EC=82=AC=ED=95=AD=20?= =?UTF-8?q?=EB=B3=B5=EA=B5=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PR #49(085de07)에서 dev를 다른 브랜치로 병합할 때, 이미 재병합(PR #48)된 feature/SAIO-164 변경분이 base와 동일한 내용으로 인식되어 되돌리기(PR #47) 쪽 삭제가 충돌 없이 그대로 적용되며 사라졌다. 이후 main/dev 양쪽에 이 상태가 그대로 이어져왔다. 005d654(PR #48, 마지막으로 정상 반영된 상태) 기준으로 UrgencyLevel, isMe 스웨거 직렬화, 홈 응답의 ScheduleView 연동 등을 복구했다. --- .../domain/home/port/dto/HomeMemberDto.kt | 2 ++ .../server/domain/home/port/dto/HomeView.kt | 6 ++-- .../domain/home/service/HomeQueryService.kt | 14 ++++++++-- .../domain/schedule/enums/UrgencyLevel.kt | 17 +++++++++++ .../schedule/port/dto/ScheduleDetailResult.kt | 2 ++ .../port/dto/ScheduleSummaryResult.kt | 2 ++ .../domain/schedule/port/dto/ScheduleView.kt | 12 -------- .../port/in/GetSchedulesForCircleInPort.kt | 6 ++-- .../schedule/port/out/ScheduleOutPort.kt | 10 +++++++ .../schedule/service/ScheduleQueryService.kt | 28 +++++++++++++++---- .../in/web/circle/dto/CircleHomeResponse.kt | 9 +++--- .../adapter/in/web/home/HomeApiDoc.kt | 3 +- .../adapter/in/web/schedule/ScheduleApiDoc.kt | 8 ++++++ .../schedule/dto/ScheduleDetailResponse.kt | 13 +++++++++ .../schedule/dto/ScheduleSummaryResponse.kt | 13 +++++++++ .../schedule/ScheduleJpaRepository.kt | 27 ++++++++++++++++++ .../schedule/SchedulePersistenceAdapter.kt | 17 ++++++++++- .../out/schedule/NoOpScheduleQueryAdapter.kt | 14 ---------- .../domain/home/HomeQueryServiceTest.kt | 5 +--- .../service/CancelConfirmationServiceTest.kt | 9 ++++++ .../service/ScheduleCommandServiceTest.kt | 15 +++++++++- .../ScheduleConfirmationServiceTest.kt | 9 ++++++ .../service/ScheduleQueryServiceTest.kt | 16 ++++++++++- .../SchedulePersistenceAdapterTest.kt | 5 ++-- 24 files changed, 208 insertions(+), 54 deletions(-) create mode 100644 src/main/kotlin/com/unicorn/server/domain/schedule/enums/UrgencyLevel.kt delete mode 100644 src/main/kotlin/com/unicorn/server/domain/schedule/port/dto/ScheduleView.kt delete mode 100644 src/main/kotlin/com/unicorn/server/infrastructure/adapter/out/schedule/NoOpScheduleQueryAdapter.kt 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/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/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/out/ScheduleOutPort.kt b/src/main/kotlin/com/unicorn/server/domain/schedule/port/out/ScheduleOutPort.kt index d971c06..9105311 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,7 @@ 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 interface ScheduleOutPort { fun save(schedule: Schedule): Schedule @@ -13,7 +14,16 @@ interface ScheduleOutPort { fun findActiveByCircleId( circleId: String, + today: LocalDate, cursor: SchedulePageCursor?, size: Int, ): 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/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/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/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..441273a 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 @@ -17,6 +17,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 +27,7 @@ interface ScheduleJpaRepository : JpaRepository { ) fun findFirstPage( @Param("circleId") circleId: String, + @Param("today") today: LocalDate, @Param("size") size: Int, ): List @@ -35,6 +37,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 +52,33 @@ 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 + + // 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..1a27802 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,7 @@ 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.LocalTime @PersistenceAdapter @@ -36,14 +37,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 +56,16 @@ class SchedulePersistenceAdapter( return entities.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/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/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..04647ea 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,18 @@ class CancelConfirmationServiceTest { override fun findActiveByCircleId( circleId: String, + today: LocalDate, cursor: SchedulePageCursor?, size: Int, ): List = emptyList() + + 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..38d9c4c 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 @@ -239,9 +239,22 @@ 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 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 { 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..cd278f8 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 @@ -160,9 +160,18 @@ class ScheduleConfirmationServiceTest { override fun findActiveByCircleId( circleId: String, + today: LocalDate, cursor: SchedulePageCursor?, size: Int, ): List = emptyList() + + 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/ScheduleQueryServiceTest.kt b/src/test/kotlin/com/unicorn/server/domain/schedule/service/ScheduleQueryServiceTest.kt index bf2abd5..dfc22f1 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,29 @@ 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 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/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) From d3d290fcc679674140e6c4dcca911d79be3ab3ff Mon Sep 17 00:00:00 2001 From: junyong Date: Sat, 18 Jul 2026 16:35:49 +0900 Subject: [PATCH 2/7] =?UTF-8?q?fix[SAIO-164]:=20=ED=99=88=20=EC=8D=A8?= =?UTF-8?q?=ED=81=B4=20=EB=A9=A4=EB=B2=84=20=ED=99=94=EB=A9=B4=20me=20->?= =?UTF-8?q?=20isMe?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../adapter/in/web/circle/dto/CircleMemberResponse.kt | 2 ++ 1 file changed, 2 insertions(+) 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") From 961e46fa92d65b696aec17a6154da210d0008631 Mon Sep 17 00:00:00 2001 From: junyong Date: Thu, 23 Jul 2026 21:05:24 +0900 Subject: [PATCH 3/7] =?UTF-8?q?feat[SAIO-265]:=20=EC=9D=BC=EC=A0=95=20?= =?UTF-8?q?=EC=83=9D=EC=84=B1=20=EC=95=8C=EB=A6=BC=20=EC=97=B0=EB=8F=99?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../schedule/event/ScheduleCreatedEvent.kt | 10 ++ .../service/ScheduleCommandService.kt | 14 ++- .../ScheduleNotificationEventListener.kt | 47 ++++++++ .../service/ScheduleCommandServiceTest.kt | 18 +++ .../ScheduleNotificationEventListenerTest.kt | 109 ++++++++++++++++++ 5 files changed, 197 insertions(+), 1 deletion(-) create mode 100644 src/main/kotlin/com/unicorn/server/domain/schedule/event/ScheduleCreatedEvent.kt create mode 100644 src/main/kotlin/com/unicorn/server/infrastructure/adapter/in/event/schedule/ScheduleNotificationEventListener.kt create mode 100644 src/test/kotlin/com/unicorn/server/infrastructure/adapter/in/event/schedule/ScheduleNotificationEventListenerTest.kt 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/service/ScheduleCommandService.kt b/src/main/kotlin/com/unicorn/server/domain/schedule/service/ScheduleCommandService.kt index 707ed96..fb153d1 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,9 @@ 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.exception.ScheduleErrorCode import com.unicorn.server.domain.schedule.port.`in`.CreateScheduleInPort import com.unicorn.server.domain.schedule.port.`in`.DeleteScheduleInPort @@ -23,6 +25,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 +50,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) { 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..cf3dc33 --- /dev/null +++ b/src/main/kotlin/com/unicorn/server/infrastructure/adapter/in/event/schedule/ScheduleNotificationEventListener.kt @@ -0,0 +1,47 @@ +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.event.NotificationRequestedEvent +import com.unicorn.server.domain.notification.event.ScheduleCreatedPayload +import com.unicorn.server.domain.notification.port.`in`.NotificationPushTokenInPort +import com.unicorn.server.domain.schedule.event.ScheduleCreatedEvent +import org.springframework.context.event.EventListener +import org.springframework.stereotype.Component + +@Component +class ScheduleNotificationEventListener( + private val circleMemberInPort: CircleMemberInPort, + private val notificationPushTokenInPort: NotificationPushTokenInPort, + 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}", + ), + ) + } + } + } + + private fun List.nicknameOf(memberId: String): String = + firstOrNull { it.memberId == memberId }?.nickname + ?: error("Active circle member not found: memberId=$memberId") +} 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..e6b976e 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,11 @@ 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.exception.ScheduleErrorCode import com.unicorn.server.domain.schedule.port.dto.ConfirmationCountResult import com.unicorn.server.domain.schedule.port.dto.CreateScheduleCommand @@ -29,11 +32,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 +53,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 @@ -301,6 +311,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/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..e817075 --- /dev/null +++ b/src/test/kotlin/com/unicorn/server/infrastructure/adapter/in/event/schedule/ScheduleNotificationEventListenerTest.kt @@ -0,0 +1,109 @@ +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.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.dto.RegisterPushTokenCommand +import com.unicorn.server.domain.notification.vo.DevicePushTokenId +import com.unicorn.server.domain.schedule.event.ScheduleCreatedEvent +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, 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() + } + + private fun pushToken(id: Long, token: String): DevicePushToken { + val now = LocalDateTime.now() + return DevicePushToken.reconstitute( + id = DevicePushTokenId.of(id), + memberId = "member-$id", + token = token, + platform = DevicePlatform.IOS, + osNotificationPermissionGranted = true, + appVersion = null, + 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 RecordingEventPublisher : EventPublisher { + val events = mutableListOf() + + override fun publish(event: Event) { + events += event + } + } +} From b0d5255582a48cc204d4473ebb2f5e616e6fe9f7 Mon Sep 17 00:00:00 2001 From: junyong Date: Thu, 23 Jul 2026 22:07:32 +0900 Subject: [PATCH 4/7] =?UTF-8?q?feat[SAIO-256]:=20=EC=9D=BC=EC=A0=95=20D-7?= =?UTF-8?q?=20/=20D-1=20/=20D-day=20=EB=A6=AC=EB=A7=88=EC=9D=B8=EB=93=9C?= =?UTF-8?q?=20=EC=95=8C=EB=A6=BC=20=EC=97=B0=EB=8F=99?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../schedule/enums/ScheduleReminderType.kt | 8 + .../event/ScheduleReminderDueEvent.kt | 13 ++ .../port/in/ScheduleReminderInPort.kt | 9 ++ .../schedule/port/out/ScheduleOutPort.kt | 19 +++ .../service/ScheduleReminderService.kt | 76 ++++++++++ .../ScheduleNotificationEventListener.kt | 78 ++++++++-- .../schedule/ScheduleReminderScheduler.kt | 27 ++++ .../schedule/ScheduleJpaRepository.kt | 49 ++++++ .../schedule/SchedulePersistenceAdapter.kt | 24 +++ .../schedule/entity/ScheduleEntity.kt | 2 + ...1.0.3.3__add_schedule_reminder_indexes.sql | 5 + .../service/CancelConfirmationServiceTest.kt | 16 ++ .../service/ScheduleCommandServiceTest.kt | 16 ++ .../ScheduleConfirmationServiceTest.kt | 16 ++ .../service/ScheduleQueryServiceTest.kt | 16 ++ .../service/ScheduleReminderServiceTest.kt | 140 ++++++++++++++++++ .../ScheduleNotificationEventListenerTest.kt | 77 +++++++++- 17 files changed, 579 insertions(+), 12 deletions(-) create mode 100644 src/main/kotlin/com/unicorn/server/domain/schedule/enums/ScheduleReminderType.kt create mode 100644 src/main/kotlin/com/unicorn/server/domain/schedule/event/ScheduleReminderDueEvent.kt create mode 100644 src/main/kotlin/com/unicorn/server/domain/schedule/port/in/ScheduleReminderInPort.kt create mode 100644 src/main/kotlin/com/unicorn/server/domain/schedule/service/ScheduleReminderService.kt create mode 100644 src/main/kotlin/com/unicorn/server/infrastructure/adapter/in/scheduler/schedule/ScheduleReminderScheduler.kt create mode 100644 src/main/resources/db/migration/V1.0.3.3__add_schedule_reminder_indexes.sql create mode 100644 src/test/kotlin/com/unicorn/server/domain/schedule/service/ScheduleReminderServiceTest.kt 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/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/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..918bf97 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 @@ -16,4 +19,20 @@ interface ScheduleOutPort { 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 } 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 index cf3dc33..e8695d6 100644 --- a/src/main/kotlin/com/unicorn/server/infrastructure/adapter/in/event/schedule/ScheduleNotificationEventListener.kt +++ b/src/main/kotlin/com/unicorn/server/infrastructure/adapter/in/event/schedule/ScheduleNotificationEventListener.kt @@ -4,17 +4,27 @@ 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.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.enums.ScheduleReminderType +import com.unicorn.server.domain.schedule.event.ScheduleReminderDueEvent 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 eventPublisher: EventPublisher, ) { @EventListener @@ -27,21 +37,67 @@ class ScheduleNotificationEventListener( .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}", - ), - ) + 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}", + ), + ) + } } - } } 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..b8a3c7b --- /dev/null +++ b/src/main/kotlin/com/unicorn/server/infrastructure/adapter/in/scheduler/schedule/ScheduleReminderScheduler.kt @@ -0,0 +1,27 @@ +package com.unicorn.server.infrastructure.adapter.`in`.scheduler.schedule + +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, +) { + @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)) + } + + 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/out/persistence/schedule/ScheduleJpaRepository.kt b/src/main/kotlin/com/unicorn/server/infrastructure/adapter/out/persistence/schedule/ScheduleJpaRepository.kt index e6011e5..ed5ecdc 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 { @@ -54,4 +55,52 @@ interface ScheduleJpaRepository : JpaRepository { @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 } 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..d92087d 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 @@ -53,4 +55,26 @@ 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() } } 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..4d43853 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,8 @@ 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"), ], ) class ScheduleEntity protected constructor() { diff --git a/src/main/resources/db/migration/V1.0.3.3__add_schedule_reminder_indexes.sql b/src/main/resources/db/migration/V1.0.3.3__add_schedule_reminder_indexes.sql new file mode 100644 index 0000000..7a10379 --- /dev/null +++ b/src/main/resources/db/migration/V1.0.3.3__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/test/kotlin/com/unicorn/server/domain/schedule/service/CancelConfirmationServiceTest.kt b/src/test/kotlin/com/unicorn/server/domain/schedule/service/CancelConfirmationServiceTest.kt index 769246c..db8a592 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 @@ -118,6 +118,22 @@ class CancelConfirmationServiceTest { 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") } 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 e6b976e..7b8d012 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 @@ -252,6 +252,22 @@ class ScheduleCommandServiceTest { cursor: SchedulePageCursor?, size: Int, ): List = store.values.filter { it.circleId == circleId && !it.isDeleted }.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") } private class FakeScheduleConfirmationOutPort : ScheduleConfirmationOutPort { 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..a38e244 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 @@ -163,6 +163,22 @@ class ScheduleConfirmationServiceTest { 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") } private class FakeScheduleConfirmationOutPort : ScheduleConfirmationOutPort { 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..66731e4 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 @@ -194,6 +194,22 @@ class ScheduleQueryServiceTest { .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") + 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..2a5c764 --- /dev/null +++ b/src/test/kotlin/com/unicorn/server/domain/schedule/service/ScheduleReminderServiceTest.kt @@ -0,0 +1,140 @@ +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, + cursor: SchedulePageCursor?, + size: Int, + ): List = 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) + } + } + + 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 index e817075..f6f3b40 100644 --- a/src/test/kotlin/com/unicorn/server/infrastructure/adapter/in/event/schedule/ScheduleNotificationEventListenerTest.kt +++ b/src/test/kotlin/com/unicorn/server/infrastructure/adapter/in/event/schedule/ScheduleNotificationEventListenerTest.kt @@ -7,15 +7,20 @@ 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.enums.ScheduleReminderType +import com.unicorn.server.domain.schedule.event.ScheduleReminderDueEvent import org.assertj.core.api.Assertions.assertThat import org.junit.jupiter.api.DisplayName import org.junit.jupiter.api.Test @@ -41,7 +46,12 @@ class ScheduleNotificationEventListenerTest { ), ) val eventPublisher = RecordingEventPublisher() - val listener = ScheduleNotificationEventListener(circleMemberInPort, notificationPushTokenInPort, eventPublisher) + val listener = ScheduleNotificationEventListener( + circleMemberInPort, + notificationPushTokenInPort, + FakeNotificationSettingInPort(), + eventPublisher, + ) listener.handle( ScheduleCreatedEvent( @@ -63,6 +73,50 @@ class ScheduleNotificationEventListenerTest { 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")), + eventPublisher, + ) + + listener.handle( + ScheduleReminderDueEvent( + reminderType = ScheduleReminderType.D7, + scheduleId = "SC1", + circleId = "circle-1", + scheduleTitle = "제주도 여행", + startTime = null, + ), + ) + + val events = eventPublisher.events.filterIsInstance() + assertThat(events).singleElement().satisfies { event -> + 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") + } + } + private fun pushToken(id: Long, token: String): DevicePushToken { val now = LocalDateTime.now() return DevicePushToken.reconstitute( @@ -99,6 +153,27 @@ class ScheduleNotificationEventListenerTest { 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 RecordingEventPublisher : EventPublisher { val events = mutableListOf() From 6566b165cdeeae22099454ca23a8ce7e4d8eded2 Mon Sep 17 00:00:00 2001 From: junyong Date: Thu, 23 Jul 2026 23:07:07 +0900 Subject: [PATCH 5/7] =?UTF-8?q?feat[SAIO-265]:=20=EC=9D=BC=EC=A0=95=20?= =?UTF-8?q?=ED=99=95=EC=9D=B8=20=EC=95=8C=EB=A6=BC=20=EC=97=B0=EB=8F=99?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../ScheduleConfirmationRequestDueEvent.kt | 10 +++ .../schedule/event/ScheduleConfirmedEvent.kt | 11 +++ .../schedule/event/ScheduleDeletedEvent.kt | 10 +++ .../in/ScheduleConfirmationRequestInPort.kt | 7 ++ .../in/ScheduleConfirmationStatusInPort.kt | 7 ++ .../schedule/port/out/ScheduleOutPort.kt | 5 ++ .../service/ScheduleCommandService.kt | 9 ++ .../ScheduleConfirmationRequestService.kt | 42 ++++++++++ .../service/ScheduleConfirmationService.kt | 23 ++++- .../ScheduleNotificationEventListener.kt | 55 ++++++++++++ .../schedule/ScheduleReminderScheduler.kt | 7 ++ .../schedule/ScheduleJpaRepository.kt | 16 ++++ .../schedule/SchedulePersistenceAdapter.kt | 7 ++ .../schedule/entity/ScheduleEntity.kt | 1 + ...dd_schedule_confirmation_request_index.sql | 2 + .../service/CancelConfirmationServiceTest.kt | 5 ++ .../service/ScheduleCommandServiceTest.kt | 9 ++ .../ScheduleConfirmationRequestServiceTest.kt | 83 +++++++++++++++++++ .../ScheduleConfirmationServiceTest.kt | 21 +++++ .../service/ScheduleQueryServiceTest.kt | 5 ++ .../service/ScheduleReminderServiceTest.kt | 5 ++ .../ScheduleNotificationEventListenerTest.kt | 83 +++++++++++++++++++ 22 files changed, 422 insertions(+), 1 deletion(-) create mode 100644 src/main/kotlin/com/unicorn/server/domain/schedule/event/ScheduleConfirmationRequestDueEvent.kt create mode 100644 src/main/kotlin/com/unicorn/server/domain/schedule/event/ScheduleConfirmedEvent.kt create mode 100644 src/main/kotlin/com/unicorn/server/domain/schedule/event/ScheduleDeletedEvent.kt create mode 100644 src/main/kotlin/com/unicorn/server/domain/schedule/port/in/ScheduleConfirmationRequestInPort.kt create mode 100644 src/main/kotlin/com/unicorn/server/domain/schedule/port/in/ScheduleConfirmationStatusInPort.kt create mode 100644 src/main/kotlin/com/unicorn/server/domain/schedule/service/ScheduleConfirmationRequestService.kt create mode 100644 src/main/resources/db/migration/V1.0.3.4__add_schedule_confirmation_request_index.sql create mode 100644 src/test/kotlin/com/unicorn/server/domain/schedule/service/ScheduleConfirmationRequestServiceTest.kt 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/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/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/out/ScheduleOutPort.kt b/src/main/kotlin/com/unicorn/server/domain/schedule/port/out/ScheduleOutPort.kt index 918bf97..152b3ff 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 @@ -35,4 +35,9 @@ interface ScheduleOutPort { startTime: LocalTime, createdBefore: LocalDateTime, ): List + + fun findActiveConfirmationRequiredCreatedBetween( + createdFrom: LocalDateTime, + createdBefore: LocalDateTime, + ): List } 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 fb153d1..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 @@ -4,6 +4,7 @@ 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 @@ -109,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/infrastructure/adapter/in/event/schedule/ScheduleNotificationEventListener.kt b/src/main/kotlin/com/unicorn/server/infrastructure/adapter/in/event/schedule/ScheduleNotificationEventListener.kt index e8695d6..4c0c9d8 100644 --- a/src/main/kotlin/com/unicorn/server/infrastructure/adapter/in/event/schedule/ScheduleNotificationEventListener.kt +++ b/src/main/kotlin/com/unicorn/server/infrastructure/adapter/in/event/schedule/ScheduleNotificationEventListener.kt @@ -7,6 +7,8 @@ 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 @@ -14,8 +16,12 @@ 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 @@ -25,6 +31,7 @@ class ScheduleNotificationEventListener( private val circleMemberInPort: CircleMemberInPort, private val notificationPushTokenInPort: NotificationPushTokenInPort, private val notificationSettingInPort: NotificationSettingInPort, + private val scheduleConfirmationStatusInPort: ScheduleConfirmationStatusInPort, private val eventPublisher: EventPublisher, ) { @EventListener @@ -75,6 +82,54 @@ class ScheduleNotificationEventListener( } } + @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") 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 index b8a3c7b..03d510a 100644 --- 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 @@ -1,5 +1,6 @@ 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 @@ -9,6 +10,7 @@ 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() { @@ -20,6 +22,11 @@ class ScheduleReminderScheduler( 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/out/persistence/schedule/ScheduleJpaRepository.kt b/src/main/kotlin/com/unicorn/server/infrastructure/adapter/out/persistence/schedule/ScheduleJpaRepository.kt index ed5ecdc..32f601a 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 @@ -103,4 +103,20 @@ interface ScheduleJpaRepository : JpaRepository { @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 } 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 d92087d..2fc3195 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 @@ -77,4 +77,11 @@ class SchedulePersistenceAdapter( 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() } } 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 4d43853..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 @@ -19,6 +19,7 @@ import java.time.LocalTime 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/resources/db/migration/V1.0.3.4__add_schedule_confirmation_request_index.sql b/src/main/resources/db/migration/V1.0.3.4__add_schedule_confirmation_request_index.sql new file mode 100644 index 0000000..d21adc4 --- /dev/null +++ b/src/main/resources/db/migration/V1.0.3.4__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/schedule/service/CancelConfirmationServiceTest.kt b/src/test/kotlin/com/unicorn/server/domain/schedule/service/CancelConfirmationServiceTest.kt index db8a592..a7552fd 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 @@ -134,6 +134,11 @@ class CancelConfirmationServiceTest { 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") } 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 7b8d012..8f77609 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 @@ -6,6 +6,7 @@ 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 @@ -186,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 = @@ -268,6 +272,11 @@ class ScheduleCommandServiceTest { startTime: LocalTime, createdBefore: LocalDateTime, ): List = error("not used") + + override fun findActiveConfirmationRequiredCreatedBetween( + createdFrom: LocalDateTime, + createdBefore: LocalDateTime, + ): List = error("not used") } private class FakeScheduleConfirmationOutPort : ScheduleConfirmationOutPort { 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..31443d5 --- /dev/null +++ b/src/test/kotlin/com/unicorn/server/domain/schedule/service/ScheduleConfirmationRequestServiceTest.kt @@ -0,0 +1,83 @@ +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, cursor: SchedulePageCursor?, size: Int): List = 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 a38e244..8a4dc63 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 @@ -179,6 +187,11 @@ class ScheduleConfirmationServiceTest { 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") } private class FakeScheduleConfirmationOutPort : ScheduleConfirmationOutPort { @@ -227,6 +240,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 66731e4..db48ff3 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 @@ -210,6 +210,11 @@ class ScheduleQueryServiceTest { createdBefore: java.time.LocalDateTime, ): List = error("not used") + override fun findActiveConfirmationRequiredCreatedBetween( + createdFrom: java.time.LocalDateTime, + createdBefore: java.time.LocalDateTime, + ): List = error("not used") + 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 index 2a5c764..4ad3802 100644 --- a/src/test/kotlin/com/unicorn/server/domain/schedule/service/ScheduleReminderServiceTest.kt +++ b/src/test/kotlin/com/unicorn/server/domain/schedule/service/ScheduleReminderServiceTest.kt @@ -128,6 +128,11 @@ class ScheduleReminderServiceTest { it.startTime == startTime && it.createdAt.isBefore(createdBefore) } + + override fun findActiveConfirmationRequiredCreatedBetween( + createdFrom: LocalDateTime, + createdBefore: LocalDateTime, + ): List = error("not used") } private class RecordingEventPublisher : EventPublisher { diff --git a/src/test/kotlin/com/unicorn/server/infrastructure/adapter/in/event/schedule/ScheduleNotificationEventListenerTest.kt b/src/test/kotlin/com/unicorn/server/infrastructure/adapter/in/event/schedule/ScheduleNotificationEventListenerTest.kt index f6f3b40..d66498a 100644 --- a/src/test/kotlin/com/unicorn/server/infrastructure/adapter/in/event/schedule/ScheduleNotificationEventListenerTest.kt +++ b/src/test/kotlin/com/unicorn/server/infrastructure/adapter/in/event/schedule/ScheduleNotificationEventListenerTest.kt @@ -19,8 +19,12 @@ 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 @@ -50,6 +54,7 @@ class ScheduleNotificationEventListenerTest { circleMemberInPort, notificationPushTokenInPort, FakeNotificationSettingInPort(), + FakeScheduleConfirmationStatusInPort(), eventPublisher, ) @@ -95,6 +100,7 @@ class ScheduleNotificationEventListenerTest { circleMemberInPort, notificationPushTokenInPort, FakeNotificationSettingInPort(disabledMembers = setOf("disabled")), + FakeScheduleConfirmationStatusInPort(), eventPublisher, ) @@ -117,6 +123,77 @@ class ScheduleNotificationEventListenerTest { } } + @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( @@ -174,6 +251,12 @@ class ScheduleNotificationEventListenerTest { 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() From 6f38c39e3c532d6084803657d55ebd3a7de7e3cf Mon Sep 17 00:00:00 2001 From: junyong Date: Thu, 23 Jul 2026 23:28:57 +0900 Subject: [PATCH 6/7] =?UTF-8?q?test[SAIO-256]:=20=EC=9D=BC=EC=A0=95=20?= =?UTF-8?q?=EC=95=8C=EB=A6=BC=20=EC=97=B0=EB=8F=99=EC=97=90=20=EB=A7=9E?= =?UTF-8?q?=EC=B6=B0=20=EC=9D=BC=EC=A0=95=20=EC=BB=A8=ED=8A=B8=EB=A1=A4?= =?UTF-8?q?=EB=9F=AC=20=ED=85=8C=EC=8A=A4=ED=8A=B8=20fixture=20=EB=B3=B4?= =?UTF-8?q?=EC=99=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../ScheduleNotificationEventListenerTest.kt | 11 +++--- .../in/web/schedule/ScheduleControllerTest.kt | 34 +++++++++++++++++++ 2 files changed, 39 insertions(+), 6 deletions(-) diff --git a/src/test/kotlin/com/unicorn/server/infrastructure/adapter/in/event/schedule/ScheduleNotificationEventListenerTest.kt b/src/test/kotlin/com/unicorn/server/infrastructure/adapter/in/event/schedule/ScheduleNotificationEventListenerTest.kt index d66498a..6c6ba08 100644 --- a/src/test/kotlin/com/unicorn/server/infrastructure/adapter/in/event/schedule/ScheduleNotificationEventListenerTest.kt +++ b/src/test/kotlin/com/unicorn/server/infrastructure/adapter/in/event/schedule/ScheduleNotificationEventListenerTest.kt @@ -115,12 +115,11 @@ class ScheduleNotificationEventListenerTest { ) val events = eventPublisher.events.filterIsInstance() - assertThat(events).singleElement().satisfies { event -> - 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") - } + 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 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 From bc9b2f2699b2ad7a1715651e5c0e2abadc1fed6e Mon Sep 17 00:00:00 2001 From: junyong Date: Fri, 24 Jul 2026 01:50:44 +0900 Subject: [PATCH 7/7] =?UTF-8?q?fix[SAIO-265]:=20=EC=9D=BC=EC=A0=95=20?= =?UTF-8?q?=EC=95=8C=EB=A6=BC=20migration=20=EB=B2=84=EC=A0=84=20=EC=B6=A9?= =?UTF-8?q?=EB=8F=8C=20=ED=95=B4=EC=86=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ...er_indexes.sql => V1.0.3.5__add_schedule_reminder_indexes.sql} | 0 ....sql => V1.0.3.6__add_schedule_confirmation_request_index.sql} | 0 2 files changed, 0 insertions(+), 0 deletions(-) rename src/main/resources/db/migration/{V1.0.3.3__add_schedule_reminder_indexes.sql => V1.0.3.5__add_schedule_reminder_indexes.sql} (100%) rename src/main/resources/db/migration/{V1.0.3.4__add_schedule_confirmation_request_index.sql => V1.0.3.6__add_schedule_confirmation_request_index.sql} (100%) diff --git a/src/main/resources/db/migration/V1.0.3.3__add_schedule_reminder_indexes.sql b/src/main/resources/db/migration/V1.0.3.5__add_schedule_reminder_indexes.sql similarity index 100% rename from src/main/resources/db/migration/V1.0.3.3__add_schedule_reminder_indexes.sql rename to src/main/resources/db/migration/V1.0.3.5__add_schedule_reminder_indexes.sql diff --git a/src/main/resources/db/migration/V1.0.3.4__add_schedule_confirmation_request_index.sql b/src/main/resources/db/migration/V1.0.3.6__add_schedule_confirmation_request_index.sql similarity index 100% rename from src/main/resources/db/migration/V1.0.3.4__add_schedule_confirmation_request_index.sql rename to src/main/resources/db/migration/V1.0.3.6__add_schedule_confirmation_request_index.sql