diff --git a/src/main/kotlin/com/moa/entity/notification/WorkScheduleTime.kt b/src/main/kotlin/com/moa/entity/notification/WorkScheduleTime.kt new file mode 100644 index 0000000..c87f129 --- /dev/null +++ b/src/main/kotlin/com/moa/entity/notification/WorkScheduleTime.kt @@ -0,0 +1,21 @@ +package com.moa.entity.notification + +import java.time.LocalDate +import java.time.LocalTime + +class WorkScheduleTime private constructor( + val clockInTime: LocalTime, + val clockOutTime: LocalTime, +) { + val isMidnightCrossing: Boolean = clockOutTime < clockInTime + + fun clockOutDate(baseDate: LocalDate): LocalDate = + if (isMidnightCrossing) baseDate.plusDays(1) else baseDate + + companion object { + fun of(clockIn: LocalTime, clockOut: LocalTime) = WorkScheduleTime( + clockInTime = LocalTime.of(clockIn.hour, clockIn.minute), + clockOutTime = LocalTime.of(clockOut.hour, clockOut.minute), + ) + } +} diff --git a/src/main/kotlin/com/moa/service/FcmService.kt b/src/main/kotlin/com/moa/service/FcmService.kt index 21afe5b..379824a 100644 --- a/src/main/kotlin/com/moa/service/FcmService.kt +++ b/src/main/kotlin/com/moa/service/FcmService.kt @@ -2,6 +2,7 @@ package com.moa.service import com.google.firebase.messaging.* import com.moa.repository.FcmTokenRepository +import com.moa.service.dto.FcmRequest import org.slf4j.LoggerFactory import org.springframework.stereotype.Service @@ -11,16 +12,16 @@ class FcmService( ) { private val log = LoggerFactory.getLogger(javaClass) - fun sendEach(requests: List>>): List { + fun sendEach(requests: List): List { if (requests.isEmpty()) return emptyList() val results = ArrayList(requests.size) requests.chunked(MAX_BATCH_SIZE).forEach { batch -> try { - val messages = batch.map { (token, data) -> buildMessage(token, data) } + val messages = batch.map { buildMessage(it.token, it.data) } val response = FirebaseMessaging.getInstance().sendEach(messages) response.responses.forEachIndexed { i, sendResponse -> if (!sendResponse.isSuccessful) { - handleFcmException(sendResponse.exception, batch[i].first) + handleFcmException(sendResponse.exception, batch[i].token) } results.add(sendResponse.isSuccessful) } diff --git a/src/main/kotlin/com/moa/service/dto/FcmRequest.kt b/src/main/kotlin/com/moa/service/dto/FcmRequest.kt new file mode 100644 index 0000000..075498b --- /dev/null +++ b/src/main/kotlin/com/moa/service/dto/FcmRequest.kt @@ -0,0 +1,3 @@ +package com.moa.service.dto + +data class FcmRequest(val token: String, val data: Map) diff --git a/src/main/kotlin/com/moa/service/notification/NotificationBatchService.kt b/src/main/kotlin/com/moa/service/notification/NotificationBatchService.kt index ff7e691..e8f8ecb 100644 --- a/src/main/kotlin/com/moa/service/notification/NotificationBatchService.kt +++ b/src/main/kotlin/com/moa/service/notification/NotificationBatchService.kt @@ -1,25 +1,23 @@ package com.moa.service.notification -import com.moa.entity.* +import com.moa.entity.Workday +import com.moa.entity.WorkPolicyVersion import com.moa.entity.notification.NotificationLog -import com.moa.entity.notification.NotificationSetting +import com.moa.entity.notification.NotificationSettingType import com.moa.entity.notification.NotificationType -import com.moa.repository.* +import com.moa.entity.notification.WorkScheduleTime +import com.moa.repository.NotificationLogRepository +import com.moa.repository.WorkPolicyVersionRepository import org.slf4j.LoggerFactory import org.springframework.stereotype.Service import org.springframework.transaction.annotation.Transactional import java.time.LocalDate -import java.time.LocalTime @Service class NotificationBatchService( private val workPolicyVersionRepository: WorkPolicyVersionRepository, - private val dailyWorkScheduleRepository: DailyWorkScheduleRepository, private val notificationLogRepository: NotificationLogRepository, - private val notificationSettingRepository: NotificationSettingRepository, - private val fcmTokenRepository: FcmTokenRepository, - private val termRepository: TermRepository, - private val termAgreementRepository: TermAgreementRepository, + private val notificationEligibilityService: NotificationEligibilityService, ) { private val log = LoggerFactory.getLogger(javaClass) @@ -31,8 +29,8 @@ class NotificationBatchService( if (workdayPolicies.isEmpty()) return val memberIds = workdayPolicies.map { it.memberId } - val requiredTermCodes = findRequiredTermCodes() - val context = loadContext(memberIds, date) + val requiredTermCodes = notificationEligibilityService.findRequiredTermCodes() + val context = notificationEligibilityService.loadContext(memberIds, date) log.info("Generating notifications for {} members on {}", memberIds.size, date) @@ -57,79 +55,28 @@ class NotificationBatchService( .filter { todayWorkday in it.workdays } } - private fun findRequiredTermCodes(): Set = - termRepository.findAll() - .filter { it.required } - .map { it.code } - .toSet() - - private fun loadContext(memberIds: List, date: LocalDate): NotificationContext { - val agreementsMap = termAgreementRepository.findAllByMemberIdIn(memberIds) - .filter { it.agreed } - .groupBy { it.memberId } - .mapValues { (_, v) -> v.map { it.termCode }.toSet() } - - val settingsMap = notificationSettingRepository.findAllByMemberIdIn(memberIds) - .associateBy { it.memberId } - - val overridesMap = dailyWorkScheduleRepository.findAllByMemberIdInAndDate(memberIds, date) - .associateBy { it.memberId } - - val tokensMap = fcmTokenRepository.findAllByMemberIdIn(memberIds) - .groupBy { it.memberId } - - return NotificationContext(agreementsMap, settingsMap, overridesMap, tokensMap) - } - private fun createNotificationsIfEligible( policy: WorkPolicyVersion, date: LocalDate, requiredCodes: Set, - context: NotificationContext, + context: NotificationEligibilityContext, ): List? { val memberId = policy.memberId if (!context.hasAgreedToAll(memberId, requiredCodes)) return null - if (!context.isNotificationEnabled(memberId)) return null - if (context.isWorkSuppressed(memberId)) return null + if (!context.isSettingEnabled(memberId, NotificationSettingType.WORK)) return null + if (context.shouldSkipNotification(memberId)) return null if (!context.hasFcmToken(memberId)) return null val override = context.getOverride(memberId) - val clockInTime = truncateToMinute(override?.clockInTime ?: policy.clockInTime) - val clockOutTime = truncateToMinute(override?.clockOutTime ?: policy.clockOutTime) - val clockOutDate = if (clockOutTime < clockInTime) date.plusDays(1) else date + val schedule = WorkScheduleTime.of( + clockIn = override?.clockInTime ?: policy.clockInTime, + clockOut = override?.clockOutTime ?: policy.clockOutTime, + ) return listOf( - NotificationLog(memberId, NotificationType.CLOCK_IN, date, clockInTime), - NotificationLog(memberId, NotificationType.CLOCK_OUT, clockOutDate, clockOutTime), + NotificationLog(memberId, NotificationType.CLOCK_IN, date, schedule.clockInTime), + NotificationLog(memberId, NotificationType.CLOCK_OUT, schedule.clockOutDate(date), schedule.clockOutTime), ) } - - private fun truncateToMinute(time: LocalTime): LocalTime = - LocalTime.of(time.hour, time.minute) - - private class NotificationContext( - private val agreementsMap: Map>, - private val settingsMap: Map, - private val overridesMap: Map, - private val tokensMap: Map>, - ) { - fun hasAgreedToAll(memberId: Long, requiredCodes: Set): Boolean { - val agreedCodes = agreementsMap[memberId] ?: emptySet() - return agreedCodes.containsAll(requiredCodes) - } - - fun isNotificationEnabled(memberId: Long): Boolean = - settingsMap[memberId]?.workNotificationEnabled != false - - fun isWorkSuppressed(memberId: Long): Boolean = - overridesMap[memberId]?.type == DailyWorkScheduleType.VACATION || - overridesMap[memberId]?.type == DailyWorkScheduleType.NONE - - fun hasFcmToken(memberId: Long): Boolean = - !tokensMap[memberId].isNullOrEmpty() - - fun getOverride(memberId: Long): DailyWorkSchedule? = - overridesMap[memberId] - } } diff --git a/src/main/kotlin/com/moa/service/notification/NotificationDispatchService.kt b/src/main/kotlin/com/moa/service/notification/NotificationDispatchService.kt index 7762d19..da863de 100644 --- a/src/main/kotlin/com/moa/service/notification/NotificationDispatchService.kt +++ b/src/main/kotlin/com/moa/service/notification/NotificationDispatchService.kt @@ -5,6 +5,7 @@ import com.moa.entity.notification.NotificationStatus import com.moa.repository.FcmTokenRepository import com.moa.repository.NotificationLogRepository import com.moa.service.FcmService +import com.moa.service.dto.FcmRequest import org.slf4j.LoggerFactory import org.springframework.stereotype.Service import java.time.LocalDate @@ -34,12 +35,6 @@ class NotificationDispatchService( val tokensByMemberId = fcmTokenRepository.findAllByMemberIdIn(memberIds) .groupBy { it.memberId } - data class DispatchItem( - val notification: NotificationLog, - val token: String, - val data: Map, - ) - val dispatchItems = mutableListOf() for (notification in pendingLogs) { val tokens = tokensByMemberId[notification.memberId].orEmpty() @@ -48,12 +43,17 @@ class NotificationDispatchService( log.warn("No FCM tokens for member {}, marking as FAILED", notification.memberId) continue } - val data = notificationMessageBuilder.buildMessage(notification).toData() - tokens.forEach { dispatchItems.add(DispatchItem(notification, it.token, data)) } + try { + val data = notificationMessageBuilder.buildMessage(notification).toData() + tokens.forEach { dispatchItems.add(DispatchItem(notification, it.token, data)) } + } catch (e: Exception) { + notification.status = NotificationStatus.FAILED + log.error("Failed to build message for notification {}, member {}", notification.id, notification.memberId, e) + } } if (dispatchItems.isNotEmpty()) { - val results = fcmService.sendEach(dispatchItems.map { it.token to it.data }) + val results = fcmService.sendEach(dispatchItems.map { FcmRequest(it.token, it.data) }) results.forEachIndexed { i, success -> if (success) dispatchItems[i].notification.status = NotificationStatus.SENT } @@ -64,3 +64,9 @@ class NotificationDispatchService( notificationLogRepository.saveAll(pendingLogs) } } + +private data class DispatchItem( + val notification: NotificationLog, + val token: String, + val data: Map, +) diff --git a/src/main/kotlin/com/moa/service/notification/NotificationEarningsService.kt b/src/main/kotlin/com/moa/service/notification/NotificationEarningsService.kt new file mode 100644 index 0000000..862f4dc --- /dev/null +++ b/src/main/kotlin/com/moa/service/notification/NotificationEarningsService.kt @@ -0,0 +1,56 @@ +package com.moa.service.notification + +import com.moa.common.exception.NotFoundException +import com.moa.entity.DailyWorkScheduleType +import com.moa.repository.DailyWorkScheduleRepository +import com.moa.repository.PayrollVersionRepository +import com.moa.repository.WorkPolicyVersionRepository +import com.moa.service.calculator.CompensationCalculator +import org.springframework.stereotype.Service +import java.math.BigDecimal +import java.time.LocalDate +import java.time.YearMonth + +@Service +class NotificationEarningsService( + private val workPolicyVersionRepository: WorkPolicyVersionRepository, + private val payrollVersionRepository: PayrollVersionRepository, + private val dailyWorkScheduleRepository: DailyWorkScheduleRepository, + private val compensationCalculator: CompensationCalculator, +) { + fun calculateTodayEarnings(memberId: Long, date: LocalDate): BigDecimal { + val policy = resolveMonthlyRepresentativePolicyOrNull(memberId, date.year, date.monthValue) + ?: throw NotFoundException() + val payroll = resolveMonthlyRepresentativePayrollOrNull(memberId, date.year, date.monthValue) + ?: throw NotFoundException() + + val override = dailyWorkScheduleRepository.findByMemberIdAndDate(memberId, date) + return compensationCalculator.calculateDailyEarnings( + date = date, + salaryType = payroll.salaryInputType, + salaryAmount = payroll.salaryAmount, + policy = policy, + type = override?.type ?: DailyWorkScheduleType.WORK, + clockInTime = override?.clockInTime, + clockOutTime = override?.clockOutTime, + ) + } + + private fun resolveMonthlyRepresentativePolicyOrNull( + memberId: Long, + year: Int, + month: Int, + ) = workPolicyVersionRepository.findTopByMemberIdAndEffectiveFromLessThanEqualOrderByEffectiveFromDesc( + memberId, + YearMonth.of(year, month).atEndOfMonth(), + ) + + private fun resolveMonthlyRepresentativePayrollOrNull( + memberId: Long, + year: Int, + month: Int, + ) = payrollVersionRepository.findTopByMemberIdAndEffectiveFromLessThanEqualOrderByEffectiveFromDesc( + memberId, + YearMonth.of(year, month).atEndOfMonth(), + ) +} diff --git a/src/main/kotlin/com/moa/service/notification/NotificationEligibilityContext.kt b/src/main/kotlin/com/moa/service/notification/NotificationEligibilityContext.kt new file mode 100644 index 0000000..8ed831e --- /dev/null +++ b/src/main/kotlin/com/moa/service/notification/NotificationEligibilityContext.kt @@ -0,0 +1,40 @@ +package com.moa.service.notification + +import com.moa.entity.DailyWorkSchedule +import com.moa.entity.DailyWorkScheduleType +import com.moa.entity.FcmToken +import com.moa.entity.notification.NotificationSetting +import com.moa.entity.notification.NotificationSettingType + +/** + * 알림 발송 적격성 판단을 위한 조회 결과를 보관하는 컨텍스트입니다. + * + * [NotificationEligibilityService.loadContext]에서 생성되며, + * 배치 서비스가 회원별 적격성을 판단할 때 사용합니다. + * + * @param overridesMap 출퇴근 알림 전용. 월급날 알림에서는 빈 맵으로 생성됩니다. + */ +class NotificationEligibilityContext( + private val agreementsMap: Map>, + private val settingsMap: Map, + private val tokensMap: Map>, + private val overridesMap: Map = emptyMap(), +) { + fun hasAgreedToAll(memberId: Long, requiredCodes: Set): Boolean { + val agreedCodes = agreementsMap[memberId] ?: emptySet() + return agreedCodes.containsAll(requiredCodes) + } + + fun isSettingEnabled(memberId: Long, type: NotificationSettingType): Boolean = + settingsMap[memberId]?.isEnabled(type) != false + + fun hasFcmToken(memberId: Long): Boolean = + !tokensMap[memberId].isNullOrEmpty() + + fun shouldSkipNotification(memberId: Long): Boolean { + val overrideType = overridesMap[memberId]?.type + return overrideType == DailyWorkScheduleType.VACATION || overrideType == DailyWorkScheduleType.NONE + } + + fun getOverride(memberId: Long): DailyWorkSchedule? = overridesMap[memberId] +} diff --git a/src/main/kotlin/com/moa/service/notification/NotificationEligibilityService.kt b/src/main/kotlin/com/moa/service/notification/NotificationEligibilityService.kt new file mode 100644 index 0000000..9c6cf18 --- /dev/null +++ b/src/main/kotlin/com/moa/service/notification/NotificationEligibilityService.kt @@ -0,0 +1,58 @@ +package com.moa.service.notification + +import com.moa.repository.TermRepository +import com.moa.repository.TermAgreementRepository +import com.moa.repository.NotificationSettingRepository +import com.moa.repository.FcmTokenRepository +import com.moa.repository.DailyWorkScheduleRepository +import org.springframework.stereotype.Service +import java.time.LocalDate + +/** + * 알림 발송 적격성 판단에 필요한 데이터를 로딩하는 서비스입니다. + * + * 적격성 조건: + * - 필수 약관 전체 동의 여부 + * - 알림 유형별 수신 설정 활성화 여부 (출퇴근 / 월급날) + * - 유효한 FCM 토큰 보유 여부 + * - 알림 발송 제외 스케줄 여부 (휴가, 미근무) + * + * [NotificationBatchService], [PaydayNotificationBatchService]에서 공통으로 사용합니다. + */ +@Service +class NotificationEligibilityService( + private val termRepository: TermRepository, + private val termAgreementRepository: TermAgreementRepository, + private val notificationSettingRepository: NotificationSettingRepository, + private val fcmTokenRepository: FcmTokenRepository, + private val dailyWorkScheduleRepository: DailyWorkScheduleRepository, +) { + fun findRequiredTermCodes(): Set = + termRepository.findAll() + .filter { it.required } + .map { it.code } + .toSet() + + fun loadContext( + memberIds: List, + date: LocalDate? = null, + ): NotificationEligibilityContext { + val agreementsMap = termAgreementRepository.findAllByMemberIdIn(memberIds) + .filter { it.agreed } + .groupBy { it.memberId } + .mapValues { (_, v) -> v.map { it.termCode }.toSet() } + + val settingsMap = notificationSettingRepository.findAllByMemberIdIn(memberIds) + .associateBy { it.memberId } + + val tokensMap = fcmTokenRepository.findAllByMemberIdIn(memberIds) + .groupBy { it.memberId } + + val overridesMap = date?.let { + dailyWorkScheduleRepository.findAllByMemberIdInAndDate(memberIds, it) + .associateBy { schedule -> schedule.memberId } + } ?: emptyMap() + + return NotificationEligibilityContext(agreementsMap, settingsMap, tokensMap, overridesMap) + } +} diff --git a/src/main/kotlin/com/moa/service/notification/NotificationMessageBuilder.kt b/src/main/kotlin/com/moa/service/notification/NotificationMessageBuilder.kt index 9122914..98ee3ab 100644 --- a/src/main/kotlin/com/moa/service/notification/NotificationMessageBuilder.kt +++ b/src/main/kotlin/com/moa/service/notification/NotificationMessageBuilder.kt @@ -1,26 +1,15 @@ package com.moa.service.notification -import com.moa.common.exception.NotFoundException -import com.moa.entity.DailyWorkScheduleType import com.moa.entity.notification.NotificationLog import com.moa.entity.notification.NotificationType -import com.moa.repository.DailyWorkScheduleRepository -import com.moa.repository.PayrollVersionRepository -import com.moa.repository.WorkPolicyVersionRepository -import com.moa.service.calculator.CompensationCalculator import org.springframework.stereotype.Service import java.math.BigDecimal import java.text.NumberFormat -import java.time.LocalDate -import java.time.YearMonth import java.util.* @Service class NotificationMessageBuilder( - private val workPolicyVersionRepository: WorkPolicyVersionRepository, - private val payrollVersionRepository: PayrollVersionRepository, - private val dailyWorkScheduleRepository: DailyWorkScheduleRepository, - private val compensationCalculator: CompensationCalculator, + private val notificationEarningsService: NotificationEarningsService, ) { fun buildMessage(notification: NotificationLog): NotificationMessage { @@ -34,7 +23,10 @@ class NotificationMessageBuilder( } private fun buildClockOutBody(notification: NotificationLog): String { - val earnings = calculateTodayEarnings(notification.memberId, notification.scheduledDate) + val earnings = notificationEarningsService.calculateTodayEarnings( + notification.memberId, + notification.scheduledDate, + ) if (earnings == BigDecimal.ZERO) { return CLOCK_OUT_FALLBACK_BODY } @@ -42,40 +34,6 @@ class NotificationMessageBuilder( return notification.notificationType.getBody(formatted) } - private fun calculateTodayEarnings(memberId: Long, date: LocalDate): BigDecimal { - val policy = resolveMonthlyRepresentativePolicyOrNull(memberId, date.year, date.monthValue) ?: throw NotFoundException() - val payroll = resolveMonthlyRepresentativePayrollOrNull(memberId, date.year, date.monthValue) ?: throw NotFoundException() - - val override = dailyWorkScheduleRepository.findByMemberIdAndDate(memberId, date) - return compensationCalculator.calculateDailyEarnings( - date = date, - salaryType = payroll.salaryInputType, - salaryAmount = payroll.salaryAmount, - policy = policy, - type = override?.type ?: DailyWorkScheduleType.WORK, - clockInTime = override?.clockInTime, - clockOutTime = override?.clockOutTime, - ) - } - - private fun resolveMonthlyRepresentativePolicyOrNull( - memberId: Long, - year: Int, - month: Int, - ) = workPolicyVersionRepository.findTopByMemberIdAndEffectiveFromLessThanEqualOrderByEffectiveFromDesc( - memberId, - YearMonth.of(year, month).atEndOfMonth(), - ) - - private fun resolveMonthlyRepresentativePayrollOrNull( - memberId: Long, - year: Int, - month: Int, - ) = payrollVersionRepository.findTopByMemberIdAndEffectiveFromLessThanEqualOrderByEffectiveFromDesc( - memberId, - YearMonth.of(year, month).atEndOfMonth(), - ) - companion object { private const val CLOCK_OUT_FALLBACK_BODY = "오늘도 수고하셨어요!" } diff --git a/src/main/kotlin/com/moa/service/notification/NotificationSyncService.kt b/src/main/kotlin/com/moa/service/notification/NotificationSyncService.kt index 3b19472..94160da 100644 --- a/src/main/kotlin/com/moa/service/notification/NotificationSyncService.kt +++ b/src/main/kotlin/com/moa/service/notification/NotificationSyncService.kt @@ -4,6 +4,7 @@ import com.moa.entity.DailyWorkScheduleType import com.moa.entity.notification.NotificationLog import com.moa.entity.notification.NotificationStatus import com.moa.entity.notification.NotificationType +import com.moa.entity.notification.WorkScheduleTime import com.moa.repository.NotificationLogRepository import org.slf4j.LoggerFactory import org.springframework.stereotype.Service @@ -47,20 +48,20 @@ class NotificationSyncService( for (pendingLog in pendingLogs) { when (pendingLog.notificationType) { NotificationType.CLOCK_IN -> { - clockInTime?.let { pendingLog.scheduledTime = truncateToMinute(it) } + clockInTime?.let { + pendingLog.scheduledTime = LocalTime.of(it.hour, it.minute) + } pendingLog.scheduledDate = date } NotificationType.CLOCK_OUT -> { - clockOutTime?.let { - val truncated = truncateToMinute(it) - val clockInTruncated = clockInTime?.let(::truncateToMinute) - if (clockInTruncated != null && truncated < clockInTruncated) { - pendingLog.scheduledDate = date.plusDays(1) - } else { - pendingLog.scheduledDate = date - } - pendingLog.scheduledTime = truncated + if (clockInTime != null && clockOutTime != null) { + val schedule = WorkScheduleTime.of(clockInTime, clockOutTime) + pendingLog.scheduledDate = schedule.clockOutDate(date) + pendingLog.scheduledTime = schedule.clockOutTime + } else if (clockOutTime != null) { + pendingLog.scheduledTime = LocalTime.of(clockOutTime.hour, clockOutTime.minute) + pendingLog.scheduledDate = date } } @@ -95,10 +96,8 @@ class NotificationSyncService( if (clockInTime == null || clockOutTime == null) return val now = LocalDateTime.now(ZoneId.of("Asia/Seoul")) - val logs = mutableListOf() - val truncatedIn = truncateToMinute(clockInTime) - val truncatedOut = truncateToMinute(clockOutTime) - val isMidnightCrossing = truncatedOut < truncatedIn + val schedule = WorkScheduleTime.of(clockInTime, clockOutTime) + val clockOutDate = schedule.clockOutDate(date) val relatedDates = listOf(date, date.plusDays(1)) val existingLogs = notificationLogRepository .findAllByMemberIdAndScheduledDateInAndNotificationTypeIn( @@ -107,23 +106,24 @@ class NotificationSyncService( workNotificationTypes, ) - if (date.atTime(truncatedIn).isAfter(now) && existingLogs.none { + val logs = mutableListOf() + + if (date.atTime(schedule.clockInTime).isAfter(now) && existingLogs.none { it.notificationType == NotificationType.CLOCK_IN && it.scheduledDate == date && it.status != NotificationStatus.CANCELLED } ) { - logs.add(NotificationLog(memberId, NotificationType.CLOCK_IN, date, truncatedIn)) + logs.add(NotificationLog(memberId, NotificationType.CLOCK_IN, date, schedule.clockInTime)) } - val clockOutDate = if (isMidnightCrossing) date.plusDays(1) else date - if (clockOutDate.atTime(truncatedOut).isAfter(now) && existingLogs.none { + if (clockOutDate.atTime(schedule.clockOutTime).isAfter(now) && existingLogs.none { it.notificationType == NotificationType.CLOCK_OUT && it.scheduledDate == clockOutDate && it.status != NotificationStatus.CANCELLED } ) { - logs.add(NotificationLog(memberId, NotificationType.CLOCK_OUT, clockOutDate, truncatedOut)) + logs.add(NotificationLog(memberId, NotificationType.CLOCK_OUT, clockOutDate, schedule.clockOutTime)) } if (logs.isNotEmpty()) { @@ -135,6 +135,4 @@ class NotificationSyncService( } } - private fun truncateToMinute(time: LocalTime): LocalTime = - LocalTime.of(time.hour, time.minute) } diff --git a/src/main/kotlin/com/moa/service/notification/PaydayNotificationBatchService.kt b/src/main/kotlin/com/moa/service/notification/PaydayNotificationBatchService.kt index c86b374..fba159d 100644 --- a/src/main/kotlin/com/moa/service/notification/PaydayNotificationBatchService.kt +++ b/src/main/kotlin/com/moa/service/notification/PaydayNotificationBatchService.kt @@ -1,12 +1,12 @@ package com.moa.service.notification -import com.moa.entity.FcmToken import com.moa.entity.PaydayDay import com.moa.entity.Profile import com.moa.entity.notification.NotificationLog -import com.moa.entity.notification.NotificationSetting +import com.moa.entity.notification.NotificationSettingType import com.moa.entity.notification.NotificationType -import com.moa.repository.* +import com.moa.repository.NotificationLogRepository +import com.moa.repository.ProfileRepository import org.slf4j.LoggerFactory import org.springframework.stereotype.Service import org.springframework.transaction.annotation.Transactional @@ -17,10 +17,7 @@ import java.time.LocalTime class PaydayNotificationBatchService( private val profileRepository: ProfileRepository, private val notificationLogRepository: NotificationLogRepository, - private val notificationSettingRepository: NotificationSettingRepository, - private val fcmTokenRepository: FcmTokenRepository, - private val termRepository: TermRepository, - private val termAgreementRepository: TermAgreementRepository, + private val notificationEligibilityService: NotificationEligibilityService, ) { private val log = LoggerFactory.getLogger(javaClass) @@ -32,8 +29,8 @@ class PaydayNotificationBatchService( if (profiles.isEmpty()) return val memberIds = profiles.map { it.memberId } - val requiredTermCodes = findRequiredTermCodes() - val context = loadContext(memberIds) + val requiredTermCodes = notificationEligibilityService.findRequiredTermCodes() + val context = notificationEligibilityService.loadContext(memberIds) log.info("Generating payday notifications for {} members on {}", memberIds.size, date) @@ -63,57 +60,19 @@ class PaydayNotificationBatchService( return profileRepository.findAllByPaydayDay_ValueIn(candidatePaydayDays) } - private fun findRequiredTermCodes(): Set = - termRepository.findAll() - .filter { it.required } - .map { it.code } - .toSet() - - private fun loadContext(memberIds: List): PaydayNotificationContext { - val agreementsMap = termAgreementRepository.findAllByMemberIdIn(memberIds) - .filter { it.agreed } - .groupBy { it.memberId } - .mapValues { (_, v) -> v.map { it.termCode }.toSet() } - - val settingsMap = notificationSettingRepository.findAllByMemberIdIn(memberIds) - .associateBy { it.memberId } - - val tokensMap = fcmTokenRepository.findAllByMemberIdIn(memberIds) - .groupBy { it.memberId } - - return PaydayNotificationContext(agreementsMap, settingsMap, tokensMap) - } - private fun createNotificationIfEligible( memberId: Long, date: LocalDate, requiredCodes: Set, - context: PaydayNotificationContext, + context: NotificationEligibilityContext, ): NotificationLog? { if (!context.hasAgreedToAll(memberId, requiredCodes)) return null - if (!context.isPaydayNotificationEnabled(memberId)) return null + if (!context.isSettingEnabled(memberId, NotificationSettingType.PAYDAY)) return null if (!context.hasFcmToken(memberId)) return null return NotificationLog(memberId, NotificationType.PAYDAY, date, PAYDAY_NOTIFICATION_TIME) } - private class PaydayNotificationContext( - private val agreementsMap: Map>, - private val settingsMap: Map, - private val tokensMap: Map>, - ) { - fun hasAgreedToAll(memberId: Long, requiredCodes: Set): Boolean { - val agreedCodes = agreementsMap[memberId] ?: emptySet() - return agreedCodes.containsAll(requiredCodes) - } - - fun isPaydayNotificationEnabled(memberId: Long): Boolean = - settingsMap[memberId]?.paydayNotificationEnabled != false - - fun hasFcmToken(memberId: Long): Boolean = - !tokensMap[memberId].isNullOrEmpty() - } - companion object { private val PAYDAY_NOTIFICATION_TIME = LocalTime.of(9, 0) }