Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -1,5 +1,9 @@
package im.toduck.domain.notification.domain.data;

import com.fasterxml.jackson.annotation.JsonProperty;

import im.toduck.domain.schedule.persistence.vo.ScheduleAlram;
import lombok.AllArgsConstructor;
import lombok.Getter;
import lombok.NoArgsConstructor;

Expand All @@ -8,7 +12,19 @@
*/
@Getter
@NoArgsConstructor
@AllArgsConstructor
public class ScheduleReminderData extends AbstractNotificationData {
// TODO: 필요한 필드 추가
// 예상 필드: 일정 ID, 일정 제목, 일정 시작 시간, 남은 시간 등
private Long scheduleId;
private String scheduleTitle;
private ScheduleAlram reminderType;
@JsonProperty("allDay")
private boolean isAllDay;

public static ScheduleReminderData of(
final Long scheduleId,
final String scheduleTitle,
final ScheduleAlram reminderType,
final boolean isAllDay) {
return new ScheduleReminderData(scheduleId, scheduleTitle, reminderType, isAllDay);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
package im.toduck.domain.notification.domain.event;

import im.toduck.domain.notification.domain.data.ScheduleReminderData;
import im.toduck.domain.notification.persistence.entity.NotificationType;
import im.toduck.domain.schedule.persistence.vo.ScheduleAlram;
import lombok.AccessLevel;
import lombok.Getter;
import lombok.NoArgsConstructor;

@Getter
@NoArgsConstructor(access = AccessLevel.PRIVATE)
public class ScheduleReminderNotificationEvent extends NotificationEvent<ScheduleReminderData> {

private ScheduleReminderNotificationEvent(final Long userId, final ScheduleReminderData data) {
super(userId, NotificationType.SCHEDULE_REMINDER, data);
}

public static ScheduleReminderNotificationEvent of(
final Long userId,
final Long scheduleId,
final String scheduleTitle,
final ScheduleAlram reminderType,
final boolean isAllDay) {
return new ScheduleReminderNotificationEvent(
userId,
ScheduleReminderData.of(scheduleId, scheduleTitle, reminderType, isAllDay));
}

@Override
public String getInAppTitle() {
return "";
}

@Override
public String getInAppBody() {
return "";
}

@Override
public String getPushTitle() {
return getData().getScheduleTitle();
}

@Override
public String getPushBody() {
if (getData().isAllDay()) {
return "일정 하루 전! 준비된 하루를 시작해볼까요? 📅";
}

ScheduleAlram reminderType = getData().getReminderType();
return String.format("일정 %d분 전! 준비된 하루를 시작해볼까요? 📅", reminderType.getMinutes());
}
Comment on lines +44 to +52
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

ONE_DAY enum이 isAllDay=false와 조합될 경우 "1440분 전" 메시지가 표시됩니다.

현재 getPushBodyisAllDay 플래그만으로 분기하므로, ScheduleAlram.ONE_DAY가 비-종일 일정에 사용되면 "일정 1440분 전!" 이라는 부자연스러운 메시지가 생성됩니다. 현재 비즈니스 로직상 이 조합이 발생하지 않는다면 방어 코드나 주석을 추가하는 것을 권장합니다.

🛡️ 방어 로직 예시
     `@Override`
     public String getPushBody() {
         if (getData().isAllDay()) {
             return "일정 하루 전! 준비된 하루를 시작해볼까요? 📅";
         }
 
         ScheduleAlram reminderType = getData().getReminderType();
+        if (reminderType.getMinutes() >= 1440) {
+            return "일정 하루 전! 준비된 하루를 시작해볼까요? 📅";
+        }
         return String.format("일정 %d분 전! 준비된 하루를 시작해볼까요? 📅", reminderType.getMinutes());
     }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
@Override
public String getPushBody() {
if (getData().isAllDay()) {
return "일정 하루 전! 준비된 하루를 시작해볼까요? 📅";
}
ScheduleAlram reminderType = getData().getReminderType();
return String.format("일정 %d분 전! 준비된 하루를 시작해볼까요? 📅", reminderType.getMinutes());
}
`@Override`
public String getPushBody() {
if (getData().isAllDay()) {
return "일정 하루 전! 준비된 하루를 시작해볼까요? 📅";
}
ScheduleAlram reminderType = getData().getReminderType();
if (reminderType.getMinutes() >= 1440) {
return "일정 하루 전! 준비된 하루를 시작해볼까요? 📅";
}
return String.format("일정 %d분 전! 준비된 하루를 시작해볼까요? 📅", reminderType.getMinutes());
}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@src/main/java/im/toduck/domain/notification/domain/event/ScheduleReminderNotificationEvent.java`
around lines 44 - 52, In ScheduleReminderNotificationEvent.getPushBody(), guard
against the case where getData().getReminderType() == ScheduleAlram.ONE_DAY
while getData().isAllDay() is false so you don't render "1440분 전"; update the
method to detect that combination and return a natural string (e.g., use "일정 하루
전! ..." or another short phrase) instead of String.format with minutes, or add a
defensive comment explaining the invariants if you prefer to fail fast;
reference getPushBody(), getData().isAllDay(), getData().getReminderType(), and
ScheduleAlram.ONE_DAY when making the change.


@Override
public String getActionUrl() {
return "toduck://todo";
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
package im.toduck.domain.schedule.domain.event;

import lombok.Getter;

@Getter
public class ScheduleCreatedEvent {
private final Long scheduleId;
private final Long userId;

public ScheduleCreatedEvent(final Long scheduleId, final Long userId) {
this.scheduleId = scheduleId;
this.userId = userId;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
package im.toduck.domain.schedule.domain.event;

import lombok.Getter;

@Getter
public class ScheduleDeletedEvent {
private final Long scheduleId;

public ScheduleDeletedEvent(final Long scheduleId) {
this.scheduleId = scheduleId;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
package im.toduck.domain.schedule.domain.event;

import java.time.LocalDate;
import java.time.LocalDateTime;

import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.scheduling.annotation.Async;
import org.springframework.stereotype.Component;
import org.springframework.transaction.event.TransactionPhase;
import org.springframework.transaction.event.TransactionalEventListener;

import im.toduck.domain.schedule.domain.service.ScheduleReadService;
import im.toduck.domain.schedule.domain.service.ScheduleReminderSchedulerService;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;

@Slf4j
@Component
@RequiredArgsConstructor
@ConditionalOnProperty(name = "spring.quartz.auto-startup", havingValue = "true", matchIfMissing = true)
public class ScheduleReminderEventListener {

private final ScheduleReadService scheduleReadService;
private final ScheduleReminderSchedulerService scheduleReminderSchedulerService;

@Async
@TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
public void handleScheduleCreated(final ScheduleCreatedEvent event) {
log.info("일정 생성 이벤트 처리 시작 - ScheduleId: {}", event.getScheduleId());

try {
scheduleReadService.getScheduleById(event.getScheduleId())
.ifPresent(schedule -> {
LocalDateTime currentDateTime = LocalDateTime.now();
scheduleReminderSchedulerService.scheduleScheduleReminders(schedule, currentDateTime, false);
});
} catch (Exception e) {
log.error("일정 생성 이벤트 처리 중 오류 발생 - ScheduleId: {}", event.getScheduleId(), e);
}
}

@Async
@TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
public void handleScheduleUpdated(final ScheduleUpdatedEvent event) {
log.info("일정 수정 이벤트 처리 시작 - ScheduleId: {}", event.getScheduleId());

try {
if (event.isReminderRelatedChanged()) {
scheduleReminderSchedulerService.cancelFutureScheduleReminders(
event.getScheduleId(), LocalDate.now());

scheduleReadService.getScheduleById(event.getScheduleId())
.ifPresent(schedule -> {
LocalDateTime currentDateTime = LocalDateTime.now();
scheduleReminderSchedulerService.scheduleScheduleReminders(
schedule, currentDateTime, false);
});
Comment on lines +47 to +57
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

취소 후 조회 실패 시 알림이 유실될 수 있습니다.

Line 49-50에서 기존 알림을 먼저 취소한 후, Line 52에서 스케줄을 다시 조회합니다. 만약 getScheduleByIdOptional.empty()를 반환하거나 예외가 발생하면, 기존 알림은 이미 취소되었지만 새 알림은 스케줄링되지 않아 알림이 유실됩니다.

스케줄 조회를 먼저 수행하고, 성공한 경우에만 취소 + 재스케줄링을 진행하는 것을 권장합니다.

🐛 제안: 조회 후 취소 순서로 변경
         try {
             if (event.isReminderRelatedChanged()) {
-                scheduleReminderSchedulerService.cancelFutureScheduleReminders(
-                        event.getScheduleId(), LocalDate.now());
-
                 scheduleReadService.getScheduleById(event.getScheduleId())
                         .ifPresent(schedule -> {
+                            scheduleReminderSchedulerService.cancelFutureScheduleReminders(
+                                    event.getScheduleId(), LocalDate.now());
                             LocalDateTime currentDateTime = LocalDateTime.now();
                             scheduleReminderSchedulerService.scheduleScheduleReminders(
                                     schedule, currentDateTime, false);
                         });
             }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
try {
if (event.isReminderRelatedChanged()) {
scheduleReminderSchedulerService.cancelFutureScheduleReminders(
event.getScheduleId(), LocalDate.now());
scheduleReadService.getScheduleById(event.getScheduleId())
.ifPresent(schedule -> {
LocalDateTime currentDateTime = LocalDateTime.now();
scheduleReminderSchedulerService.scheduleScheduleReminders(
schedule, currentDateTime, false);
});
try {
if (event.isReminderRelatedChanged()) {
scheduleReadService.getScheduleById(event.getScheduleId())
.ifPresent(schedule -> {
scheduleReminderSchedulerService.cancelFutureScheduleReminders(
event.getScheduleId(), LocalDate.now());
LocalDateTime currentDateTime = LocalDateTime.now();
scheduleReminderSchedulerService.scheduleScheduleReminders(
schedule, currentDateTime, false);
});
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@src/main/java/im/toduck/domain/schedule/domain/event/ScheduleReminderEventListener.java`
around lines 47 - 57, The listener currently cancels existing reminders before
verifying the schedule exists, causing potential loss if getScheduleById returns
empty or throws; change ScheduleReminderEventListener so that when
event.isReminderRelatedChanged() you first call
scheduleReadService.getScheduleById(event.getScheduleId()), and only if the
Optional isPresent (and retrieval succeeded) then call
scheduleReminderSchedulerService.cancelFutureScheduleReminders(event.getScheduleId(),
LocalDate.now()) followed by
scheduleReminderSchedulerService.scheduleScheduleReminders(schedule,
LocalDateTime.now(), false); also ensure any exceptions from getScheduleById are
handled/logged and do not trigger cancellation.

}
} catch (Exception e) {
log.error("일정 수정 이벤트 처리 중 오류 발생 - ScheduleId: {}", event.getScheduleId(), e);
}
}

@Async
@TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
public void handleScheduleDeleted(final ScheduleDeletedEvent event) {
log.info("일정 삭제 이벤트 처리 시작 - ScheduleId: {}", event.getScheduleId());

try {
scheduleReminderSchedulerService.cancelAllScheduleReminders(event.getScheduleId());
} catch (Exception e) {
log.error("일정 삭제 이벤트 처리 중 오류 발생 - ScheduleId: {}", event.getScheduleId(), e);
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
package im.toduck.domain.schedule.domain.event;

import lombok.Getter;

@Getter
public class ScheduleUpdatedEvent {
private final Long scheduleId;
private final Long userId;
private final boolean isAlarmChanged;
private final boolean isTimeChanged;
private final boolean isAllDayChanged;
private final boolean isTitleChanged;
private final boolean isDateChanged;
private final boolean isDaysOfWeekChanged;

public ScheduleUpdatedEvent(
final Long scheduleId,
final Long userId,
final boolean isAlarmChanged,
final boolean isTimeChanged,
final boolean isAllDayChanged,
final boolean isTitleChanged,
final boolean isDateChanged,
final boolean isDaysOfWeekChanged) {
this.scheduleId = scheduleId;
this.userId = userId;
this.isAlarmChanged = isAlarmChanged;
this.isTimeChanged = isTimeChanged;
this.isAllDayChanged = isAllDayChanged;
this.isTitleChanged = isTitleChanged;
this.isDateChanged = isDateChanged;
this.isDaysOfWeekChanged = isDaysOfWeekChanged;
}

public boolean isReminderRelatedChanged() {
return isAlarmChanged || isTimeChanged || isAllDayChanged || isTitleChanged
|| isDateChanged || isDaysOfWeekChanged;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -35,19 +35,19 @@ public class ScheduleReadService {
public ScheduleHeadResponse getRangeSchedule(User user, LocalDate startDate, LocalDate endDate) {
List<ScheduleHeadResponse.ScheduleHeadDto> scheduleHeadDtos = new ArrayList<>();
scheduleRepository.findSchedules(user.getId(), startDate, endDate)
.forEach(schedule -> {
List<ScheduleRecord> scheduleRecordList = scheduleRecordRepository
.findByScheduleAndBetweenStartDateAndEndDate(schedule.getId(), startDate, endDate);
scheduleHeadDtos.add(ScheduleMapper.toScheduleHeadDto(schedule, scheduleRecordList));
});
.forEach(schedule -> {
List<ScheduleRecord> scheduleRecordList = scheduleRecordRepository
.findByScheduleAndBetweenStartDateAndEndDate(schedule.getId(), startDate, endDate);
scheduleHeadDtos.add(ScheduleMapper.toScheduleHeadDto(schedule, scheduleRecordList));
});
return ScheduleMapper.toScheduleHeadResponse(startDate, endDate, scheduleHeadDtos);
}

@Transactional(readOnly = true)
public ScheduleInfoResponse getSchedule(Long scheduleRecordId) {
return scheduleRecordRepository.findScheduleRecordFetchJoinSchedule(scheduleRecordId)
.map(ScheduleMapper::toScheduleInfoResponse)
.orElseThrow(() -> CommonException.from(ExceptionCode.NOT_FOUND_SCHEDULE_RECORD));
.map(ScheduleMapper::toScheduleInfoResponse)
.orElseThrow(() -> CommonException.from(ExceptionCode.NOT_FOUND_SCHEDULE_RECORD));
}

public Optional<Schedule> getScheduleById(Long scheduleId) {
Expand All @@ -56,7 +56,7 @@ public Optional<Schedule> getScheduleById(Long scheduleId) {

public Schedule validateScheduleById(Long scheduleId) {
return scheduleRepository.findById(scheduleId)
.orElseThrow(() -> CommonException.from(ExceptionCode.NOT_FOUND_SCHEDULE));
.orElseThrow(() -> CommonException.from(ExceptionCode.NOT_FOUND_SCHEDULE));
}

@Transactional(readOnly = true)
Expand All @@ -80,16 +80,19 @@ public long getSchedulesCountByDateRange(final LocalDate startDate, final LocalD

@Transactional(readOnly = true)
public Map<LocalDate, Long> getSchedulesCountByDateRangeGroupByDate(
final LocalDate startDate,
final LocalDate endDate
) {
final LocalDate startDate,
final LocalDate endDate) {
LocalDateTime startDateTime = startDate.atStartOfDay();
LocalDateTime endDateTime = endDate.atTime(LocalTime.MAX);

List<DailyCount> dailyCounts = scheduleRepository.countByCreatedAtBetweenGroupByDate(
startDateTime, endDateTime
);
startDateTime, endDateTime);

return dailyCounts.stream().collect(Collectors.toMap(DailyCount::date, DailyCount::count));
}

@Transactional(readOnly = true)
public List<Schedule> findActiveSchedulesWithAlarmForDates(final LocalDate startDate, final LocalDate endDate) {
return scheduleRepository.findActiveSchedulesWithAlarmForDates(startDate, endDate);
}
}
Loading
Loading