diff --git a/.DS_Store b/.DS_Store
deleted file mode 100644
index 4dca84cb..00000000
Binary files a/.DS_Store and /dev/null differ
diff --git a/.gitignore b/.gitignore
new file mode 100644
index 00000000..4c5d06c0
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,3 @@
+.DS_Store
+.idea/
+.codex/
diff --git a/.idea/.gitignore b/.idea/.gitignore
deleted file mode 100644
index 13566b81..00000000
--- a/.idea/.gitignore
+++ /dev/null
@@ -1,8 +0,0 @@
-# Default ignored files
-/shelf/
-/workspace.xml
-# Editor-based HTTP Client requests
-/httpRequests/
-# Datasource local storage ignored files
-/dataSources/
-/dataSources.local.xml
diff --git a/.idea/compiler.xml b/.idea/compiler.xml
deleted file mode 100644
index 29f32fb3..00000000
--- a/.idea/compiler.xml
+++ /dev/null
@@ -1,15 +0,0 @@
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
\ 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
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();
+ }
+}