diff --git a/src/main/kotlin/com/moa/entity/DailyWorkSchedule.kt b/src/main/kotlin/com/moa/entity/DailyWorkSchedule.kt index 7167664..d2c6869 100644 --- a/src/main/kotlin/com/moa/entity/DailyWorkSchedule.kt +++ b/src/main/kotlin/com/moa/entity/DailyWorkSchedule.kt @@ -18,10 +18,10 @@ class DailyWorkSchedule( var type: DailyWorkScheduleType, @Column(nullable = true) - var clockInTime: LocalTime, + var clockInTime: LocalTime?, @Column(nullable = true) - var clockOutTime: LocalTime, + var clockOutTime: LocalTime?, ) : BaseEntity() { @Id diff --git a/src/main/kotlin/com/moa/repository/NotificationLogRepository.kt b/src/main/kotlin/com/moa/repository/NotificationLogRepository.kt index cf36564..05d32b9 100644 --- a/src/main/kotlin/com/moa/repository/NotificationLogRepository.kt +++ b/src/main/kotlin/com/moa/repository/NotificationLogRepository.kt @@ -14,12 +14,25 @@ interface NotificationLogRepository : JpaRepository { status: NotificationStatus, ): List - fun findAllByMemberIdAndScheduledDateAndStatus( + fun findAllByMemberIdAndScheduledDateInAndStatus( + memberId: Long, + scheduledDates: Collection, + status: NotificationStatus, + ): List + + fun findAllByMemberIdAndScheduledDateAndNotificationTypeAndStatus( memberId: Long, scheduledDate: LocalDate, + notificationType: NotificationType, status: NotificationStatus, ): List + fun findAllByMemberIdAndScheduledDateInAndNotificationTypeIn( + memberId: Long, + scheduledDates: Collection, + notificationTypes: Collection, + ): List + fun existsByMemberIdAndScheduledDateAndStatus( memberId: Long, scheduledDate: LocalDate, diff --git a/src/main/kotlin/com/moa/service/WorkdayService.kt b/src/main/kotlin/com/moa/service/WorkdayService.kt index 339a7c7..70393c4 100644 --- a/src/main/kotlin/com/moa/service/WorkdayService.kt +++ b/src/main/kotlin/com/moa/service/WorkdayService.kt @@ -163,7 +163,7 @@ class WorkdayService( DailyWorkScheduleType.VACATION -> resolveVacationTimes(memberId, date, req) - DailyWorkScheduleType.NONE -> throw BadRequestException(ErrorCode.INVALID_WORKDAY_INPUT) + DailyWorkScheduleType.NONE -> null to null } val workSchedule = dailyWorkScheduleRepository.findByMemberIdAndDate(memberId, date) @@ -200,7 +200,7 @@ class WorkdayService( fun patchClockOut(memberId: Long, date: LocalDate, req: WorkdayEditRequest): WorkdayResponse { val workSchedule = dailyWorkScheduleRepository.findByMemberIdAndDate(memberId, date) ?.also { - if (it.type == DailyWorkScheduleType.VACATION) { + if (it.type == DailyWorkScheduleType.VACATION || it.type == DailyWorkScheduleType.NONE) { throw BadRequestException(ErrorCode.INVALID_WORKDAY_INPUT) } } diff --git a/src/main/kotlin/com/moa/service/notification/NotificationBatchService.kt b/src/main/kotlin/com/moa/service/notification/NotificationBatchService.kt index 289a63d..ff7e691 100644 --- a/src/main/kotlin/com/moa/service/notification/NotificationBatchService.kt +++ b/src/main/kotlin/com/moa/service/notification/NotificationBatchService.kt @@ -91,7 +91,7 @@ class NotificationBatchService( if (!context.hasAgreedToAll(memberId, requiredCodes)) return null if (!context.isNotificationEnabled(memberId)) return null - if (context.isOnVacation(memberId)) return null + if (context.isWorkSuppressed(memberId)) return null if (!context.hasFcmToken(memberId)) return null val override = context.getOverride(memberId) @@ -122,8 +122,9 @@ class NotificationBatchService( fun isNotificationEnabled(memberId: Long): Boolean = settingsMap[memberId]?.workNotificationEnabled != false - fun isOnVacation(memberId: Long): Boolean = - overridesMap[memberId]?.type == DailyWorkScheduleType.VACATION + fun isWorkSuppressed(memberId: Long): Boolean = + overridesMap[memberId]?.type == DailyWorkScheduleType.VACATION || + overridesMap[memberId]?.type == DailyWorkScheduleType.NONE fun hasFcmToken(memberId: Long): Boolean = !tokensMap[memberId].isNullOrEmpty() diff --git a/src/main/kotlin/com/moa/service/notification/NotificationSyncService.kt b/src/main/kotlin/com/moa/service/notification/NotificationSyncService.kt index 50694b2..3b19472 100644 --- a/src/main/kotlin/com/moa/service/notification/NotificationSyncService.kt +++ b/src/main/kotlin/com/moa/service/notification/NotificationSyncService.kt @@ -18,6 +18,7 @@ class NotificationSyncService( private val notificationLogRepository: NotificationLogRepository, ) { private val log = LoggerFactory.getLogger(javaClass) + private val workNotificationTypes = listOf(NotificationType.CLOCK_IN, NotificationType.CLOCK_OUT) @Transactional fun syncNotifications( @@ -27,20 +28,18 @@ class NotificationSyncService( clockInTime: LocalTime?, clockOutTime: LocalTime?, ) { - val pendingLogs = notificationLogRepository - .findAllByMemberIdAndScheduledDateAndStatus(memberId, date, NotificationStatus.PENDING) - .filter { it.notificationType != NotificationType.PAYDAY } + val pendingLogs = findPendingWorkNotifications(memberId, date) if (pendingLogs.isEmpty()) { - restoreIfCancelled(memberId, date, type, clockInTime, clockOutTime) + ensureUpcomingWorkNotifications(memberId, date, type, clockInTime, clockOutTime) return } - if (type == DailyWorkScheduleType.VACATION) { + if (type == DailyWorkScheduleType.VACATION || type == DailyWorkScheduleType.NONE) { pendingLogs.forEach { it.status = NotificationStatus.CANCELLED } log.info( - "Cancelled {} pending notifications for member {} on {} (vacation)", - pendingLogs.size, memberId, date, + "Cancelled {} pending notifications for member {} on {} ({})", + pendingLogs.size, memberId, date, type, ) return } @@ -49,6 +48,7 @@ class NotificationSyncService( when (pendingLog.notificationType) { NotificationType.CLOCK_IN -> { clockInTime?.let { pendingLog.scheduledTime = truncateToMinute(it) } + pendingLog.scheduledDate = date } NotificationType.CLOCK_OUT -> { @@ -70,7 +70,21 @@ class NotificationSyncService( log.info("Synced pending notifications for member {} on {}", memberId, date) } - private fun restoreIfCancelled( + private fun findPendingWorkNotifications(memberId: Long, date: LocalDate): List { + val sameDayLogs = notificationLogRepository + .findAllByMemberIdAndScheduledDateInAndStatus(memberId, listOf(date), NotificationStatus.PENDING) + .filter { it.notificationType != NotificationType.PAYDAY } + val nextDayClockOutLogs = notificationLogRepository + .findAllByMemberIdAndScheduledDateAndNotificationTypeAndStatus( + memberId, + date.plusDays(1), + NotificationType.CLOCK_OUT, + NotificationStatus.PENDING, + ) + return sameDayLogs + nextDayClockOutLogs + } + + private fun ensureUpcomingWorkNotifications( memberId: Long, date: LocalDate, type: DailyWorkScheduleType, @@ -80,29 +94,42 @@ class NotificationSyncService( if (type != DailyWorkScheduleType.WORK) return if (clockInTime == null || clockOutTime == null) return - val hasCancelled = notificationLogRepository - .existsByMemberIdAndScheduledDateAndStatus(memberId, date, NotificationStatus.CANCELLED) - if (!hasCancelled) 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 relatedDates = listOf(date, date.plusDays(1)) + val existingLogs = notificationLogRepository + .findAllByMemberIdAndScheduledDateInAndNotificationTypeIn( + memberId, + relatedDates, + workNotificationTypes, + ) - if (date.atTime(truncatedIn).isAfter(now)) { + if (date.atTime(truncatedIn).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)) } val clockOutDate = if (isMidnightCrossing) date.plusDays(1) else date - if (clockOutDate.atTime(truncatedOut).isAfter(now)) { + if (clockOutDate.atTime(truncatedOut).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)) } if (logs.isNotEmpty()) { notificationLogRepository.saveAll(logs) log.info( - "Restored {} notifications for member {} on {} (vacation -> work)", + "Ensured {} upcoming work notifications for member {} on {}", logs.size, memberId, date, ) }