From 44274f65ed2b59152ac7fbccce514262b6f41157 Mon Sep 17 00:00:00 2001 From: jjoonleo Date: Tue, 5 May 2026 18:16:25 +0900 Subject: [PATCH 1/2] chore: ignore local development files --- .DS_Store | Bin 6148 -> 0 bytes .gitignore | 3 + .idea/.gitignore | 8 - .idea/compiler.xml | 15 - .idea/dataSources.xml | 19 - .idea/dbnavigator.xml | 409 ---------------------- .idea/gradle.xml | 16 - .idea/jarRepositories.xml | 20 -- .idea/misc.xml | 7 - .idea/modules.xml | 8 - .idea/modules/Ontime.ontime-back.main.iml | 8 - .idea/modules/devkor.ontime-back.main.iml | 8 - .idea/modules/ontime-back.main.iml | 7 - .idea/ontime-back.iml | 9 - .idea/sqlDataSources.xml | 8 - .idea/uiDesigner.xml | 124 ------- .idea/vcs.xml | 6 - ontime-back/.gitignore | 1 + 18 files changed, 4 insertions(+), 672 deletions(-) delete mode 100644 .DS_Store create mode 100644 .gitignore delete mode 100644 .idea/.gitignore delete mode 100644 .idea/compiler.xml delete mode 100644 .idea/dataSources.xml delete mode 100644 .idea/dbnavigator.xml delete mode 100644 .idea/gradle.xml delete mode 100644 .idea/jarRepositories.xml delete mode 100644 .idea/misc.xml delete mode 100644 .idea/modules.xml delete mode 100644 .idea/modules/Ontime.ontime-back.main.iml delete mode 100644 .idea/modules/devkor.ontime-back.main.iml delete mode 100644 .idea/modules/ontime-back.main.iml delete mode 100644 .idea/ontime-back.iml delete mode 100644 .idea/sqlDataSources.xml delete mode 100644 .idea/uiDesigner.xml delete mode 100644 .idea/vcs.xml diff --git a/.DS_Store b/.DS_Store deleted file mode 100644 index 4dca84cb5bb2658998ee4b0ccc1b9543ae76f4d1..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 6148 zcmeHKO>fgc5S>i}wG|=d04m3oxTYm75DE3-hV;OJOI^`JLBXygug|YwZUSss1$ECTbH=0B3B3C~h(CXJ4|0Z)pOR zea0D$Xp>S(=wvBd8XlqoJa;WJR8mDbU7x?x_k}r03zH&)e~+K=%|F%6! zO3#aYQgyq(Rb#FB=<#~69<+kb@i#MztE8Gv`pNj1Ylp@Z>1CXxAM?>{*naxXlvR?K zqp>CA*$640j`A`yv%Z;@*~D^V*Aaw4IBajs=i58Ij(*cyboBh~PPe09zv?X(Vest5 z%iV+FX>nGX?{@g4@XK0z;_(uGAXp*EMLaGGQ+`DTvqL(decGb|eE@DjzU&{-_K>x8 zO*B-+L;+Di6u5r{`~n!X?tdsU0#QH|cwh?f`k>*Ak;mGhT{=+MD*%v>k=79NVUao7 zW8|@Rh#r`7RG_0Of5lLaj&|+yB9FC0M - - - - - - - - - - - - - - \ No newline at end of file diff --git a/.idea/dataSources.xml b/.idea/dataSources.xml deleted file mode 100644 index 6faad9e0..00000000 --- a/.idea/dataSources.xml +++ /dev/null @@ -1,19 +0,0 @@ - - - - - mysql.8 - true - true - $PROJECT_DIR$/ontime-back/src/main/resources/application.properties - com.mysql.cj.jdbc.Driver - jdbc:mysql://localhost:3306/ontime - - - - - - $ProjectFileDir$ - - - \ No newline at end of file diff --git a/.idea/dbnavigator.xml b/.idea/dbnavigator.xml deleted file mode 100644 index 3cd3e9d6..00000000 --- a/.idea/dbnavigator.xml +++ /dev/null @@ -1,409 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - \ No newline at end of file diff --git a/.idea/gradle.xml b/.idea/gradle.xml deleted file mode 100644 index 3c55f852..00000000 --- a/.idea/gradle.xml +++ /dev/null @@ -1,16 +0,0 @@ - - - - - - - \ No newline at end of file diff --git a/.idea/jarRepositories.xml b/.idea/jarRepositories.xml deleted file mode 100644 index fdc392fe..00000000 --- a/.idea/jarRepositories.xml +++ /dev/null @@ -1,20 +0,0 @@ - - - - - - - - - - - \ No newline at end of file diff --git a/.idea/misc.xml b/.idea/misc.xml deleted file mode 100644 index b0137f1c..00000000 --- a/.idea/misc.xml +++ /dev/null @@ -1,7 +0,0 @@ - - - - - - - \ No newline at end of file diff --git a/.idea/modules.xml b/.idea/modules.xml deleted file mode 100644 index fe7e74f5..00000000 --- a/.idea/modules.xml +++ /dev/null @@ -1,8 +0,0 @@ - - - - - - - - \ No newline at end of file diff --git a/.idea/modules/Ontime.ontime-back.main.iml b/.idea/modules/Ontime.ontime-back.main.iml deleted file mode 100644 index 2b3b8afc..00000000 --- a/.idea/modules/Ontime.ontime-back.main.iml +++ /dev/null @@ -1,8 +0,0 @@ - - - - - - - - \ No newline at end of file diff --git a/.idea/modules/devkor.ontime-back.main.iml b/.idea/modules/devkor.ontime-back.main.iml deleted file mode 100644 index 2b3b8afc..00000000 --- a/.idea/modules/devkor.ontime-back.main.iml +++ /dev/null @@ -1,8 +0,0 @@ - - - - - - - - \ No newline at end of file diff --git a/.idea/modules/ontime-back.main.iml b/.idea/modules/ontime-back.main.iml deleted file mode 100644 index f4cd67e5..00000000 --- a/.idea/modules/ontime-back.main.iml +++ /dev/null @@ -1,7 +0,0 @@ - - - - - - - \ No newline at end of file diff --git a/.idea/ontime-back.iml b/.idea/ontime-back.iml deleted file mode 100644 index d6ebd480..00000000 --- a/.idea/ontime-back.iml +++ /dev/null @@ -1,9 +0,0 @@ - - - - - - - - - \ No newline at end of file diff --git a/.idea/sqlDataSources.xml b/.idea/sqlDataSources.xml deleted file mode 100644 index f87c1238..00000000 --- a/.idea/sqlDataSources.xml +++ /dev/null @@ -1,8 +0,0 @@ - - - - - - - - \ No newline at end of file diff --git a/.idea/uiDesigner.xml b/.idea/uiDesigner.xml deleted file mode 100644 index 2b63946d..00000000 --- a/.idea/uiDesigner.xml +++ /dev/null @@ -1,124 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - \ No newline at end of file diff --git a/.idea/vcs.xml b/.idea/vcs.xml deleted file mode 100644 index 35eb1ddf..00000000 --- a/.idea/vcs.xml +++ /dev/null @@ -1,6 +0,0 @@ - - - - - - \ No newline at end of file diff --git a/ontime-back/.gitignore b/ontime-back/.gitignore index 3bf355b1..e251a71e 100644 --- a/ontime-back/.gitignore +++ b/ontime-back/.gitignore @@ -1,6 +1,7 @@ src/main/resources/application.properties src/main/resources/ontime-c63f1-firebase-adminsdk-fbsvc-a043cdc829.json src/main/resources/key/AuthKey_743M7R5W3W.p8 +.env HELP.md .gradle From bed3b7b43f8cb4f8c078a7518678ae498b0cf0dc Mon Sep 17 00:00:00 2001 From: jjoonleo Date: Tue, 5 May 2026 21:42:38 +0900 Subject: [PATCH 2/2] Implement backend alarm feature --- .../ontime_back/config/SecurityConfig.java | 6 +- .../controller/AlarmController.java | 83 +++ .../controller/FirebaseTokenController.java | 5 +- .../controller/ScheduleController.java | 31 +- .../dto/AlarmDeviceCurrentRequestDto.java | 22 + .../dto/AlarmDeviceCurrentResponseDto.java | 16 + .../dto/AlarmDeviceUnregisterRequestDto.java | 16 + .../dto/AlarmDeviceUnregisterResponseDto.java | 12 + .../dto/AlarmSettingsResponseDto.java | 16 + .../dto/AlarmStatusCurrentResponseDto.java | 36 ++ .../dto/AlarmStatusFailureDto.java | 17 + .../dto/AlarmStatusReportRequestDto.java | 33 ++ .../dto/AlarmStatusReportResponseDto.java | 12 + .../dto/AlarmWindowScheduleDto.java | 27 + .../ontime_back/dto/FirebaseTokenAddDto.java | 1 + .../devkor/ontime_back/entity/Schedule.java | 12 +- .../ontime_back/entity/UserAlarmSetting.java | 71 +++ .../ontime_back/entity/UserAlarmStatus.java | 118 ++++ .../devkor/ontime_back/entity/UserDevice.java | 120 +++++ .../global/oauth/apple/AppleLoginService.java | 10 +- .../oauth/google/GoogleLoginService.java | 12 +- .../global/oauth/kakao/KakaoLoginFilter.java | 30 +- .../repository/ScheduleRepository.java | 14 + .../UserAlarmSettingRepository.java | 12 + .../repository/UserAlarmStatusRepository.java | 12 + .../repository/UserDeviceRepository.java | 19 + .../ontime_back/response/ErrorCode.java | 5 +- .../ontime_back/service/AlarmService.java | 508 ++++++++++++++++++ .../service/FirebaseTokenService.java | 6 +- .../service/FriendshipService.java | 4 + .../service/NotificationService.java | 11 +- .../ontime_back/service/ScheduleService.java | 105 +++- .../ontime_back/service/UserAuthService.java | 32 +- .../V8__add_native_alarm_support.sql | 57 ++ .../ontime_back/service/AlarmServiceTest.java | 238 ++++++++ 35 files changed, 1700 insertions(+), 29 deletions(-) create mode 100644 ontime-back/src/main/java/devkor/ontime_back/controller/AlarmController.java create mode 100644 ontime-back/src/main/java/devkor/ontime_back/dto/AlarmDeviceCurrentRequestDto.java create mode 100644 ontime-back/src/main/java/devkor/ontime_back/dto/AlarmDeviceCurrentResponseDto.java create mode 100644 ontime-back/src/main/java/devkor/ontime_back/dto/AlarmDeviceUnregisterRequestDto.java create mode 100644 ontime-back/src/main/java/devkor/ontime_back/dto/AlarmDeviceUnregisterResponseDto.java create mode 100644 ontime-back/src/main/java/devkor/ontime_back/dto/AlarmSettingsResponseDto.java create mode 100644 ontime-back/src/main/java/devkor/ontime_back/dto/AlarmStatusCurrentResponseDto.java create mode 100644 ontime-back/src/main/java/devkor/ontime_back/dto/AlarmStatusFailureDto.java create mode 100644 ontime-back/src/main/java/devkor/ontime_back/dto/AlarmStatusReportRequestDto.java create mode 100644 ontime-back/src/main/java/devkor/ontime_back/dto/AlarmStatusReportResponseDto.java create mode 100644 ontime-back/src/main/java/devkor/ontime_back/dto/AlarmWindowScheduleDto.java create mode 100644 ontime-back/src/main/java/devkor/ontime_back/entity/UserAlarmSetting.java create mode 100644 ontime-back/src/main/java/devkor/ontime_back/entity/UserAlarmStatus.java create mode 100644 ontime-back/src/main/java/devkor/ontime_back/entity/UserDevice.java create mode 100644 ontime-back/src/main/java/devkor/ontime_back/repository/UserAlarmSettingRepository.java create mode 100644 ontime-back/src/main/java/devkor/ontime_back/repository/UserAlarmStatusRepository.java create mode 100644 ontime-back/src/main/java/devkor/ontime_back/repository/UserDeviceRepository.java create mode 100644 ontime-back/src/main/java/devkor/ontime_back/service/AlarmService.java create mode 100644 ontime-back/src/main/resources/db/migration/V8__add_native_alarm_support.sql create mode 100644 ontime-back/src/test/java/devkor/ontime_back/service/AlarmServiceTest.java diff --git a/ontime-back/src/main/java/devkor/ontime_back/config/SecurityConfig.java b/ontime-back/src/main/java/devkor/ontime_back/config/SecurityConfig.java index 83e106be..76ea1e4c 100644 --- a/ontime-back/src/main/java/devkor/ontime_back/config/SecurityConfig.java +++ b/ontime-back/src/main/java/devkor/ontime_back/config/SecurityConfig.java @@ -15,6 +15,7 @@ import devkor.ontime_back.global.oauth.google.GoogleLoginService; import devkor.ontime_back.global.oauth.kakao.KakaoLoginFilter; import devkor.ontime_back.global.oauth.google.GoogleLoginFilter; +import devkor.ontime_back.repository.UserAlarmSettingRepository; import devkor.ontime_back.repository.UserRepository; import lombok.RequiredArgsConstructor; import org.springframework.beans.factory.annotation.Value; @@ -51,6 +52,7 @@ public class SecurityConfig { private final LoginService loginService; private final JwtTokenProvider jwtTokenProvider; private final UserRepository userRepository; + private final UserAlarmSettingRepository userAlarmSettingRepository; private final ObjectMapper objectMapper; private final AppleLoginService appleLoginService; private final GoogleLoginService googleLoginService; @@ -75,7 +77,7 @@ public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { .requestMatchers("/health").permitAll() // 로드밸런서 연결 확인용 url .anyRequest().authenticated() ) - .addFilterBefore(new KakaoLoginFilter("/oauth2/kakao/login", jwtTokenProvider, userRepository), + .addFilterBefore(new KakaoLoginFilter("/oauth2/kakao/login", jwtTokenProvider, userRepository, userAlarmSettingRepository), UsernamePasswordAuthenticationFilter.class) .addFilterBefore(new GoogleLoginFilter("/oauth2/google/login", googleLoginService, userRepository), UsernamePasswordAuthenticationFilter.class) @@ -145,4 +147,4 @@ public CorsConfigurationSource corsConfigurationSource() { source.registerCorsConfiguration("/**", configuration); return source; } -} \ No newline at end of file +} diff --git a/ontime-back/src/main/java/devkor/ontime_back/controller/AlarmController.java b/ontime-back/src/main/java/devkor/ontime_back/controller/AlarmController.java new file mode 100644 index 00000000..c4e498b0 --- /dev/null +++ b/ontime-back/src/main/java/devkor/ontime_back/controller/AlarmController.java @@ -0,0 +1,83 @@ +package devkor.ontime_back.controller; + +import devkor.ontime_back.dto.*; +import devkor.ontime_back.response.ApiResponseForm; +import devkor.ontime_back.service.AlarmService; +import devkor.ontime_back.service.UserAuthService; +import jakarta.servlet.http.HttpServletRequest; +import lombok.RequiredArgsConstructor; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; + +import java.util.Map; + +@RestController +@RequiredArgsConstructor +public class AlarmController { + + private final UserAuthService userAuthService; + private final AlarmService alarmService; + + @GetMapping("/users/me/alarm-settings") + public ResponseEntity> getAlarmSettings(HttpServletRequest request) { + Long userId = userAuthService.getUserIdFromToken(request); + return ResponseEntity.status(HttpStatus.OK) + .body(ApiResponseForm.success(alarmService.getAlarmSettings(userId))); + } + + @PatchMapping("/users/me/alarm-settings") + public ResponseEntity> patchAlarmSettings( + HttpServletRequest request, + @RequestBody Map requestBody) { + Long userId = userAuthService.getUserIdFromToken(request); + return ResponseEntity.status(HttpStatus.OK) + .body(ApiResponseForm.success(alarmService.patchAlarmSettings(userId, requestBody))); + } + + @PutMapping("/users/me/devices/current") + public ResponseEntity> registerCurrentDevice( + HttpServletRequest request, + @RequestBody AlarmDeviceCurrentRequestDto requestDto) { + Long userId = userAuthService.getUserIdFromToken(request); + return ResponseEntity.status(HttpStatus.OK) + .body(ApiResponseForm.success(alarmService.registerCurrentDevice( + userId, + requestDto, + userAuthService.getAccessTokenFromRequest(request), + userAuthService.getRefreshTokenFromRequest(request)))); + } + + @DeleteMapping("/users/me/devices/current") + public ResponseEntity> unregisterCurrentDevice( + HttpServletRequest request, + @RequestBody(required = false) AlarmDeviceUnregisterRequestDto requestDto) { + Long userId = userAuthService.getUserIdFromToken(request); + return ResponseEntity.status(HttpStatus.OK) + .body(ApiResponseForm.success(alarmService.unregisterCurrentDevice( + userId, + requestDto, + userAuthService.getAccessTokenFromRequest(request)))); + } + + @PostMapping("/users/me/alarm-status") + public ResponseEntity> reportAlarmStatus( + HttpServletRequest request, + @RequestBody AlarmStatusReportRequestDto requestDto) { + Long userId = userAuthService.getUserIdFromToken(request); + return ResponseEntity.status(HttpStatus.OK) + .body(ApiResponseForm.success(alarmService.reportAlarmStatus( + userId, + requestDto, + userAuthService.getAccessTokenFromRequest(request)))); + } + + @GetMapping("/users/me/alarm-status") + public ResponseEntity> getCurrentAlarmStatus(HttpServletRequest request) { + Long userId = userAuthService.getUserIdFromToken(request); + return ResponseEntity.status(HttpStatus.OK) + .body(ApiResponseForm.success(alarmService.getCurrentAlarmStatus( + userId, + userAuthService.getAccessTokenFromRequest(request)))); + } +} diff --git a/ontime-back/src/main/java/devkor/ontime_back/controller/FirebaseTokenController.java b/ontime-back/src/main/java/devkor/ontime_back/controller/FirebaseTokenController.java index fc0584ec..8350e011 100644 --- a/ontime-back/src/main/java/devkor/ontime_back/controller/FirebaseTokenController.java +++ b/ontime-back/src/main/java/devkor/ontime_back/controller/FirebaseTokenController.java @@ -50,7 +50,10 @@ public class FirebaseTokenController { public ResponseEntity> registerFirebaseToken(HttpServletRequest request, @RequestBody FirebaseTokenAddDto firebaseTokenAddDto) { Long userId = userAuthService.getUserIdFromToken(request); - firebaseTokenService.registerFirebaseToken(userId, firebaseTokenAddDto); + firebaseTokenService.registerFirebaseToken( + userId, + firebaseTokenAddDto, + userAuthService.getAccessTokenFromRequest(request)); String message = "FCM 토큰이 성공적으로 User테이블에 저장되었습니다!"; return ResponseEntity.ok(ApiResponseForm.success(null, message)); diff --git a/ontime-back/src/main/java/devkor/ontime_back/controller/ScheduleController.java b/ontime-back/src/main/java/devkor/ontime_back/controller/ScheduleController.java index e74551da..4fe69833 100644 --- a/ontime-back/src/main/java/devkor/ontime_back/controller/ScheduleController.java +++ b/ontime-back/src/main/java/devkor/ontime_back/controller/ScheduleController.java @@ -1,6 +1,7 @@ package devkor.ontime_back.controller; import devkor.ontime_back.dto.*; +import devkor.ontime_back.response.GeneralException; import devkor.ontime_back.response.ApiResponseForm; import devkor.ontime_back.service.ScheduleService; import devkor.ontime_back.service.UserAuthService; @@ -17,10 +18,14 @@ import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.*; +import java.time.DateTimeException; import java.time.LocalDateTime; +import java.time.format.DateTimeFormatter; import java.util.List; import java.util.UUID; +import static devkor.ontime_back.response.ErrorCode.INVALID_INPUT; + @RestController @RequestMapping("/schedules") @RequiredArgsConstructor @@ -65,6 +70,20 @@ public ResponseEntity>> getPeriodSchedule(Http return ResponseEntity.status(HttpStatus.OK).body(ApiResponseForm.success(schedules)); } + @GetMapping("/alarm-window") + public ResponseEntity>> getAlarmWindowSchedules( + HttpServletRequest request, + @RequestParam String startDate, + @RequestParam String endDate) { + Long userId = userAuthService.getUserIdFromToken(request); + List schedules = scheduleService.getAlarmWindowSchedules( + userId, + parseLocalWallClockDateTime(startDate), + parseLocalWallClockDateTime(endDate) + ); + return ResponseEntity.status(HttpStatus.OK).body(ApiResponseForm.success(schedules)); + } + // id로 스케줄 조회 @Operation(summary = "일정 id로 일정 조회", requestBody = @io.swagger.v3.oas.annotations.parameters.RequestBody( @@ -262,6 +281,16 @@ public ResponseEntity> finishSchedule( return ResponseEntity.ok(ApiResponseForm.success(null, message)); } + private LocalDateTime parseLocalWallClockDateTime(String value) { + if (value == null || value.isBlank()) { + throw new GeneralException(INVALID_INPUT); + } + try { + return LocalDateTime.from(DateTimeFormatter.ISO_DATE_TIME.parse(value)); + } catch (DateTimeException e) { + throw new GeneralException(INVALID_INPUT); + } + } + } - diff --git a/ontime-back/src/main/java/devkor/ontime_back/dto/AlarmDeviceCurrentRequestDto.java b/ontime-back/src/main/java/devkor/ontime_back/dto/AlarmDeviceCurrentRequestDto.java new file mode 100644 index 00000000..4d1884bc --- /dev/null +++ b/ontime-back/src/main/java/devkor/ontime_back/dto/AlarmDeviceCurrentRequestDto.java @@ -0,0 +1,22 @@ +package devkor.ontime_back.dto; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.ToString; + +@Getter +@Builder +@ToString +@NoArgsConstructor +@AllArgsConstructor +public class AlarmDeviceCurrentRequestDto { + private String deviceId; + private String platform; + private String appVersion; + private String osVersion; + private Boolean supportsNativeAlarm; + private String nativeAlarmProvider; + private String fallbackProvider; +} diff --git a/ontime-back/src/main/java/devkor/ontime_back/dto/AlarmDeviceCurrentResponseDto.java b/ontime-back/src/main/java/devkor/ontime_back/dto/AlarmDeviceCurrentResponseDto.java new file mode 100644 index 00000000..c5f1d953 --- /dev/null +++ b/ontime-back/src/main/java/devkor/ontime_back/dto/AlarmDeviceCurrentResponseDto.java @@ -0,0 +1,16 @@ +package devkor.ontime_back.dto; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; + +import java.time.Instant; + +@Getter +@Builder +@AllArgsConstructor +public class AlarmDeviceCurrentResponseDto { + private String deviceId; + private Boolean active; + private Instant lastSeenAt; +} diff --git a/ontime-back/src/main/java/devkor/ontime_back/dto/AlarmDeviceUnregisterRequestDto.java b/ontime-back/src/main/java/devkor/ontime_back/dto/AlarmDeviceUnregisterRequestDto.java new file mode 100644 index 00000000..90530f8a --- /dev/null +++ b/ontime-back/src/main/java/devkor/ontime_back/dto/AlarmDeviceUnregisterRequestDto.java @@ -0,0 +1,16 @@ +package devkor.ontime_back.dto; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.ToString; + +@Getter +@Builder +@ToString +@NoArgsConstructor +@AllArgsConstructor +public class AlarmDeviceUnregisterRequestDto { + private String deviceId; +} diff --git a/ontime-back/src/main/java/devkor/ontime_back/dto/AlarmDeviceUnregisterResponseDto.java b/ontime-back/src/main/java/devkor/ontime_back/dto/AlarmDeviceUnregisterResponseDto.java new file mode 100644 index 00000000..2110021f --- /dev/null +++ b/ontime-back/src/main/java/devkor/ontime_back/dto/AlarmDeviceUnregisterResponseDto.java @@ -0,0 +1,12 @@ +package devkor.ontime_back.dto; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; + +@Getter +@Builder +@AllArgsConstructor +public class AlarmDeviceUnregisterResponseDto { + private Boolean active; +} diff --git a/ontime-back/src/main/java/devkor/ontime_back/dto/AlarmSettingsResponseDto.java b/ontime-back/src/main/java/devkor/ontime_back/dto/AlarmSettingsResponseDto.java new file mode 100644 index 00000000..85470545 --- /dev/null +++ b/ontime-back/src/main/java/devkor/ontime_back/dto/AlarmSettingsResponseDto.java @@ -0,0 +1,16 @@ +package devkor.ontime_back.dto; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; + +import java.time.Instant; + +@Getter +@Builder +@AllArgsConstructor +public class AlarmSettingsResponseDto { + private Boolean alarmsEnabled; + private Integer defaultAlarmOffsetMinutes; + private Instant updatedAt; +} diff --git a/ontime-back/src/main/java/devkor/ontime_back/dto/AlarmStatusCurrentResponseDto.java b/ontime-back/src/main/java/devkor/ontime_back/dto/AlarmStatusCurrentResponseDto.java new file mode 100644 index 00000000..3274908d --- /dev/null +++ b/ontime-back/src/main/java/devkor/ontime_back/dto/AlarmStatusCurrentResponseDto.java @@ -0,0 +1,36 @@ +package devkor.ontime_back.dto; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; + +import java.time.Instant; +import java.time.LocalDateTime; +import java.util.List; + +@Getter +@Builder +@AllArgsConstructor +public class AlarmStatusCurrentResponseDto { + private String deviceId; + private Boolean active; + private String platform; + private String appVersion; + private String osVersion; + private Boolean supportsNativeAlarm; + private String nativeAlarmProvider; + private String fallbackProvider; + private Instant lastSeenAt; + private Instant reconciledAt; + private LocalDateTime scheduleWindowStart; + private LocalDateTime scheduleWindowEnd; + private LocalDateTime alarmCoverageStart; + private LocalDateTime alarmCoverageEnd; + private String status; + private String permissionIssue; + private Integer armedScheduleCount; + private List armedScheduleIds; + private Integer skippedScheduleCount; + private List failures; + private Instant updatedAt; +} diff --git a/ontime-back/src/main/java/devkor/ontime_back/dto/AlarmStatusFailureDto.java b/ontime-back/src/main/java/devkor/ontime_back/dto/AlarmStatusFailureDto.java new file mode 100644 index 00000000..86f4055f --- /dev/null +++ b/ontime-back/src/main/java/devkor/ontime_back/dto/AlarmStatusFailureDto.java @@ -0,0 +1,17 @@ +package devkor.ontime_back.dto; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.ToString; + +@Getter +@Builder +@ToString +@NoArgsConstructor +@AllArgsConstructor +public class AlarmStatusFailureDto { + private String scheduleId; + private String reason; +} diff --git a/ontime-back/src/main/java/devkor/ontime_back/dto/AlarmStatusReportRequestDto.java b/ontime-back/src/main/java/devkor/ontime_back/dto/AlarmStatusReportRequestDto.java new file mode 100644 index 00000000..a8e78280 --- /dev/null +++ b/ontime-back/src/main/java/devkor/ontime_back/dto/AlarmStatusReportRequestDto.java @@ -0,0 +1,33 @@ +package devkor.ontime_back.dto; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.ToString; + +import java.time.LocalDateTime; +import java.time.OffsetDateTime; +import java.util.List; + +@Getter +@Builder +@ToString +@NoArgsConstructor +@AllArgsConstructor +public class AlarmStatusReportRequestDto { + private String deviceId; + private OffsetDateTime reconciledAt; + private LocalDateTime scheduleWindowStart; + private LocalDateTime scheduleWindowEnd; + private LocalDateTime alarmCoverageStart; + private LocalDateTime alarmCoverageEnd; + private String status; + private String permissionIssue; + private String nativeAlarmProvider; + private String fallbackProvider; + private Integer armedScheduleCount; + private List armedScheduleIds; + private Integer skippedScheduleCount; + private List failures; +} diff --git a/ontime-back/src/main/java/devkor/ontime_back/dto/AlarmStatusReportResponseDto.java b/ontime-back/src/main/java/devkor/ontime_back/dto/AlarmStatusReportResponseDto.java new file mode 100644 index 00000000..72e23202 --- /dev/null +++ b/ontime-back/src/main/java/devkor/ontime_back/dto/AlarmStatusReportResponseDto.java @@ -0,0 +1,12 @@ +package devkor.ontime_back.dto; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; + +@Getter +@Builder +@AllArgsConstructor +public class AlarmStatusReportResponseDto { + private Boolean received; +} diff --git a/ontime-back/src/main/java/devkor/ontime_back/dto/AlarmWindowScheduleDto.java b/ontime-back/src/main/java/devkor/ontime_back/dto/AlarmWindowScheduleDto.java new file mode 100644 index 00000000..f33da759 --- /dev/null +++ b/ontime-back/src/main/java/devkor/ontime_back/dto/AlarmWindowScheduleDto.java @@ -0,0 +1,27 @@ +package devkor.ontime_back.dto; + +import devkor.ontime_back.entity.DoneStatus; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; + +import java.time.LocalDateTime; +import java.util.List; +import java.util.UUID; + +@Getter +@Builder +@AllArgsConstructor +public class AlarmWindowScheduleDto { + private UUID scheduleId; + private String scheduleName; + private PlaceDto place; + private LocalDateTime scheduleTime; + private Integer moveTime; + private Integer scheduleSpareTime; + private DoneStatus doneStatus; + private LocalDateTime preparationStartTime; + private LocalDateTime defaultAlarmTime; + private List preparations; + private Object alarmSettings; +} diff --git a/ontime-back/src/main/java/devkor/ontime_back/dto/FirebaseTokenAddDto.java b/ontime-back/src/main/java/devkor/ontime_back/dto/FirebaseTokenAddDto.java index 136cb6f2..e15f7d6e 100644 --- a/ontime-back/src/main/java/devkor/ontime_back/dto/FirebaseTokenAddDto.java +++ b/ontime-back/src/main/java/devkor/ontime_back/dto/FirebaseTokenAddDto.java @@ -7,4 +7,5 @@ @Getter public class FirebaseTokenAddDto { String firebaseToken; + String deviceId; } diff --git a/ontime-back/src/main/java/devkor/ontime_back/entity/Schedule.java b/ontime-back/src/main/java/devkor/ontime_back/entity/Schedule.java index 861d2d14..62143922 100644 --- a/ontime-back/src/main/java/devkor/ontime_back/entity/Schedule.java +++ b/ontime-back/src/main/java/devkor/ontime_back/entity/Schedule.java @@ -66,6 +66,7 @@ public void updateSchedule(Place place, ScheduleModDto scheduleModDto) { this.scheduleSpareTime = scheduleModDto.getScheduleSpareTime(); this.latenessTime = scheduleModDto.getLatenessTime(); this.scheduleNote = scheduleModDto.getScheduleNote(); + syncDoneStatusFromLatenessTime(); } public void startSchedule() { @@ -77,12 +78,16 @@ public void startSchedule() { public void updateLatenessTime(Integer latenessTime) { this.latenessTime = latenessTime; - if (latenessTime > 0) { + syncDoneStatusFromLatenessTime(); + } + + private void syncDoneStatusFromLatenessTime() { + if (latenessTime == null || latenessTime == -1) { + this.doneStatus = DoneStatus.NOT_ENDED; + } else if (latenessTime > 0) { this.doneStatus = DoneStatus.LATE; } else if (latenessTime == 0) { this.doneStatus = DoneStatus.NORMAL; - } else if (latenessTime == -1) { - this.doneStatus = DoneStatus.NOT_ENDED; } else { this.doneStatus = DoneStatus.ABNORMAL; @@ -90,4 +95,3 @@ public void updateLatenessTime(Integer latenessTime) { } } - diff --git a/ontime-back/src/main/java/devkor/ontime_back/entity/UserAlarmSetting.java b/ontime-back/src/main/java/devkor/ontime_back/entity/UserAlarmSetting.java new file mode 100644 index 00000000..f4a6eaa2 --- /dev/null +++ b/ontime-back/src/main/java/devkor/ontime_back/entity/UserAlarmSetting.java @@ -0,0 +1,71 @@ +package devkor.ontime_back.entity; + +import jakarta.persistence.*; +import lombok.AccessLevel; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; +import org.hibernate.annotations.OnDelete; +import org.hibernate.annotations.OnDeleteAction; + +import java.time.Instant; + +@Getter +@Entity +@Builder +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@AllArgsConstructor +@Table( + uniqueConstraints = { + @UniqueConstraint(name = "uk_user_alarm_setting_user", columnNames = "user_id") + } +) +public class UserAlarmSetting { + + public static final int DEFAULT_ALARM_OFFSET_MINUTES = 5; + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long userAlarmSettingId; + + @OneToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "user_id", nullable = false) + @OnDelete(action = OnDeleteAction.CASCADE) + private User user; + + @Column(nullable = false) + private Boolean alarmsEnabled; + + @Column(nullable = false) + private Integer defaultAlarmOffsetMinutes; + + @Column(nullable = false) + private Instant updatedAt; + + public static UserAlarmSetting defaultFor(User user) { + return UserAlarmSetting.builder() + .user(user) + .alarmsEnabled(true) + .defaultAlarmOffsetMinutes(DEFAULT_ALARM_OFFSET_MINUTES) + .updatedAt(Instant.now()) + .build(); + } + + @PrePersist + private void initializeDefaults() { + if (alarmsEnabled == null) alarmsEnabled = true; + if (defaultAlarmOffsetMinutes == null) defaultAlarmOffsetMinutes = DEFAULT_ALARM_OFFSET_MINUTES; + if (updatedAt == null) updatedAt = Instant.now(); + } + + public void update(Boolean alarmsEnabled, Integer defaultAlarmOffsetMinutes) { + if (alarmsEnabled != null) { + this.alarmsEnabled = alarmsEnabled; + } + if (defaultAlarmOffsetMinutes != null) { + this.defaultAlarmOffsetMinutes = defaultAlarmOffsetMinutes; + } + this.updatedAt = Instant.now(); + } +} diff --git a/ontime-back/src/main/java/devkor/ontime_back/entity/UserAlarmStatus.java b/ontime-back/src/main/java/devkor/ontime_back/entity/UserAlarmStatus.java new file mode 100644 index 00000000..01e981f7 --- /dev/null +++ b/ontime-back/src/main/java/devkor/ontime_back/entity/UserAlarmStatus.java @@ -0,0 +1,118 @@ +package devkor.ontime_back.entity; + +import jakarta.persistence.*; +import lombok.AccessLevel; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; +import org.hibernate.annotations.OnDelete; +import org.hibernate.annotations.OnDeleteAction; + +import java.time.Instant; +import java.time.LocalDateTime; + +@Getter +@Entity +@Builder +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@AllArgsConstructor +@Table( + uniqueConstraints = { + @UniqueConstraint(name = "uk_user_alarm_status_device", columnNames = "user_device_id") + } +) +public class UserAlarmStatus { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long userAlarmStatusId; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "user_id", nullable = false) + @OnDelete(action = OnDeleteAction.CASCADE) + private User user; + + @OneToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "user_device_id", nullable = false) + @OnDelete(action = OnDeleteAction.CASCADE) + private UserDevice userDevice; + + @Column(nullable = false, length = 128) + private String deviceId; + + @Column(nullable = false) + private Instant reconciledAt; + + private LocalDateTime scheduleWindowStart; + private LocalDateTime scheduleWindowEnd; + private LocalDateTime alarmCoverageStart; + private LocalDateTime alarmCoverageEnd; + + @Column(nullable = false, length = 40) + private String status; + + @Column(length = 60) + private String permissionIssue; + + @Column(nullable = false, length = 40) + private String nativeAlarmProvider; + + @Column(nullable = false, length = 40) + private String fallbackProvider; + + @Column(nullable = false) + private Integer armedScheduleCount; + + @Lob + @Column(columnDefinition = "TEXT") + private String armedScheduleIds; + + @Column(nullable = false) + private Integer skippedScheduleCount; + + @Lob + @Column(columnDefinition = "TEXT") + private String failures; + + @Column(nullable = false) + private Instant updatedAt; + + public static UserAlarmStatus create(User user, UserDevice userDevice) { + return UserAlarmStatus.builder() + .user(user) + .userDevice(userDevice) + .deviceId(userDevice.getDeviceId()) + .build(); + } + + public void replace(Instant reconciledAt, + LocalDateTime scheduleWindowStart, + LocalDateTime scheduleWindowEnd, + LocalDateTime alarmCoverageStart, + LocalDateTime alarmCoverageEnd, + String status, + String permissionIssue, + String nativeAlarmProvider, + String fallbackProvider, + Integer armedScheduleCount, + String armedScheduleIds, + Integer skippedScheduleCount, + String failures) { + this.deviceId = userDevice.getDeviceId(); + this.reconciledAt = reconciledAt; + this.scheduleWindowStart = scheduleWindowStart; + this.scheduleWindowEnd = scheduleWindowEnd; + this.alarmCoverageStart = alarmCoverageStart; + this.alarmCoverageEnd = alarmCoverageEnd; + this.status = status; + this.permissionIssue = permissionIssue; + this.nativeAlarmProvider = nativeAlarmProvider; + this.fallbackProvider = fallbackProvider; + this.armedScheduleCount = armedScheduleCount; + this.armedScheduleIds = armedScheduleIds; + this.skippedScheduleCount = skippedScheduleCount; + this.failures = failures; + this.updatedAt = Instant.now(); + } +} diff --git a/ontime-back/src/main/java/devkor/ontime_back/entity/UserDevice.java b/ontime-back/src/main/java/devkor/ontime_back/entity/UserDevice.java new file mode 100644 index 00000000..af4387e8 --- /dev/null +++ b/ontime-back/src/main/java/devkor/ontime_back/entity/UserDevice.java @@ -0,0 +1,120 @@ +package devkor.ontime_back.entity; + +import jakarta.persistence.*; +import lombok.AccessLevel; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; +import org.hibernate.annotations.OnDelete; +import org.hibernate.annotations.OnDeleteAction; + +import java.time.Instant; + +@Getter +@Entity +@Builder +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@AllArgsConstructor +@Table( + uniqueConstraints = { + @UniqueConstraint(name = "uk_user_device_user_device", columnNames = {"user_id", "device_id"}) + }, + indexes = { + @Index(name = "idx_user_device_user_active", columnList = "user_id, active") + } +) +public class UserDevice { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long userDeviceId; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "user_id", nullable = false) + @OnDelete(action = OnDeleteAction.CASCADE) + private User user; + + @Column(name = "device_id", nullable = false, length = 128) + private String deviceId; + + @Column(nullable = false, length = 20) + private String platform; + + @Column(length = 128) + private String appVersion; + + @Column(length = 128) + private String osVersion; + + @Column(nullable = false) + private Boolean supportsNativeAlarm; + + @Column(nullable = false, length = 40) + private String nativeAlarmProvider; + + @Column(nullable = false, length = 40) + private String fallbackProvider; + + @Column(nullable = false) + private Boolean active; + + @Column(nullable = false) + private Instant lastSeenAt; + + @Lob + @Column(columnDefinition = "TEXT") + private String firebaseToken; + + @Lob + @Column(columnDefinition = "TEXT") + private String sessionAccessToken; + + @Lob + @Column(columnDefinition = "TEXT") + private String sessionRefreshToken; + + public static UserDevice create(User user, String deviceId) { + return UserDevice.builder() + .user(user) + .deviceId(deviceId) + .active(false) + .build(); + } + + public void activate(String platform, + String appVersion, + String osVersion, + Boolean supportsNativeAlarm, + String nativeAlarmProvider, + String fallbackProvider, + Instant lastSeenAt) { + this.platform = platform; + this.appVersion = appVersion; + this.osVersion = osVersion; + this.supportsNativeAlarm = supportsNativeAlarm; + this.nativeAlarmProvider = nativeAlarmProvider; + this.fallbackProvider = fallbackProvider; + this.active = true; + this.lastSeenAt = lastSeenAt; + } + + public void deactivate() { + this.active = false; + this.lastSeenAt = Instant.now(); + } + + public void bindSession(String accessToken, String refreshToken) { + this.sessionAccessToken = accessToken; + this.sessionRefreshToken = refreshToken; + } + + public boolean belongsToAccessToken(String accessToken) { + return sessionAccessToken != null && sessionAccessToken.equals(accessToken); + } + + public void updateFirebaseToken(String firebaseToken) { + this.firebaseToken = firebaseToken; + this.lastSeenAt = Instant.now(); + } +} diff --git a/ontime-back/src/main/java/devkor/ontime_back/global/oauth/apple/AppleLoginService.java b/ontime-back/src/main/java/devkor/ontime_back/global/oauth/apple/AppleLoginService.java index d58d67cc..15a3147b 100644 --- a/ontime-back/src/main/java/devkor/ontime_back/global/oauth/apple/AppleLoginService.java +++ b/ontime-back/src/main/java/devkor/ontime_back/global/oauth/apple/AppleLoginService.java @@ -7,9 +7,11 @@ import devkor.ontime_back.entity.Role; import devkor.ontime_back.entity.SocialType; import devkor.ontime_back.entity.User; +import devkor.ontime_back.entity.UserAlarmSetting; import devkor.ontime_back.entity.UserSetting; import devkor.ontime_back.global.jwt.JwtTokenProvider; import devkor.ontime_back.global.jwt.JwtUtils; +import devkor.ontime_back.repository.UserAlarmSettingRepository; import devkor.ontime_back.repository.UserRepository; import devkor.ontime_back.response.InvalidTokenException; import io.jsonwebtoken.Claims; @@ -62,6 +64,7 @@ public class AppleLoginService { private final ApplePublicKeyGenerator applePublicKeyGenerator; private final JwtUtils jwtUtils; private final UserRepository userRepository; + private final UserAlarmSettingRepository userAlarmSettingRepository; private final JwtTokenProvider jwtTokenProvider; private final RestTemplate restTemplate = new RestTemplate(); @@ -72,8 +75,10 @@ public Authentication handleLogin(String appleRefreshToken, User user, HttpServl String accessToken = jwtTokenProvider.createAccessToken(user.getEmail(), user.getId()); String refreshToken = jwtTokenProvider.createRefreshToken(); - jwtTokenProvider.updateRefreshToken(user.getEmail(), refreshToken); jwtTokenProvider.sendAccessAndRefreshToken(response, accessToken, refreshToken); + user.updateAccessToken(accessToken); + user.updateRefreshToken(refreshToken); + userRepository.saveAndFlush(user); Authentication authentication = new UsernamePasswordAuthenticationToken( user, null, Collections.singletonList(new SimpleGrantedAuthority(user.getRole().name())) @@ -127,8 +132,10 @@ public Authentication handleRegister(String appleRefreshToken, OAuthAppleUserDto jwtTokenProvider.sendAccessAndRefreshToken(response, accessToken, refreshToken); + savedUser.updateAccessToken(accessToken); savedUser.updateRefreshToken(refreshToken); userRepository.save(savedUser); + userAlarmSettingRepository.save(UserAlarmSetting.defaultFor(savedUser)); Authentication authentication = new UsernamePasswordAuthenticationToken( savedUser, null, Collections.singletonList(new SimpleGrantedAuthority(savedUser.getRole().name())) @@ -270,4 +277,3 @@ public boolean revokeToken(Long userId) throws Exception { } } } - diff --git a/ontime-back/src/main/java/devkor/ontime_back/global/oauth/google/GoogleLoginService.java b/ontime-back/src/main/java/devkor/ontime_back/global/oauth/google/GoogleLoginService.java index 07e7c4f2..2be184bc 100644 --- a/ontime-back/src/main/java/devkor/ontime_back/global/oauth/google/GoogleLoginService.java +++ b/ontime-back/src/main/java/devkor/ontime_back/global/oauth/google/GoogleLoginService.java @@ -8,8 +8,10 @@ import devkor.ontime_back.entity.Role; import devkor.ontime_back.entity.SocialType; import devkor.ontime_back.entity.User; +import devkor.ontime_back.entity.UserAlarmSetting; import devkor.ontime_back.entity.UserSetting; import devkor.ontime_back.global.jwt.JwtTokenProvider; +import devkor.ontime_back.repository.UserAlarmSettingRepository; import devkor.ontime_back.repository.UserRepository; import io.jsonwebtoken.Claims; import jakarta.servlet.http.HttpServletResponse; @@ -39,6 +41,7 @@ public class GoogleLoginService { private final JwtTokenProvider jwtTokenProvider; private final UserRepository userRepository; + private final UserAlarmSettingRepository userAlarmSettingRepository; private static final String GOOGLE_USER_INFO_URL = "https://www.googleapis.com/userinfo/v2/me"; private static final String GOOGLE_REVOKE_URL = "https://oauth2.googleapis.com/revoke?token="; @@ -47,11 +50,13 @@ public class GoogleLoginService { public GoogleLoginService( JwtTokenProvider jwtTokenProvider, UserRepository userRepository, + UserAlarmSettingRepository userAlarmSettingRepository, @Value("${google.web.client-id}") String webClientId, @Value("${google.app.client-id}") String appClientId ) { this.jwtTokenProvider = jwtTokenProvider; this.userRepository = userRepository; + this.userAlarmSettingRepository = userAlarmSettingRepository; this.validClientIds = List.of(webClientId, appClientId); } @@ -62,8 +67,10 @@ public Authentication handleLogin(OAuthGoogleRequestDto oAuthGoogleRequestDto, U String accessToken = jwtTokenProvider.createAccessToken(user.getEmail(), user.getId()); String refreshToken = jwtTokenProvider.createRefreshToken(); - jwtTokenProvider.updateRefreshToken(user.getEmail(), refreshToken); jwtTokenProvider.sendAccessAndRefreshToken(response, accessToken, refreshToken); + user.updateAccessToken(accessToken); + user.updateRefreshToken(refreshToken); + userRepository.saveAndFlush(user); Authentication authentication = new UsernamePasswordAuthenticationToken( user, null, Collections.singletonList(new SimpleGrantedAuthority(user.getRole().name())) @@ -116,8 +123,10 @@ public Authentication handleRegister(OAuthGoogleRequestDto oAuthGoogleRequestDto jwtTokenProvider.sendAccessAndRefreshToken(response, accessToken, refreshToken); + savedUser.updateAccessToken(accessToken); savedUser.updateRefreshToken(refreshToken); userRepository.save(savedUser); + userAlarmSettingRepository.save(UserAlarmSetting.defaultFor(savedUser)); Authentication authentication = new UsernamePasswordAuthenticationToken( savedUser, null, Collections.singletonList(new SimpleGrantedAuthority(savedUser.getRole().name())) @@ -181,4 +190,3 @@ public boolean revokeToken(Long userId) { } } - diff --git a/ontime-back/src/main/java/devkor/ontime_back/global/oauth/kakao/KakaoLoginFilter.java b/ontime-back/src/main/java/devkor/ontime_back/global/oauth/kakao/KakaoLoginFilter.java index 34d2217e..a7e4e9c5 100644 --- a/ontime-back/src/main/java/devkor/ontime_back/global/oauth/kakao/KakaoLoginFilter.java +++ b/ontime-back/src/main/java/devkor/ontime_back/global/oauth/kakao/KakaoLoginFilter.java @@ -5,7 +5,10 @@ import devkor.ontime_back.entity.Role; import devkor.ontime_back.entity.SocialType; import devkor.ontime_back.entity.User; +import devkor.ontime_back.entity.UserAlarmSetting; +import devkor.ontime_back.entity.UserSetting; import devkor.ontime_back.global.jwt.JwtTokenProvider; +import devkor.ontime_back.repository.UserAlarmSettingRepository; import devkor.ontime_back.repository.UserRepository; import jakarta.servlet.FilterChain; import jakarta.servlet.ServletException; @@ -22,17 +25,23 @@ import java.io.IOException; import java.util.Collections; import java.util.Optional; +import java.util.UUID; @Slf4j public class KakaoLoginFilter extends AbstractAuthenticationProcessingFilter { private final UserRepository userRepository; + private final UserAlarmSettingRepository userAlarmSettingRepository; private final JwtTokenProvider jwtTokenProvider; - public KakaoLoginFilter(String defaultFilterProcessesUrl, JwtTokenProvider jwtTokenProvider, UserRepository userRepository) { + public KakaoLoginFilter(String defaultFilterProcessesUrl, + JwtTokenProvider jwtTokenProvider, + UserRepository userRepository, + UserAlarmSettingRepository userAlarmSettingRepository) { super(defaultFilterProcessesUrl); this.jwtTokenProvider = jwtTokenProvider; this.userRepository = userRepository; + this.userAlarmSettingRepository = userAlarmSettingRepository; } @@ -55,8 +64,10 @@ private Authentication handleLogin(User user, HttpServletResponse response) thro String accessToken = jwtTokenProvider.createAccessToken(user.getEmail(), user.getId()); String refreshToken = jwtTokenProvider.createRefreshToken(); - jwtTokenProvider.updateRefreshToken(user.getEmail(), refreshToken); - jwtTokenProvider.sendAccessToken(response, accessToken); + jwtTokenProvider.sendAccessAndRefreshToken(response, accessToken, refreshToken); + user.updateAccessToken(accessToken); + user.updateRefreshToken(refreshToken); + userRepository.saveAndFlush(user); response.setContentType("application/json"); response.setCharacterEncoding("UTF-8"); @@ -86,10 +97,21 @@ private Authentication handleRegister(OAuthKakaoUserDto oAuthKakaoUserDto, HttpS .role(Role.GUEST) .build(); + UserSetting userSetting = UserSetting.builder() + .userSettingId(UUID.randomUUID()) + .user(newUser) + .build(); + newUser.setUserSetting(userSetting); + User savedUser = userRepository.save(newUser); String accessToken = jwtTokenProvider.createAccessToken(newUser.getEmail(), newUser.getId()); - jwtTokenProvider.sendAccessToken(response, accessToken); + String refreshToken = jwtTokenProvider.createRefreshToken(); + jwtTokenProvider.sendAccessAndRefreshToken(response, accessToken, refreshToken); + savedUser.updateAccessToken(accessToken); + savedUser.updateRefreshToken(refreshToken); + userRepository.save(savedUser); + userAlarmSettingRepository.save(UserAlarmSetting.defaultFor(savedUser)); response.setContentType("application/json"); response.setCharacterEncoding("UTF-8"); diff --git a/ontime-back/src/main/java/devkor/ontime_back/repository/ScheduleRepository.java b/ontime-back/src/main/java/devkor/ontime_back/repository/ScheduleRepository.java index 9af5e638..f3ac0245 100644 --- a/ontime-back/src/main/java/devkor/ontime_back/repository/ScheduleRepository.java +++ b/ontime-back/src/main/java/devkor/ontime_back/repository/ScheduleRepository.java @@ -1,6 +1,7 @@ package devkor.ontime_back.repository; +import devkor.ontime_back.entity.DoneStatus; import devkor.ontime_back.entity.Schedule; import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.data.jpa.repository.Modifying; @@ -51,4 +52,17 @@ public interface ScheduleRepository extends JpaRepository { @Query("SELECT s FROM Schedule s WHERE s.user.id = :userId") List findAllByUserId(@Param("userId") Long userId); + @Query("SELECT s FROM Schedule s " + + "JOIN FETCH s.user " + + "LEFT JOIN FETCH s.place " + + "WHERE s.user.id = :userId " + + "AND s.doneStatus = :doneStatus " + + "AND s.scheduleTime >= :startDate " + + "AND s.scheduleTime < :endDate " + + "ORDER BY s.scheduleTime ASC, s.scheduleId ASC") + List findAlarmWindowSchedules(@Param("userId") Long userId, + @Param("startDate") LocalDateTime startDate, + @Param("endDate") LocalDateTime endDate, + @Param("doneStatus") DoneStatus doneStatus); + } diff --git a/ontime-back/src/main/java/devkor/ontime_back/repository/UserAlarmSettingRepository.java b/ontime-back/src/main/java/devkor/ontime_back/repository/UserAlarmSettingRepository.java new file mode 100644 index 00000000..8d533eb4 --- /dev/null +++ b/ontime-back/src/main/java/devkor/ontime_back/repository/UserAlarmSettingRepository.java @@ -0,0 +1,12 @@ +package devkor.ontime_back.repository; + +import devkor.ontime_back.entity.UserAlarmSetting; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +import java.util.Optional; + +@Repository +public interface UserAlarmSettingRepository extends JpaRepository { + Optional findByUserId(Long userId); +} diff --git a/ontime-back/src/main/java/devkor/ontime_back/repository/UserAlarmStatusRepository.java b/ontime-back/src/main/java/devkor/ontime_back/repository/UserAlarmStatusRepository.java new file mode 100644 index 00000000..ffcbcee8 --- /dev/null +++ b/ontime-back/src/main/java/devkor/ontime_back/repository/UserAlarmStatusRepository.java @@ -0,0 +1,12 @@ +package devkor.ontime_back.repository; + +import devkor.ontime_back.entity.UserAlarmStatus; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +import java.util.Optional; + +@Repository +public interface UserAlarmStatusRepository extends JpaRepository { + Optional findByUserDeviceUserDeviceId(Long userDeviceId); +} diff --git a/ontime-back/src/main/java/devkor/ontime_back/repository/UserDeviceRepository.java b/ontime-back/src/main/java/devkor/ontime_back/repository/UserDeviceRepository.java new file mode 100644 index 00000000..e080b104 --- /dev/null +++ b/ontime-back/src/main/java/devkor/ontime_back/repository/UserDeviceRepository.java @@ -0,0 +1,19 @@ +package devkor.ontime_back.repository; + +import devkor.ontime_back.entity.UserDevice; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +import java.util.List; +import java.util.Optional; + +@Repository +public interface UserDeviceRepository extends JpaRepository { + Optional findByUserIdAndDeviceId(Long userId, String deviceId); + + Optional findByUserIdAndDeviceIdAndActiveTrue(Long userId, String deviceId); + + Optional findFirstByUserIdAndActiveTrueOrderByLastSeenAtDesc(Long userId); + + List findAllByUserIdAndActiveTrue(Long userId); +} diff --git a/ontime-back/src/main/java/devkor/ontime_back/response/ErrorCode.java b/ontime-back/src/main/java/devkor/ontime_back/response/ErrorCode.java index da7c71eb..3117cab2 100644 --- a/ontime-back/src/main/java/devkor/ontime_back/response/ErrorCode.java +++ b/ontime-back/src/main/java/devkor/ontime_back/response/ErrorCode.java @@ -30,6 +30,9 @@ public enum ErrorCode { FIRST_PREPARATION_NOT_FOUND(1012, "해당 ID의 사용자의 준비과정을 찾을 수 없습니다.", HttpStatus.BAD_REQUEST), NOTIFICATION_NOT_FOUND(1013, "알림을 찾을 수 없습니다.", HttpStatus.BAD_REQUEST ), PREPARATION_ALREADY_EXISTS(1014, "해당 사용자의 준비과정이 이미 존재합니다.", HttpStatus.BAD_REQUEST), + ALARM_SETTINGS_INVALID_FIELD(1101, "ALARM_SETTINGS_INVALID_FIELD", HttpStatus.BAD_REQUEST), + ALARM_WINDOW_RANGE_TOO_LONG(1102, "ALARM_WINDOW_RANGE_TOO_LONG", HttpStatus.BAD_REQUEST), + DEVICE_SESSION_NOT_ACTIVE(1103, "DEVICE_SESSION_NOT_ACTIVE", HttpStatus.CONFLICT), // 공통 오류 메시지 UNEXPECTED_ERROR(1000, "Unexpected Error: An unexpected error occurred.", HttpStatus.INTERNAL_SERVER_ERROR),; @@ -58,4 +61,4 @@ public HttpStatus getHttpStatus() { return httpStatus; } -} \ No newline at end of file +} diff --git a/ontime-back/src/main/java/devkor/ontime_back/service/AlarmService.java b/ontime-back/src/main/java/devkor/ontime_back/service/AlarmService.java new file mode 100644 index 00000000..3fe02b34 --- /dev/null +++ b/ontime-back/src/main/java/devkor/ontime_back/service/AlarmService.java @@ -0,0 +1,508 @@ +package devkor.ontime_back.service; + +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.core.type.TypeReference; +import com.fasterxml.jackson.databind.ObjectMapper; +import devkor.ontime_back.dto.*; +import devkor.ontime_back.entity.User; +import devkor.ontime_back.entity.UserAlarmSetting; +import devkor.ontime_back.entity.UserAlarmStatus; +import devkor.ontime_back.entity.UserDevice; +import devkor.ontime_back.repository.UserAlarmSettingRepository; +import devkor.ontime_back.repository.UserAlarmStatusRepository; +import devkor.ontime_back.repository.UserDeviceRepository; +import devkor.ontime_back.repository.UserRepository; +import devkor.ontime_back.response.GeneralException; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.math.BigInteger; +import java.time.Duration; +import java.time.Instant; +import java.time.LocalDateTime; +import java.util.*; +import java.util.regex.Pattern; + +import static devkor.ontime_back.response.ErrorCode.*; + +@Service +@RequiredArgsConstructor +@Transactional(readOnly = true) +public class AlarmService { + + private static final int MAX_DEFAULT_ALARM_OFFSET_MINUTES = 1440; + private static final Pattern DEVICE_ID_PATTERN = Pattern.compile("^[A-Za-z0-9._:-]{16,128}$"); + private static final Set ALARM_SETTING_FIELDS = Set.of("alarmsEnabled", "defaultAlarmOffsetMinutes"); + private static final Set PLATFORMS = Set.of("android", "ios"); + private static final Set NATIVE_PROVIDERS = Set.of("androidAlarmManager", "iosAlarmKit", "none"); + private static final Set FALLBACK_PROVIDERS = Set.of("localNotification", "none"); + private static final Set STATUSES = Set.of("armed", "partial", "disabled", "permissionNeeded", "unsupported", "settingsUnavailable"); + private static final Set PERMISSION_ISSUES = Set.of("nativePermissionDenied", "notificationPermissionDenied"); + private static final Set FAILURE_REASONS = Set.of("preparationLoadFailed", "scheduleInvalid", "platformError", "unknown"); + private static final String NATIVE_NONE = "none"; + private static final String FALLBACK_LOCAL_NOTIFICATION = "localNotification"; + + private final UserRepository userRepository; + private final UserAlarmSettingRepository userAlarmSettingRepository; + private final UserDeviceRepository userDeviceRepository; + private final UserAlarmStatusRepository userAlarmStatusRepository; + private final ObjectMapper objectMapper; + + @Transactional + public AlarmSettingsResponseDto getAlarmSettings(Long userId) { + UserAlarmSetting setting = getOrCreateAlarmSetting(userId); + return toAlarmSettingsResponse(setting); + } + + @Transactional + public AlarmSettingsResponseDto patchAlarmSettings(Long userId, Map requestBody) { + if (requestBody == null || requestBody.isEmpty()) { + throw new GeneralException(INVALID_INPUT); + } + + for (String field : requestBody.keySet()) { + if (!ALARM_SETTING_FIELDS.contains(field)) { + throw new GeneralException(ALARM_SETTINGS_INVALID_FIELD); + } + } + + Boolean alarmsEnabled = null; + Integer defaultAlarmOffsetMinutes = null; + + if (requestBody.containsKey("alarmsEnabled")) { + Object value = requestBody.get("alarmsEnabled"); + if (!(value instanceof Boolean)) { + throw new GeneralException(INVALID_INPUT); + } + alarmsEnabled = (Boolean) value; + } + + if (requestBody.containsKey("defaultAlarmOffsetMinutes")) { + Object value = requestBody.get("defaultAlarmOffsetMinutes"); + defaultAlarmOffsetMinutes = parseInteger(value); + if (defaultAlarmOffsetMinutes < 0 || defaultAlarmOffsetMinutes > MAX_DEFAULT_ALARM_OFFSET_MINUTES) { + throw new GeneralException(INVALID_INPUT); + } + } + + UserAlarmSetting setting = getOrCreateAlarmSetting(userId); + setting.update(alarmsEnabled, defaultAlarmOffsetMinutes); + return toAlarmSettingsResponse(setting); + } + + @Transactional + public AlarmDeviceCurrentResponseDto registerCurrentDevice(Long userId, + AlarmDeviceCurrentRequestDto requestDto, + String accessToken, + String refreshToken) { + validateDeviceRegistration(requestDto); + validateSessionAccessToken(accessToken); + + User user = userRepository.findById(userId) + .orElseThrow(() -> new GeneralException(USER_NOT_FOUND)); + Instant now = Instant.now(); + + List activeDevices = userDeviceRepository.findAllByUserIdAndActiveTrue(userId); + for (UserDevice activeDevice : activeDevices) { + activeDevice.deactivate(); + } + + UserDevice device = userDeviceRepository.findByUserIdAndDeviceId(userId, requestDto.getDeviceId()) + .orElseGet(() -> UserDevice.create(user, requestDto.getDeviceId())); + device.activate( + requestDto.getPlatform(), + requestDto.getAppVersion(), + requestDto.getOsVersion(), + Boolean.TRUE.equals(requestDto.getSupportsNativeAlarm()), + requestDto.getNativeAlarmProvider(), + requestDto.getFallbackProvider(), + now + ); + device.bindSession(accessToken, refreshToken); + + UserDevice savedDevice = userDeviceRepository.save(device); + return AlarmDeviceCurrentResponseDto.builder() + .deviceId(savedDevice.getDeviceId()) + .active(savedDevice.getActive()) + .lastSeenAt(savedDevice.getLastSeenAt()) + .build(); + } + + @Transactional + public AlarmDeviceUnregisterResponseDto unregisterCurrentDevice(Long userId, + AlarmDeviceUnregisterRequestDto requestDto, + String accessToken) { + validateSessionAccessToken(accessToken); + if (requestDto != null && requestDto.getDeviceId() != null && !requestDto.getDeviceId().isBlank()) { + validateDeviceId(requestDto.getDeviceId()); + UserDevice currentDevice = userDeviceRepository.findByUserIdAndDeviceIdAndActiveTrue(userId, requestDto.getDeviceId()) + .orElseThrow(() -> new GeneralException(DEVICE_SESSION_NOT_ACTIVE)); + ensureDeviceSessionActive(currentDevice, accessToken); + currentDevice.deactivate(); + } else { + List activeDevices = userDeviceRepository.findAllByUserIdAndActiveTrue(userId); + List currentSessionDevices = activeDevices.stream() + .filter(device -> device.belongsToAccessToken(accessToken)) + .toList(); + if (currentSessionDevices.isEmpty()) { + activeDevices.forEach(UserDevice::deactivate); + } else { + currentSessionDevices.forEach(UserDevice::deactivate); + } + } + + return AlarmDeviceUnregisterResponseDto.builder() + .active(false) + .build(); + } + + @Transactional + public AlarmStatusReportResponseDto reportAlarmStatus(Long userId, + AlarmStatusReportRequestDto requestDto, + String accessToken) { + validateAlarmStatusReport(requestDto); + validateSessionAccessToken(accessToken); + + UserDevice currentDevice = userDeviceRepository.findByUserIdAndDeviceIdAndActiveTrue(userId, requestDto.getDeviceId()) + .orElseThrow(() -> new GeneralException(DEVICE_SESSION_NOT_ACTIVE)); + ensureDeviceSessionActive(currentDevice, accessToken); + validateProviderCompatibility( + currentDevice.getPlatform(), + requestDto.getNativeAlarmProvider(), + Boolean.TRUE.equals(currentDevice.getSupportsNativeAlarm()) + ); + + User user = userRepository.findById(userId) + .orElseThrow(() -> new GeneralException(USER_NOT_FOUND)); + + UserAlarmStatus alarmStatus = userAlarmStatusRepository.findByUserDeviceUserDeviceId(currentDevice.getUserDeviceId()) + .orElseGet(() -> UserAlarmStatus.create(user, currentDevice)); + + alarmStatus.replace( + requestDto.getReconciledAt().toInstant(), + requestDto.getScheduleWindowStart(), + requestDto.getScheduleWindowEnd(), + requestDto.getAlarmCoverageStart(), + requestDto.getAlarmCoverageEnd(), + requestDto.getStatus(), + requestDto.getPermissionIssue(), + requestDto.getNativeAlarmProvider(), + requestDto.getFallbackProvider(), + defaultNonNegative(requestDto.getArmedScheduleCount()), + toJson(defaultList(requestDto.getArmedScheduleIds())), + defaultNonNegative(requestDto.getSkippedScheduleCount()), + toJson(defaultList(requestDto.getFailures())) + ); + + userAlarmStatusRepository.save(alarmStatus); + return AlarmStatusReportResponseDto.builder() + .received(true) + .build(); + } + + public AlarmStatusCurrentResponseDto getCurrentAlarmStatus(Long userId, String accessToken) { + validateSessionAccessToken(accessToken); + + Optional currentDevice = userDeviceRepository.findAllByUserIdAndActiveTrue(userId).stream() + .filter(device -> device.belongsToAccessToken(accessToken)) + .findFirst(); + if (currentDevice.isEmpty()) { + return AlarmStatusCurrentResponseDto.builder() + .active(false) + .build(); + } + + UserDevice device = currentDevice.get(); + Optional latestStatus = userAlarmStatusRepository.findByUserDeviceUserDeviceId(device.getUserDeviceId()); + + AlarmStatusCurrentResponseDto.AlarmStatusCurrentResponseDtoBuilder builder = AlarmStatusCurrentResponseDto.builder() + .deviceId(device.getDeviceId()) + .active(device.getActive()) + .platform(device.getPlatform()) + .appVersion(device.getAppVersion()) + .osVersion(device.getOsVersion()) + .supportsNativeAlarm(device.getSupportsNativeAlarm()) + .nativeAlarmProvider(device.getNativeAlarmProvider()) + .fallbackProvider(device.getFallbackProvider()) + .lastSeenAt(device.getLastSeenAt()); + + latestStatus.ifPresent(status -> builder + .reconciledAt(status.getReconciledAt()) + .scheduleWindowStart(status.getScheduleWindowStart()) + .scheduleWindowEnd(status.getScheduleWindowEnd()) + .alarmCoverageStart(status.getAlarmCoverageStart()) + .alarmCoverageEnd(status.getAlarmCoverageEnd()) + .status(status.getStatus()) + .permissionIssue(status.getPermissionIssue()) + .armedScheduleCount(status.getArmedScheduleCount()) + .armedScheduleIds(parseStringList(status.getArmedScheduleIds())) + .skippedScheduleCount(status.getSkippedScheduleCount()) + .failures(parseFailureList(status.getFailures())) + .updatedAt(status.getUpdatedAt())); + + return builder.build(); + } + + @Transactional + public void linkFirebaseToken(Long userId, String deviceId, String firebaseToken, String accessToken) { + validateDeviceId(deviceId); + validateSessionAccessToken(accessToken); + UserDevice currentDevice = userDeviceRepository.findByUserIdAndDeviceIdAndActiveTrue(userId, deviceId) + .orElseThrow(() -> new GeneralException(DEVICE_SESSION_NOT_ACTIVE)); + ensureDeviceSessionActive(currentDevice, accessToken); + currentDevice.updateFirebaseToken(firebaseToken); + } + + public Integer getDefaultAlarmOffsetMinutes(Long userId) { + return userAlarmSettingRepository.findByUserId(userId) + .map(UserAlarmSetting::getDefaultAlarmOffsetMinutes) + .orElse(UserAlarmSetting.DEFAULT_ALARM_OFFSET_MINUTES); + } + + public boolean shouldSuppressLegacyReminder(Long userId, UUID scheduleId, LocalDateTime reminderTime) { + boolean alarmsEnabled = userAlarmSettingRepository.findByUserId(userId) + .map(UserAlarmSetting::getAlarmsEnabled) + .orElse(true); + if (!alarmsEnabled) { + return false; + } + + Optional currentDevice = userDeviceRepository.findFirstByUserIdAndActiveTrueOrderByLastSeenAtDesc(userId); + if (currentDevice.isEmpty()) { + return false; + } + String currentAccessToken = userRepository.findById(userId) + .map(User::getAccessToken) + .orElse(null); + if (!currentDevice.get().belongsToAccessToken(currentAccessToken)) { + return false; + } + + Optional latestStatus = userAlarmStatusRepository.findByUserDeviceUserDeviceId(currentDevice.get().getUserDeviceId()); + if (latestStatus.isEmpty()) { + return false; + } + + UserAlarmStatus status = latestStatus.get(); + if (status.getReconciledAt() == null || status.getReconciledAt().isBefore(Instant.now().minus(Duration.ofHours(24)))) { + return false; + } + + if (!hasProviderCoverage(status)) { + return false; + } + + if (!isSuppressibleStatus(status)) { + return false; + } + + if (reminderTime == null || status.getAlarmCoverageStart() == null || status.getAlarmCoverageEnd() == null) { + return false; + } + + if (reminderTime.isBefore(status.getAlarmCoverageStart()) || !reminderTime.isBefore(status.getAlarmCoverageEnd())) { + return false; + } + + return parseStringList(status.getArmedScheduleIds()).contains(scheduleId.toString()); + } + + private UserAlarmSetting getOrCreateAlarmSetting(Long userId) { + return userAlarmSettingRepository.findByUserId(userId) + .orElseGet(() -> { + User user = userRepository.findById(userId) + .orElseThrow(() -> new GeneralException(USER_NOT_FOUND)); + return userAlarmSettingRepository.save(UserAlarmSetting.defaultFor(user)); + }); + } + + private AlarmSettingsResponseDto toAlarmSettingsResponse(UserAlarmSetting setting) { + return AlarmSettingsResponseDto.builder() + .alarmsEnabled(setting.getAlarmsEnabled()) + .defaultAlarmOffsetMinutes(setting.getDefaultAlarmOffsetMinutes()) + .updatedAt(setting.getUpdatedAt()) + .build(); + } + + private void validateDeviceRegistration(AlarmDeviceCurrentRequestDto requestDto) { + if (requestDto == null) { + throw new GeneralException(INVALID_INPUT); + } + validateDeviceId(requestDto.getDeviceId()); + validatePlatformAndProviders( + requestDto.getPlatform(), + requestDto.getNativeAlarmProvider(), + requestDto.getFallbackProvider(), + Boolean.TRUE.equals(requestDto.getSupportsNativeAlarm()) + ); + validateOptionalBoundedString(requestDto.getAppVersion()); + validateOptionalBoundedString(requestDto.getOsVersion()); + } + + private void validateAlarmStatusReport(AlarmStatusReportRequestDto requestDto) { + if (requestDto == null) { + throw new GeneralException(INVALID_INPUT); + } + validateDeviceId(requestDto.getDeviceId()); + + if (requestDto.getReconciledAt() == null + || requestDto.getScheduleWindowStart() == null + || requestDto.getScheduleWindowEnd() == null + || requestDto.getAlarmCoverageStart() == null + || requestDto.getAlarmCoverageEnd() == null + || requestDto.getStatus() == null + || !STATUSES.contains(requestDto.getStatus()) + || requestDto.getNativeAlarmProvider() == null + || !NATIVE_PROVIDERS.contains(requestDto.getNativeAlarmProvider()) + || requestDto.getFallbackProvider() == null + || !FALLBACK_PROVIDERS.contains(requestDto.getFallbackProvider())) { + throw new GeneralException(INVALID_INPUT); + } + + if (requestDto.getPermissionIssue() != null && !PERMISSION_ISSUES.contains(requestDto.getPermissionIssue())) { + throw new GeneralException(INVALID_INPUT); + } + + if ("unsupported".equals(requestDto.getStatus()) + && (!NATIVE_NONE.equals(requestDto.getNativeAlarmProvider()) + || FALLBACK_LOCAL_NOTIFICATION.equals(requestDto.getFallbackProvider()))) { + throw new GeneralException(INVALID_INPUT); + } + + if (requestDto.getScheduleWindowEnd().isBefore(requestDto.getScheduleWindowStart()) + || requestDto.getAlarmCoverageEnd().isBefore(requestDto.getAlarmCoverageStart())) { + throw new GeneralException(INVALID_INPUT); + } + + if (requestDto.getArmedScheduleCount() != null && requestDto.getArmedScheduleCount() < 0) { + throw new GeneralException(INVALID_INPUT); + } + if (requestDto.getSkippedScheduleCount() != null && requestDto.getSkippedScheduleCount() < 0) { + throw new GeneralException(INVALID_INPUT); + } + + for (AlarmStatusFailureDto failure : defaultList(requestDto.getFailures())) { + if (failure == null || failure.getReason() == null || !FAILURE_REASONS.contains(failure.getReason())) { + throw new GeneralException(INVALID_INPUT); + } + } + } + + private void validateDeviceId(String deviceId) { + if (deviceId == null || !DEVICE_ID_PATTERN.matcher(deviceId).matches()) { + throw new GeneralException(INVALID_INPUT); + } + } + + private void validateSessionAccessToken(String accessToken) { + if (accessToken == null || accessToken.isBlank()) { + throw new GeneralException(INVALID_INPUT); + } + } + + private void ensureDeviceSessionActive(UserDevice device, String accessToken) { + if (!device.belongsToAccessToken(accessToken)) { + throw new GeneralException(DEVICE_SESSION_NOT_ACTIVE); + } + } + + private void validatePlatformAndProviders(String platform, String nativeAlarmProvider, String fallbackProvider, boolean supportsNativeAlarm) { + if (platform == null || !PLATFORMS.contains(platform) + || nativeAlarmProvider == null || !NATIVE_PROVIDERS.contains(nativeAlarmProvider) + || fallbackProvider == null || !FALLBACK_PROVIDERS.contains(fallbackProvider)) { + throw new GeneralException(INVALID_INPUT); + } + + validateProviderCompatibility(platform, nativeAlarmProvider, supportsNativeAlarm); + } + + private void validateProviderCompatibility(String platform, String nativeAlarmProvider, boolean supportsNativeAlarm) { + if ("ios".equals(platform) && "androidAlarmManager".equals(nativeAlarmProvider)) { + throw new GeneralException(INVALID_INPUT); + } + if ("android".equals(platform) && "iosAlarmKit".equals(nativeAlarmProvider)) { + throw new GeneralException(INVALID_INPUT); + } + if (!supportsNativeAlarm && !NATIVE_NONE.equals(nativeAlarmProvider)) { + throw new GeneralException(INVALID_INPUT); + } + } + + private void validateOptionalBoundedString(String value) { + if (value != null && value.length() > 128) { + throw new GeneralException(INVALID_INPUT); + } + } + + private int defaultNonNegative(Integer value) { + return value == null ? 0 : value; + } + + private Integer parseInteger(Object value) { + if (value instanceof Integer integerValue) { + return integerValue; + } + if (value instanceof Long longValue && longValue <= Integer.MAX_VALUE && longValue >= Integer.MIN_VALUE) { + return longValue.intValue(); + } + if (value instanceof Short shortValue) { + return shortValue.intValue(); + } + if (value instanceof Byte byteValue) { + return byteValue.intValue(); + } + if (value instanceof BigInteger bigIntegerValue + && bigIntegerValue.compareTo(BigInteger.valueOf(Integer.MAX_VALUE)) <= 0 + && bigIntegerValue.compareTo(BigInteger.valueOf(Integer.MIN_VALUE)) >= 0) { + return bigIntegerValue.intValue(); + } + throw new GeneralException(INVALID_INPUT); + } + + private List defaultList(List value) { + return value == null ? List.of() : value; + } + + private String toJson(Object value) { + try { + return objectMapper.writeValueAsString(value); + } catch (JsonProcessingException e) { + throw new GeneralException(INVALID_INPUT); + } + } + + private boolean hasProviderCoverage(UserAlarmStatus status) { + return !NATIVE_NONE.equals(status.getNativeAlarmProvider()) + || FALLBACK_LOCAL_NOTIFICATION.equals(status.getFallbackProvider()); + } + + private boolean isSuppressibleStatus(UserAlarmStatus status) { + if ("armed".equals(status.getStatus())) { + return true; + } + return "partial".equals(status.getStatus()) && status.getArmedScheduleCount() != null && status.getArmedScheduleCount() > 0; + } + + private List parseStringList(String json) { + if (json == null || json.isBlank()) { + return List.of(); + } + try { + return objectMapper.readValue(json, new TypeReference<>() {}); + } catch (JsonProcessingException e) { + return List.of(); + } + } + + private List parseFailureList(String json) { + if (json == null || json.isBlank()) { + return List.of(); + } + try { + return objectMapper.readValue(json, new TypeReference<>() {}); + } catch (JsonProcessingException e) { + return List.of(); + } + } +} diff --git a/ontime-back/src/main/java/devkor/ontime_back/service/FirebaseTokenService.java b/ontime-back/src/main/java/devkor/ontime_back/service/FirebaseTokenService.java index 9de09220..51b36ea5 100644 --- a/ontime-back/src/main/java/devkor/ontime_back/service/FirebaseTokenService.java +++ b/ontime-back/src/main/java/devkor/ontime_back/service/FirebaseTokenService.java @@ -16,13 +16,17 @@ @Transactional(readOnly = true) public class FirebaseTokenService { private final UserRepository userRepository; + private final AlarmService alarmService; @Transactional - public void registerFirebaseToken(Long userId, FirebaseTokenAddDto firebaseTokenAddDto) { + public void registerFirebaseToken(Long userId, FirebaseTokenAddDto firebaseTokenAddDto, String accessToken) { User user = userRepository.findById(userId) .orElseThrow(() -> new IllegalArgumentException("User not found")); user.updateFirebaseToken(firebaseTokenAddDto.getFirebaseToken()); + if (firebaseTokenAddDto.getDeviceId() != null && !firebaseTokenAddDto.getDeviceId().isBlank()) { + alarmService.linkFirebaseToken(userId, firebaseTokenAddDto.getDeviceId(), firebaseTokenAddDto.getFirebaseToken(), accessToken); + } userRepository.save(user); } diff --git a/ontime-back/src/main/java/devkor/ontime_back/service/FriendshipService.java b/ontime-back/src/main/java/devkor/ontime_back/service/FriendshipService.java index 0f3edfeb..4a776395 100644 --- a/ontime-back/src/main/java/devkor/ontime_back/service/FriendshipService.java +++ b/ontime-back/src/main/java/devkor/ontime_back/service/FriendshipService.java @@ -48,6 +48,10 @@ public User getFriendShipRequester(Long receiverId, UUID friendshipId) { FriendShip friendShip = friendshipRepository.findByFriendShipId(friendshipId) .orElseThrow(() -> new IllegalArgumentException("존재하지 않는 친구 요청입니다. 친구 추가를 신청한 유저가 탈퇴했을 수 있습니다.")); + if (userRepository.findById(friendShip.getRequesterId()).isEmpty()) { + friendshipRepository.delete(friendShip); + throw new IllegalArgumentException("존재하지 않는 친구 요청입니다. 친구 추가를 신청한 유저가 탈퇴했을 수 있습니다."); + } // UUID로 조회한 FriendShip 데이터에 수신자 ID 세팅 friendShip.updateReceiverId(receiverId); diff --git a/ontime-back/src/main/java/devkor/ontime_back/service/NotificationService.java b/ontime-back/src/main/java/devkor/ontime_back/service/NotificationService.java index de2c9474..442ae98c 100644 --- a/ontime-back/src/main/java/devkor/ontime_back/service/NotificationService.java +++ b/ontime-back/src/main/java/devkor/ontime_back/service/NotificationService.java @@ -30,6 +30,7 @@ public class NotificationService { private final UserSettingRepository userSettingRepository; + private final AlarmService alarmService; private final TaskScheduler taskScheduler; private final NotificationScheduleRepository notificationScheduleRepository; private final ConcurrentHashMap> scheduledTasks = new ConcurrentHashMap<>(); @@ -72,6 +73,14 @@ public void sendReminder(NotificationSchedule notificationSchedule, String messa log.debug("사용자 알림 전송 설정 여부: " + userSetting.getIsNotificationsEnabled()); if (Boolean.TRUE.equals(userSetting.getIsNotificationsEnabled())) { + if (alarmService.shouldSuppressLegacyReminder( + userId, + notificationSchedule.getSchedule().getScheduleId(), + notificationSchedule.getNotificationTime())) { + log.info("현재 기기 로컬 알람 커버리지로 인해 레거시 푸시 알림을 생략합니다. scheduleId={}", + notificationSchedule.getSchedule().getScheduleId()); + return; + } sendNotificationToUser(notificationSchedule.getSchedule(), message); notificationSchedule.changeStatusToSent(); notificationScheduleRepository.save(notificationSchedule); @@ -113,4 +122,4 @@ public void sendNotificationToUser(Schedule schedule, String message) { e.printStackTrace(); } } -} \ No newline at end of file +} diff --git a/ontime-back/src/main/java/devkor/ontime_back/service/ScheduleService.java b/ontime-back/src/main/java/devkor/ontime_back/service/ScheduleService.java index 0e546878..172a9976 100644 --- a/ontime-back/src/main/java/devkor/ontime_back/service/ScheduleService.java +++ b/ontime-back/src/main/java/devkor/ontime_back/service/ScheduleService.java @@ -10,6 +10,7 @@ import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; +import java.time.Duration; import java.time.LocalDateTime; import java.util.List; import java.util.Optional; @@ -26,6 +27,7 @@ public class ScheduleService { private final UserService userService; private final NotificationService notificationService; + private final AlarmService alarmService; private final ScheduleRepository scheduleRepository; private final UserRepository userRepository; @@ -108,7 +110,7 @@ public void modifySchedule(Long userId, UUID scheduleId, ScheduleModDto schedule } public void updateAndRescheduleNotification(LocalDateTime newNotificationTime, NotificationSchedule notification) { - if(newNotificationTime == notification.getNotificationTime()) return; + if(newNotificationTime.equals(notification.getNotificationTime())) return; notificationService.cancelScheduledNotification(notification.getId()); notification.updateNotificationTime(newNotificationTime); @@ -143,14 +145,18 @@ public void addSchedule(ScheduleAddDto scheduleAddDto, Long userId) { public LocalDateTime getNotificationTime(Schedule schedule, User user) { Integer preparationTime = calculatePreparationTime(schedule, user); - Integer moveTime = schedule.getMoveTime(); - Integer spareTime = schedule.getScheduleSpareTime() == null ? user.getSpareTime() : schedule.getScheduleSpareTime(); - return schedule.getScheduleTime().minusMinutes(preparationTime + moveTime + spareTime); + Integer moveTime = defaultNonNegative(schedule.getMoveTime()); + Integer spareTime = getEffectiveSpareTime(schedule); + Integer defaultAlarmOffsetMinutes = alarmService.getDefaultAlarmOffsetMinutes(user.getId()); + return schedule.getScheduleTime().minusMinutes(preparationTime + moveTime + spareTime + defaultAlarmOffsetMinutes); } private Integer calculatePreparationTime(Schedule schedule, User user) { List preparationDtos = getPreparations(user.getId(), schedule.getScheduleId()); - return preparationDtos.stream().map(PreparationDto::getPreparationTime).reduce(0, Integer::sum); + return preparationDtos.stream() + .map(PreparationDto::getPreparationTime) + .map(this::defaultNonNegative) + .reduce(0, Integer::sum); } // 지각 히스토리 반환 @@ -193,7 +199,7 @@ public List getPreparations(Long userId, UUID scheduleId) { .map(preparationSchedule -> new PreparationDto( preparationSchedule.getPreparationScheduleId(), preparationSchedule.getPreparationName(), - preparationSchedule.getPreparationTime(), + defaultNonNegative(preparationSchedule.getPreparationTime()), preparationSchedule.getNextPreparation() != null ? preparationSchedule.getNextPreparation().getPreparationScheduleId() : null @@ -204,7 +210,7 @@ public List getPreparations(Long userId, UUID scheduleId) { .map(preparationUser -> new PreparationDto( preparationUser.getPreparationUserId(), preparationUser.getPreparationName(), - preparationUser.getPreparationTime(), + defaultNonNegative(preparationUser.getPreparationTime()), preparationUser.getNextPreparation() != null ? preparationUser.getNextPreparation().getPreparationUserId() : null @@ -213,6 +219,25 @@ public List getPreparations(Long userId, UUID scheduleId) { } } + public List getAlarmWindowSchedules(Long userId, LocalDateTime startDate, LocalDateTime endDate) { + if (startDate == null || endDate == null || endDate.isBefore(startDate)) { + throw new GeneralException(INVALID_INPUT); + } + if (Duration.between(startDate, endDate).compareTo(Duration.ofDays(14)) > 0) { + throw new GeneralException(ALARM_WINDOW_RANGE_TOO_LONG); + } + + List schedules = scheduleRepository.findAlarmWindowSchedules(userId, startDate, endDate, DoneStatus.NOT_ENDED); + List userPreparations = preparationUserRepository.findByUserIdWithNextPreparation(userId).stream() + .map(this::mapPreparationUserToDto) + .collect(Collectors.toList()); + Integer defaultAlarmOffsetMinutes = alarmService.getDefaultAlarmOffsetMinutes(userId); + + return schedules.stream() + .map(schedule -> mapToAlarmWindowDto(schedule, userPreparations, defaultAlarmOffsetMinutes)) + .collect(Collectors.toList()); + } + private ScheduleDto mapToDto(Schedule schedule) { return new ScheduleDto( schedule.getScheduleId(), @@ -227,4 +252,70 @@ private ScheduleDto mapToDto(Schedule schedule) { ); } + private AlarmWindowScheduleDto mapToAlarmWindowDto(Schedule schedule, List userPreparations, Integer defaultAlarmOffsetMinutes) { + List preparations = Boolean.TRUE.equals(schedule.getIsChange()) + ? preparationScheduleRepository.findByScheduleWithNextPreparation(schedule).stream() + .map(this::mapPreparationScheduleToDto) + .collect(Collectors.toList()) + : userPreparations; + + int totalPreparationTime = preparations.stream() + .map(PreparationDto::getPreparationTime) + .map(this::defaultNonNegative) + .reduce(0, Integer::sum); + int moveTime = defaultNonNegative(schedule.getMoveTime()); + int scheduleSpareTime = getEffectiveSpareTime(schedule); + + LocalDateTime preparationStartTime = schedule.getScheduleTime() + .minusMinutes((long) totalPreparationTime + moveTime + scheduleSpareTime); + LocalDateTime defaultAlarmTime = preparationStartTime.minusMinutes(defaultNonNegative(defaultAlarmOffsetMinutes)); + + return AlarmWindowScheduleDto.builder() + .scheduleId(schedule.getScheduleId()) + .scheduleName(schedule.getScheduleName()) + .place((schedule.getPlace() != null) ? new PlaceDto(schedule.getPlace().getPlaceId(), schedule.getPlace().getPlaceName()) : null) + .scheduleTime(schedule.getScheduleTime()) + .moveTime(moveTime) + .scheduleSpareTime(scheduleSpareTime) + .doneStatus(schedule.getDoneStatus()) + .preparationStartTime(preparationStartTime) + .defaultAlarmTime(defaultAlarmTime) + .preparations(preparations) + .alarmSettings(null) + .build(); + } + + private PreparationDto mapPreparationScheduleToDto(PreparationSchedule preparationSchedule) { + return new PreparationDto( + preparationSchedule.getPreparationScheduleId(), + preparationSchedule.getPreparationName(), + defaultNonNegative(preparationSchedule.getPreparationTime()), + preparationSchedule.getNextPreparation() != null + ? preparationSchedule.getNextPreparation().getPreparationScheduleId() + : null + ); + } + + private PreparationDto mapPreparationUserToDto(PreparationUser preparationUser) { + return new PreparationDto( + preparationUser.getPreparationUserId(), + preparationUser.getPreparationName(), + defaultNonNegative(preparationUser.getPreparationTime()), + preparationUser.getNextPreparation() != null + ? preparationUser.getNextPreparation().getPreparationUserId() + : null + ); + } + + private Integer getEffectiveSpareTime(Schedule schedule) { + if (schedule.getScheduleSpareTime() != null) { + return defaultNonNegative(schedule.getScheduleSpareTime()); + } + return defaultNonNegative(schedule.getUser().getSpareTime()); + } + + private Integer defaultNonNegative(Integer value) { + return value == null ? 0 : Math.max(value, 0); + } + } diff --git a/ontime-back/src/main/java/devkor/ontime_back/service/UserAuthService.java b/ontime-back/src/main/java/devkor/ontime_back/service/UserAuthService.java index b6a66d19..9bb7d3b2 100644 --- a/ontime-back/src/main/java/devkor/ontime_back/service/UserAuthService.java +++ b/ontime-back/src/main/java/devkor/ontime_back/service/UserAuthService.java @@ -6,8 +6,10 @@ import devkor.ontime_back.dto.UserSignUpDto; import devkor.ontime_back.entity.Role; import devkor.ontime_back.entity.User; +import devkor.ontime_back.entity.UserAlarmSetting; import devkor.ontime_back.entity.UserSetting; import devkor.ontime_back.global.jwt.JwtTokenProvider; +import devkor.ontime_back.repository.UserAlarmSettingRepository; import devkor.ontime_back.repository.UserRepository; import devkor.ontime_back.repository.UserSettingRepository; import devkor.ontime_back.response.ErrorCode; @@ -32,16 +34,38 @@ public class UserAuthService { private final UserRepository userRepository; private final UserSettingRepository userSettingRepository; + private final UserAlarmSettingRepository userAlarmSettingRepository; private final PasswordEncoder passwordEncoder; private final JwtTokenProvider jwtTokenProvider; // 엑세스토큰에서 UserId 추출 public Long getUserIdFromToken(HttpServletRequest request) { - String accessToken = request.getHeader("Authorization").substring(7); // "Bearer "를 제외한 토큰 - String refreshToken = request.getHeader("refresh-token"); + String accessToken = getAccessTokenFromRequest(request); return jwtTokenProvider.extractUserId(accessToken).orElseThrow(() -> new RuntimeException("User ID not found in token")); } + public String getAccessTokenFromRequest(HttpServletRequest request) { + return stripBearerPrefix(request.getHeader("Authorization")); + } + + public String getRefreshTokenFromRequest(HttpServletRequest request) { + String refreshToken = request.getHeader("Authorization-refresh"); + if (refreshToken == null) { + refreshToken = request.getHeader("refresh-token"); + } + return stripBearerPrefix(refreshToken); + } + + private String stripBearerPrefix(String token) { + if (token == null) { + return null; + } + if (token.startsWith("Bearer ")) { + return token.substring(7); + } + return token; + } + // 자체 로그인 회원가입 @Transactional public UserInfoResponse signUp(HttpServletRequest request, HttpServletResponse response, UserSignUpDto userSignUpDto) throws Exception { @@ -87,6 +111,7 @@ private User createUserAndUserSetting(UserSignUpDto userSignUpDto) { user.setUserSetting(userSetting); userRepository.save(user); //CASCADE옵션 덕분에 userRepository만 save해주면 됨(userSettingRepository는 save안해줘도 부모인 user를 따라 저장됨) + userAlarmSettingRepository.save(UserAlarmSetting.defaultFor(user)); return user; } @@ -96,6 +121,7 @@ private void createAndSendTokens(HttpServletResponse response, User user) { jwtTokenProvider.sendAccessAndRefreshToken(response, accessToken, refreshToken); + user.updateAccessToken(accessToken); user.updateRefreshToken(refreshToken); userRepository.saveAndFlush(user); } @@ -143,4 +169,4 @@ public Long deleteUser(Long userId) { return userId; } -} \ No newline at end of file +} diff --git a/ontime-back/src/main/resources/db/migration/V8__add_native_alarm_support.sql b/ontime-back/src/main/resources/db/migration/V8__add_native_alarm_support.sql new file mode 100644 index 00000000..bb5381c4 --- /dev/null +++ b/ontime-back/src/main/resources/db/migration/V8__add_native_alarm_support.sql @@ -0,0 +1,57 @@ +CREATE TABLE user_alarm_setting ( + user_alarm_setting_id BIGINT AUTO_INCREMENT PRIMARY KEY, + user_id BIGINT NOT NULL, + alarms_enabled BOOLEAN NOT NULL DEFAULT TRUE, + default_alarm_offset_minutes INT NOT NULL DEFAULT 5, + updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + CONSTRAINT uk_user_alarm_setting_user UNIQUE (user_id), + CONSTRAINT fk_user_alarm_setting_user FOREIGN KEY (user_id) REFERENCES user (user_id) ON DELETE CASCADE +); + +INSERT INTO user_alarm_setting (user_id, alarms_enabled, default_alarm_offset_minutes, updated_at) +SELECT user_id, TRUE, 5, CURRENT_TIMESTAMP FROM user; + +CREATE TABLE user_device ( + user_device_id BIGINT AUTO_INCREMENT PRIMARY KEY, + user_id BIGINT NOT NULL, + device_id VARCHAR(128) NOT NULL, + platform VARCHAR(20) NOT NULL, + app_version VARCHAR(128), + os_version VARCHAR(128), + supports_native_alarm BOOLEAN NOT NULL DEFAULT FALSE, + native_alarm_provider VARCHAR(40) NOT NULL, + fallback_provider VARCHAR(40) NOT NULL, + active BOOLEAN NOT NULL DEFAULT TRUE, + last_seen_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + firebase_token TEXT, + session_access_token TEXT, + session_refresh_token TEXT, + CONSTRAINT uk_user_device_user_device UNIQUE (user_id, device_id), + CONSTRAINT fk_user_device_user FOREIGN KEY (user_id) REFERENCES user (user_id) ON DELETE CASCADE +); + +CREATE INDEX idx_user_device_user_active ON user_device(user_id, active); + +CREATE TABLE user_alarm_status ( + user_alarm_status_id BIGINT AUTO_INCREMENT PRIMARY KEY, + user_id BIGINT NOT NULL, + user_device_id BIGINT NOT NULL, + device_id VARCHAR(128) NOT NULL, + reconciled_at TIMESTAMP NOT NULL, + schedule_window_start TIMESTAMP, + schedule_window_end TIMESTAMP, + alarm_coverage_start TIMESTAMP, + alarm_coverage_end TIMESTAMP, + status VARCHAR(40) NOT NULL, + permission_issue VARCHAR(60), + native_alarm_provider VARCHAR(40) NOT NULL, + fallback_provider VARCHAR(40) NOT NULL, + armed_schedule_count INT NOT NULL DEFAULT 0, + armed_schedule_ids TEXT, + skipped_schedule_count INT NOT NULL DEFAULT 0, + failures TEXT, + updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + CONSTRAINT uk_user_alarm_status_device UNIQUE (user_device_id), + CONSTRAINT fk_user_alarm_status_user FOREIGN KEY (user_id) REFERENCES user (user_id) ON DELETE CASCADE, + CONSTRAINT fk_user_alarm_status_device FOREIGN KEY (user_device_id) REFERENCES user_device (user_device_id) ON DELETE CASCADE +); diff --git a/ontime-back/src/test/java/devkor/ontime_back/service/AlarmServiceTest.java b/ontime-back/src/test/java/devkor/ontime_back/service/AlarmServiceTest.java new file mode 100644 index 00000000..63519f4a --- /dev/null +++ b/ontime-back/src/test/java/devkor/ontime_back/service/AlarmServiceTest.java @@ -0,0 +1,238 @@ +package devkor.ontime_back.service; + +import com.fasterxml.jackson.databind.ObjectMapper; +import devkor.ontime_back.dto.AlarmDeviceCurrentRequestDto; +import devkor.ontime_back.dto.AlarmDeviceCurrentResponseDto; +import devkor.ontime_back.dto.AlarmStatusReportRequestDto; +import devkor.ontime_back.entity.User; +import devkor.ontime_back.entity.UserAlarmSetting; +import devkor.ontime_back.entity.UserAlarmStatus; +import devkor.ontime_back.entity.UserDevice; +import devkor.ontime_back.repository.UserAlarmSettingRepository; +import devkor.ontime_back.repository.UserAlarmStatusRepository; +import devkor.ontime_back.repository.UserDeviceRepository; +import devkor.ontime_back.repository.UserRepository; +import devkor.ontime_back.response.ErrorCode; +import devkor.ontime_back.response.GeneralException; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import java.time.Instant; +import java.time.LocalDateTime; +import java.time.OffsetDateTime; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.UUID; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +class AlarmServiceTest { + + private static final Long USER_ID = 1L; + private static final String DEVICE_ID = "4f78cdd2-2d90-43b8-8bc5-53df8d9c5b12"; + private static final String ACCESS_TOKEN = "current-access-token"; + private static final String REFRESH_TOKEN = "current-refresh-token"; + + @Mock + private UserRepository userRepository; + + @Mock + private UserAlarmSettingRepository userAlarmSettingRepository; + + @Mock + private UserDeviceRepository userDeviceRepository; + + @Mock + private UserAlarmStatusRepository userAlarmStatusRepository; + + private AlarmService alarmService; + private User user; + + @BeforeEach + void setUp() { + alarmService = new AlarmService( + userRepository, + userAlarmSettingRepository, + userDeviceRepository, + userAlarmStatusRepository, + new ObjectMapper() + ); + user = User.builder() + .id(USER_ID) + .email("user@example.com") + .accessToken(ACCESS_TOKEN) + .build(); + } + + @Test + @DisplayName("registerCurrentDevice binds the active device to the current access token and deactivates older devices") + void registerCurrentDeviceBindsSessionAndDeactivatesOlderDevices() { + UserDevice oldDevice = userDevice("old-device-id-1234", 10L); + oldDevice.bindSession("old-access-token", null); + + when(userRepository.findById(USER_ID)).thenReturn(Optional.of(user)); + when(userDeviceRepository.findAllByUserIdAndActiveTrue(USER_ID)).thenReturn(List.of(oldDevice)); + when(userDeviceRepository.findByUserIdAndDeviceId(USER_ID, DEVICE_ID)).thenReturn(Optional.empty()); + when(userDeviceRepository.save(any(UserDevice.class))).thenAnswer(invocation -> invocation.getArgument(0)); + + AlarmDeviceCurrentRequestDto request = AlarmDeviceCurrentRequestDto.builder() + .deviceId(DEVICE_ID) + .platform("ios") + .appVersion("1.4.0") + .osVersion("26.0") + .supportsNativeAlarm(true) + .nativeAlarmProvider("iosAlarmKit") + .fallbackProvider("localNotification") + .build(); + + AlarmDeviceCurrentResponseDto response = alarmService.registerCurrentDevice( + USER_ID, + request, + ACCESS_TOKEN, + REFRESH_TOKEN + ); + + assertThat(response.getDeviceId()).isEqualTo(DEVICE_ID); + assertThat(response.getActive()).isTrue(); + assertThat(oldDevice.getActive()).isFalse(); + verify(userDeviceRepository).save(any(UserDevice.class)); + } + + @Test + @DisplayName("reportAlarmStatus rejects a device whose session token is no longer current") + void reportAlarmStatusRejectsWrongSession() { + UserDevice currentDevice = userDevice(DEVICE_ID, 11L); + currentDevice.bindSession("different-access-token", null); + + when(userDeviceRepository.findByUserIdAndDeviceIdAndActiveTrue(USER_ID, DEVICE_ID)) + .thenReturn(Optional.of(currentDevice)); + + assertThatThrownBy(() -> alarmService.reportAlarmStatus(USER_ID, validStatusReport(), ACCESS_TOKEN)) + .isInstanceOf(GeneralException.class) + .extracting("errorCode") + .isEqualTo(ErrorCode.DEVICE_SESSION_NOT_ACTIVE); + } + + @Test + @DisplayName("shouldSuppressLegacyReminder only suppresses fresh current-session armed schedules") + void shouldSuppressLegacyReminderRequiresCurrentSessionAndArmedSchedule() { + UUID scheduleId = UUID.fromString("123e4567-e89b-12d3-a456-426614170105"); + UserDevice currentDevice = userDevice(DEVICE_ID, 12L); + currentDevice.bindSession(ACCESS_TOKEN, null); + UserAlarmStatus alarmStatus = UserAlarmStatus.builder() + .user(user) + .userDevice(currentDevice) + .deviceId(DEVICE_ID) + .reconciledAt(Instant.now()) + .alarmCoverageStart(LocalDateTime.of(2026, 5, 5, 0, 0)) + .alarmCoverageEnd(LocalDateTime.of(2026, 5, 12, 0, 0)) + .status("armed") + .nativeAlarmProvider("androidAlarmManager") + .fallbackProvider("none") + .armedScheduleCount(1) + .armedScheduleIds("[\"" + scheduleId + "\"]") + .skippedScheduleCount(0) + .failures("[]") + .updatedAt(Instant.now()) + .build(); + + when(userAlarmSettingRepository.findByUserId(USER_ID)) + .thenReturn(Optional.of(UserAlarmSetting.defaultFor(user))); + when(userDeviceRepository.findFirstByUserIdAndActiveTrueOrderByLastSeenAtDesc(USER_ID)) + .thenReturn(Optional.of(currentDevice)); + when(userRepository.findById(USER_ID)).thenReturn(Optional.of(user)); + when(userAlarmStatusRepository.findByUserDeviceUserDeviceId(currentDevice.getUserDeviceId())) + .thenReturn(Optional.of(alarmStatus)); + + boolean shouldSuppress = alarmService.shouldSuppressLegacyReminder( + USER_ID, + scheduleId, + LocalDateTime.of(2026, 5, 5, 8, 50) + ); + + assertThat(shouldSuppress).isTrue(); + } + + @Test + @DisplayName("patchAlarmSettings rejects fractional defaultAlarmOffsetMinutes") + void patchAlarmSettingsRejectsFractionalOffset() { + assertThatThrownBy(() -> alarmService.patchAlarmSettings( + USER_ID, + Map.of("defaultAlarmOffsetMinutes", 5.5) + )) + .isInstanceOf(GeneralException.class) + .extracting("errorCode") + .isEqualTo(ErrorCode.INVALID_INPUT); + } + + @Test + @DisplayName("reportAlarmStatus rejects unsupported when local notification fallback is active") + void reportAlarmStatusRejectsUnsupportedWithFallbackCoverage() { + assertThatThrownBy(() -> alarmService.reportAlarmStatus( + USER_ID, + unsupportedWithFallbackStatusReport(), + ACCESS_TOKEN + )) + .isInstanceOf(GeneralException.class) + .extracting("errorCode") + .isEqualTo(ErrorCode.INVALID_INPUT); + } + + private AlarmStatusReportRequestDto validStatusReport() { + return AlarmStatusReportRequestDto.builder() + .deviceId(DEVICE_ID) + .reconciledAt(OffsetDateTime.parse("2026-05-05T09:00:00.000Z")) + .scheduleWindowStart(LocalDateTime.of(2026, 5, 5, 0, 0)) + .scheduleWindowEnd(LocalDateTime.of(2026, 5, 13, 0, 0)) + .alarmCoverageStart(LocalDateTime.of(2026, 5, 5, 0, 0)) + .alarmCoverageEnd(LocalDateTime.of(2026, 5, 12, 0, 0)) + .status("armed") + .nativeAlarmProvider("iosAlarmKit") + .fallbackProvider("localNotification") + .armedScheduleCount(0) + .armedScheduleIds(List.of()) + .skippedScheduleCount(0) + .build(); + } + + private AlarmStatusReportRequestDto unsupportedWithFallbackStatusReport() { + return AlarmStatusReportRequestDto.builder() + .deviceId(DEVICE_ID) + .reconciledAt(OffsetDateTime.parse("2026-05-05T09:00:00.000Z")) + .scheduleWindowStart(LocalDateTime.of(2026, 5, 5, 0, 0)) + .scheduleWindowEnd(LocalDateTime.of(2026, 5, 13, 0, 0)) + .alarmCoverageStart(LocalDateTime.of(2026, 5, 5, 0, 0)) + .alarmCoverageEnd(LocalDateTime.of(2026, 5, 12, 0, 0)) + .status("unsupported") + .nativeAlarmProvider("none") + .fallbackProvider("localNotification") + .armedScheduleCount(0) + .armedScheduleIds(List.of()) + .skippedScheduleCount(0) + .build(); + } + + private UserDevice userDevice(String deviceId, Long userDeviceId) { + return UserDevice.builder() + .userDeviceId(userDeviceId) + .user(user) + .deviceId(deviceId) + .platform("ios") + .supportsNativeAlarm(true) + .nativeAlarmProvider("iosAlarmKit") + .fallbackProvider("localNotification") + .active(true) + .lastSeenAt(Instant.now()) + .build(); + } +}