Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions docs/account-deletion-api.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -8,6 +8,8 @@ Account deletion hard-deletes the user from OnTime. The request can optionally i

For Google and Apple social accounts, the backend first tries to revoke the social login token, then deletes the local OnTime account. If provider token revocation fails, the backend logs a warning and still deletes the local OnTime account.

Account deletion also deletes account-scoped settings and preferences, including the analytics preference stored in `user_analytics_preference`. Future user-linked Product Usage Events stop after deletion; historical analytics may be retained only in aggregate or de-identified form under the approved privacy policy and analytics provider configuration.

For release/privacy evidence by data category, see `docs/account-deletion-verification-evidence.md`.

## Authentication
Expand Down
1 change: 1 addition & 0 deletions docs/account-deletion-verification-evidence.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -49,6 +49,7 @@ request timestamp, response, owner, and evidence link.
| Access and refresh tokens | `user.access_token`, `user.refresh_token`, `user_device.session_access_token`, `user_device.session_refresh_token` | Deleted in local integration test | N/A after deletion | Account deletion hard-delete and device cascade | Test asserts user and device rows are absent | Backend repo; release env owner TBD |
| Device records and FCM tokens | `user.firebase_token`, `user_device`, `user_device.firebase_token` | Deleted in local integration test | N/A after deletion | Account deletion hard-delete and device cascade | Test asserts user device row is absent | Backend repo; release env owner TBD |
| Alarm settings and alarm status | `user_alarm_setting`, `user_alarm_status` | Deleted in local integration test | N/A after deletion | Foreign-key cascade from user/device | Test asserts alarm setting and status rows are absent | Backend repo; release env owner TBD |
| Analytics preference | `user_analytics_preference` | Deleted in local integration test | N/A after deletion | Foreign-key cascade from user | Test asserts analytics preference row is absent | Backend repo; release env owner TBD |
| Default preparation settings | `preparation_user` | Deleted in local integration test | N/A after deletion | Foreign-key cascade from user | Test asserts preparation user rows are absent | Backend repo; release env owner TBD |
| Schedules | `schedule`, `notification_schedule` | Deleted in local integration test | N/A after deletion | Foreign-key cascade from user and schedule | Test asserts schedule and notification schedule rows are absent | Backend repo; release env owner TBD |
| Schedule preparation steps | `preparation_schedule` | Deleted in local integration test | N/A after deletion | Foreign-key cascade from schedule | Test asserts preparation schedule rows are absent | Backend repo; release env owner TBD |
Expand Down
91 changes: 91 additions & 0 deletions docs/analytics-preference-api.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,91 @@
# Analytics Preference API

Issue: #318

## Summary

The analytics preference API stores whether the signed-in account allows optional
Product Usage Events. The preference is account-scoped, not device-scoped.

This API does not define Firebase event names, frontend instrumentation, local
pre-login preference storage, UI copy, marketing analytics, personalization, or
Firebase Remote Config behavior.

## Default And Release Gate

Backend default is controlled by:

```properties
analytics.preference.default-enabled=${ANALYTICS_PREFERENCE_DEFAULT_ENABLED:false}
```

The default remains `false` until the privacy policy and Google Play Data Safety
updates are approved for the Firebase Analytics release. After approval, deploy
owners may set `ANALYTICS_PREFERENCE_DEFAULT_ENABLED=true`.

Rows created before the default is flipped are marked as not user-overridden.
When the service reads a non-overridden row, it may align that row to the current
deploy default. Once a user explicitly updates the preference, the row is marked
as user-overridden and future default flips do not change that choice.

## Get Analytics Preference

```http
GET /users/me/analytics-preference
Authorization: Bearer <access token>
```

Successful response:

```json
{
"status": "success",
"code": 200,
"message": "OK",
"data": {
"enabled": false,
"updatedAt": "2026-05-26T12:00:00Z"
}
}
```

## Update Analytics Preference

```http
PUT /users/me/analytics-preference
Authorization: Bearer <access token>
Content-Type: application/json

{
"enabled": true
}
```

Successful response:

```json
{
"status": "success",
"code": 200,
"message": "OK",
"data": {
"enabled": true,
"updatedAt": "2026-05-26T12:00:05Z"
}
}
```

`enabled` is required and must be a JSON boolean. Missing, null, non-boolean, or
unknown fields are rejected with the existing validation-style `400` response.

## Persistence And Deletion

The preference is stored in `user_analytics_preference` with a unique foreign key
to `user(user_id)`, `enabled`, `updated_at`, and the internal
`user_overridden` flag.

On account deletion, the local analytics preference row is deleted by foreign-key
cascade. Future user-linked Product Usage Events stop when the account
preference is disabled or the account is deleted. Historical analytics may be
retained only in aggregate or de-identified form, subject to the approved privacy
policy and Firebase/analytics project configuration.
Original file line numberDiff line numberDiff line change
Expand Up@@ -17,6 +17,7 @@
import devkor.ontime_back.global.oauth.google.GoogleLoginFilter;
import devkor.ontime_back.repository.UserAlarmSettingRepository;
import devkor.ontime_back.repository.UserRepository;
import devkor.ontime_back.service.AnalyticsPreferenceService;
import jakarta.validation.Validator;
import lombok.RequiredArgsConstructor;
import org.springframework.beans.factory.annotation.Value;
Expand DownExpand Up@@ -58,6 +59,7 @@ public class SecurityConfig {
private final Validator validator;
private final AppleLoginService appleLoginService;
private final GoogleLoginService googleLoginService;
private final AnalyticsPreferenceService analyticsPreferenceService;

@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
Expand All@@ -79,7 +81,7 @@ public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
.requestMatchers("/health").permitAll() // 로드밸런서 연결 확인용 url
.anyRequest().authenticated()
)
.addFilterBefore(new KakaoLoginFilter("/oauth2/kakao/login", objectMapper, validator, jwtTokenProvider, userRepository, userAlarmSettingRepository),
.addFilterBefore(new KakaoLoginFilter("/oauth2/kakao/login", objectMapper, validator, jwtTokenProvider, userRepository, userAlarmSettingRepository, analyticsPreferenceService),
UsernamePasswordAuthenticationFilter.class)
.addFilterBefore(new GoogleLoginFilter("/oauth2/google/login", objectMapper, validator, googleLoginService, userRepository),
UsernamePasswordAuthenticationFilter.class)
Expand Down
Original file line numberDiff line numberDiff line change
Expand Up@@ -281,6 +281,11 @@ private Map<String, NamedExample> requestExamples(String operationKey) {
examples.put("valid_partial_update", json("Valid partial update", "Only provided fields are updated.", "{\"alarmsEnabled\":true,\"defaultAlarmOffsetMinutes\":10}"));
examples.put("invalid_unknown_field", json("Invalid unknown field", "Unknown fields are rejected.", "{\"alarmsEnabled\":\"true\",\"unknown\":1}"));
}
case "PUT /users/me/analytics-preference" -> {
examples.put("enabled", json("Enable analytics", "Allows optional Product Usage Events for the signed-in account.", "{\"enabled\":true}"));
examples.put("disabled", json("Disable analytics", "Stops optional Product Usage Events for the signed-in account.", "{\"enabled\":false}"));
examples.put("invalid_unknown_field", json("Invalid unknown field", "Only enabled is accepted.", "{\"enabled\":\"false\",\"unknown\":1}"));
}
case "PUT /users/me/devices/current" -> {
examples.put("valid_ios_device", json("Valid iOS device", "Registers the current access-token session to the device.", "{\"deviceId\":\"ios-device-000001\",\"platform\":\"ios\",\"appVersion\":\"1.2.3\",\"osVersion\":\"iOS 18.0\",\"supportsNativeAlarm\":true,\"nativeAlarmProvider\":\"iosAlarmKit\",\"fallbackProvider\":\"localNotification\"}"));
examples.put("invalid_device_id", json("Invalid device ID", "deviceId must be 16-128 allowed characters.", "{\"deviceId\":\"short\",\"platform\":\"ios\",\"supportsNativeAlarm\":true,\"nativeAlarmProvider\":\"iosAlarmKit\",\"fallbackProvider\":\"localNotification\"}"));
Expand DownExpand Up@@ -389,6 +394,8 @@ private String successData(String operationKey) {
return switch (operationKey) {
case "GET /users/me/alarm-settings" -> "{\"alarmsEnabled\":true,\"defaultAlarmOffsetMinutes\":10,\"updatedAt\":\"2026-05-05T00:00:00Z\"}";
case "PATCH /users/me/alarm-settings" -> "{\"alarmsEnabled\":false,\"defaultAlarmOffsetMinutes\":5,\"updatedAt\":\"2026-05-05T00:00:00Z\"}";
case "GET /users/me/analytics-preference" -> "{\"enabled\":false,\"updatedAt\":\"2026-05-26T12:00:00Z\"}";
case "PUT /users/me/analytics-preference" -> "{\"enabled\":true,\"updatedAt\":\"2026-05-26T12:00:05Z\"}";
case "PUT /users/me/devices/current" -> "{\"deviceId\":\"ios-device-000001\",\"active\":true,\"lastSeenAt\":\"2026-05-05T00:00:00Z\"}";
case "DELETE /users/me/devices/current" -> "{\"active\":false}";
case "POST /users/me/alarm-status" -> "{\"received\":true}";
Expand Down
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
package devkor.ontime_back.controller;

import devkor.ontime_back.dto.AnalyticsPreferenceResponseDto;
import devkor.ontime_back.dto.AnalyticsPreferenceUpdateDto;
import devkor.ontime_back.response.ApiResponseForm;
import devkor.ontime_back.service.AnalyticsPreferenceService;
import devkor.ontime_back.service.UserAuthService;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.media.Content;
import io.swagger.v3.oas.annotations.media.Schema;
import io.swagger.v3.oas.annotations.responses.ApiResponse;
import io.swagger.v3.oas.annotations.responses.ApiResponses;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PutMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RestController;

@RestController
@RequiredArgsConstructor
public class AnalyticsPreferenceController {

private final UserAuthService userAuthService;
private final AnalyticsPreferenceService analyticsPreferenceService;

@Operation(summary = "Get current user's analytics preference")
@ApiResponses(value = {
@ApiResponse(responseCode = "200", description = "Analytics preference lookup succeeded", content = @Content(
mediaType = "application/json",
schema = @Schema(example = "{\n \"status\": \"success\",\n \"code\": 200,\n \"message\": \"OK\",\n \"data\": {\n \"enabled\": false,\n \"updatedAt\": \"2026-05-26T12:00:00Z\"\n }\n}")
)),
@ApiResponse(responseCode = "4XX", description = "Analytics preference lookup failed", content = @Content(mediaType = "application/json", schema = @Schema(example = "Failure message")))
})
@GetMapping("/users/me/analytics-preference")
public ResponseEntity<ApiResponseForm<AnalyticsPreferenceResponseDto>> getAnalyticsPreference(HttpServletRequest request) {
Long userId = userAuthService.getUserIdFromToken(request);
return ResponseEntity.status(HttpStatus.OK)
.body(ApiResponseForm.success(analyticsPreferenceService.getAnalyticsPreference(userId)));
}

@Operation(
summary = "Update current user's analytics preference",
requestBody = @io.swagger.v3.oas.annotations.parameters.RequestBody(
description = "Account-scoped analytics preference update.",
required = true,
content = @Content(schema = @Schema(
type = "object",
example = "{\"enabled\": false}"
))
)
)
@ApiResponses(value = {
@ApiResponse(responseCode = "200", description = "Analytics preference update succeeded", content = @Content(
mediaType = "application/json",
schema = @Schema(example = "{\n \"status\": \"success\",\n \"code\": 200,\n \"message\": \"OK\",\n \"data\": {\n \"enabled\": false,\n \"updatedAt\": \"2026-05-26T12:00:05Z\"\n }\n}")
)),
@ApiResponse(responseCode = "4XX", description = "Analytics preference update failed", content = @Content(mediaType = "application/json", schema = @Schema(example = "Failure message")))
})
@PutMapping("/users/me/analytics-preference")
public ResponseEntity<ApiResponseForm<AnalyticsPreferenceResponseDto>> updateAnalyticsPreference(
HttpServletRequest request,
@Valid @RequestBody AnalyticsPreferenceUpdateDto requestDto) {
Long userId = userAuthService.getUserIdFromToken(request);
return ResponseEntity.status(HttpStatus.OK)
.body(ApiResponseForm.success(analyticsPreferenceService.updateAnalyticsPreference(userId, requestDto)));
}
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
package devkor.ontime_back.dto;

import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Getter;

import java.time.Instant;

@Getter
@Builder
@AllArgsConstructor
public class AnalyticsPreferenceResponseDto {
private Boolean enabled;
private Instant updatedAt;
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
package devkor.ontime_back.dto;

import com.fasterxml.jackson.annotation.JsonAnySetter;
import jakarta.validation.constraints.AssertTrue;
import lombok.Getter;
import lombok.NoArgsConstructor;

import java.util.HashMap;
import java.util.Map;

@Getter
@NoArgsConstructor
public class AnalyticsPreferenceUpdateDto {
private Object enabled;
private final Map<String, Object> unknownFields = new HashMap<>();

@JsonAnySetter
public void addUnknownField(String name, Object value) {
unknownFields.put(name, value);
}

@AssertTrue(message = "알 수 없는 분석 설정 필드입니다.")
public boolean isKnownFieldsOnly() {
return unknownFields.isEmpty();
}

@AssertTrue(message = "enabled는 필수 값입니다.")
public boolean isEnabledPresent() {
return enabled != null;
}

@AssertTrue(message = "enabled는 boolean 값이어야 합니다.")
public boolean isEnabledBoolean() {
return enabled == null || enabled instanceof Boolean;
}

public Boolean getEnabledValue() {
return enabled instanceof Boolean value ? value : null;
}
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,85 @@
package devkor.ontime_back.entity;

import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.FetchType;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.JoinColumn;
import jakarta.persistence.OneToOne;
import jakarta.persistence.PrePersist;
import jakarta.persistence.Table;
import jakarta.persistence.UniqueConstraint;
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.util.Objects;

@Getter
@Entity
@Builder
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@AllArgsConstructor
@Table(
uniqueConstraints = {
@UniqueConstraint(name = "uk_user_analytics_preference_user", columnNames = "user_id")
}
)
public class UserAnalyticsPreference {

@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long userAnalyticsPreferenceId;

@OneToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "user_id", nullable = false)
@OnDelete(action = OnDeleteAction.CASCADE)
private User user;

@Column(nullable = false)
private Boolean enabled;

@Column(nullable = false)
private Instant updatedAt;

@Column(nullable = false)
private Boolean userOverridden;

public static UserAnalyticsPreference defaultFor(User user, boolean defaultEnabled) {
return UserAnalyticsPreference.builder()
.user(user)
.enabled(defaultEnabled)
.updatedAt(Instant.now())
.userOverridden(false)
.build();
}

@PrePersist
private void initializeDefaults() {
if (enabled == null) enabled = false;
if (updatedAt == null) updatedAt = Instant.now();
if (userOverridden == null) userOverridden = false;
}

public boolean alignToDefault(boolean defaultEnabled) {
if (Boolean.TRUE.equals(userOverridden) || Objects.equals(enabled, defaultEnabled)) {
return false;
}
this.enabled = defaultEnabled;
this.updatedAt = Instant.now();
return true;
}

public void update(boolean enabled) {
this.enabled = enabled;
this.userOverridden = true;
this.updatedAt = Instant.now();
}
}
Original file line numberDiff line numberDiff line change
Expand Up@@ -14,6 +14,7 @@
import devkor.ontime_back.repository.UserAlarmSettingRepository;
import devkor.ontime_back.repository.UserRepository;
import devkor.ontime_back.response.InvalidTokenException;
import devkor.ontime_back.service.AnalyticsPreferenceService;
import io.jsonwebtoken.Claims;
import io.jsonwebtoken.Jwts;
import io.jsonwebtoken.SignatureAlgorithm;
Expand DownExpand Up@@ -71,6 +72,7 @@ public class AppleLoginService {
private final UserRepository userRepository;
private final UserAlarmSettingRepository userAlarmSettingRepository;
private final JwtTokenProvider jwtTokenProvider;
private final AnalyticsPreferenceService analyticsPreferenceService;

private final RestTemplate restTemplate = new RestTemplate();
public Authentication handleLogin(String appleRefreshToken, User user, HttpServletResponse response) throws IOException {
Expand DownExpand Up@@ -141,6 +143,7 @@ public Authentication handleRegister(String appleRefreshToken, OAuthAppleUserDto
savedUser.updateRefreshToken(refreshToken);
userRepository.save(savedUser);
userAlarmSettingRepository.save(UserAlarmSetting.defaultFor(savedUser));
analyticsPreferenceService.createDefaultPreference(savedUser);

Authentication authentication = new UsernamePasswordAuthenticationToken(
savedUser, null, Collections.singletonList(new SimpleGrantedAuthority(savedUser.getRole().name()))
Expand Down
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Add copy buttons to all
 blocks\n(function() {\n function addCopyButtons() {\n document.querySelectorAll('pre code').forEach(function(codeBlock) {\n if (codeBlock.parentElement.hasAttribute('data-copy-added')) return;\n codeBlock.parentElement.setAttribute('data-copy-added', 'true');\n \n var btn = document.createElement('button');\n btn.textContent = 'Copy';\n btn.style.cssText = 'position:absolute;top:4px;right:4px;padding:2px 8px;font-size:11px;background:#4ecdc4;border:none;border-radius:4px;color:#1a1a2e;cursor:pointer;opacity:0.7;transition:opacity 0.2s;';\n btn.onmouseover = function() { this.style.opacity = '1'; };\n btn.onmouseout = function() { this.style.opacity = '0.7'; };\n btn.onclick = function() {\n navigator.clipboard.writeText(codeBlock.textContent).then(function() {\n btn.textContent = 'Copied!';\n setTimeout(function() { btn.textContent = 'Copy'; }, 1500);\n });\n };\n codeBlock.parentElement.style.position = 'relative';\n codeBlock.parentElement.appendChild(btn);\n });\n }\n \n addCopyButtons();\n \n // Re-run on dynamic content\n var observer = new MutationObserver(addCopyButtons);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Add Copy Buttons to Code Blocks");
}
} catch(__e) { console.warn('[Userscript:Add Copy Buttons to Code Blocks]', __e); }
})();
(function(){
try {
var __m = "github.com";
var __re = new RegExp('^' + "github\\.com" + '
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions docs/account-deletion-api.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -8,6 +8,8 @@ Account deletion hard-deletes the user from OnTime. The request can optionally i

For Google and Apple social accounts, the backend first tries to revoke the social login token, then deletes the local OnTime account. If provider token revocation fails, the backend logs a warning and still deletes the local OnTime account.

Account deletion also deletes account-scoped settings and preferences, including the analytics preference stored in `user_analytics_preference`. Future user-linked Product Usage Events stop after deletion; historical analytics may be retained only in aggregate or de-identified form under the approved privacy policy and analytics provider configuration.

For release/privacy evidence by data category, see `docs/account-deletion-verification-evidence.md`.

## Authentication
Expand Down
1 change: 1 addition & 0 deletions docs/account-deletion-verification-evidence.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -49,6 +49,7 @@ request timestamp, response, owner, and evidence link.
| Access and refresh tokens | `user.access_token`, `user.refresh_token`, `user_device.session_access_token`, `user_device.session_refresh_token` | Deleted in local integration test | N/A after deletion | Account deletion hard-delete and device cascade | Test asserts user and device rows are absent | Backend repo; release env owner TBD |
| Device records and FCM tokens | `user.firebase_token`, `user_device`, `user_device.firebase_token` | Deleted in local integration test | N/A after deletion | Account deletion hard-delete and device cascade | Test asserts user device row is absent | Backend repo; release env owner TBD |
| Alarm settings and alarm status | `user_alarm_setting`, `user_alarm_status` | Deleted in local integration test | N/A after deletion | Foreign-key cascade from user/device | Test asserts alarm setting and status rows are absent | Backend repo; release env owner TBD |
| Analytics preference | `user_analytics_preference` | Deleted in local integration test | N/A after deletion | Foreign-key cascade from user | Test asserts analytics preference row is absent | Backend repo; release env owner TBD |
| Default preparation settings | `preparation_user` | Deleted in local integration test | N/A after deletion | Foreign-key cascade from user | Test asserts preparation user rows are absent | Backend repo; release env owner TBD |
| Schedules | `schedule`, `notification_schedule` | Deleted in local integration test | N/A after deletion | Foreign-key cascade from user and schedule | Test asserts schedule and notification schedule rows are absent | Backend repo; release env owner TBD |
| Schedule preparation steps | `preparation_schedule` | Deleted in local integration test | N/A after deletion | Foreign-key cascade from schedule | Test asserts preparation schedule rows are absent | Backend repo; release env owner TBD |
Expand Down
91 changes: 91 additions & 0 deletions docs/analytics-preference-api.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,91 @@
# Analytics Preference API

Issue: #318

## Summary

The analytics preference API stores whether the signed-in account allows optional
Product Usage Events. The preference is account-scoped, not device-scoped.

This API does not define Firebase event names, frontend instrumentation, local
pre-login preference storage, UI copy, marketing analytics, personalization, or
Firebase Remote Config behavior.

## Default And Release Gate

Backend default is controlled by:

```properties
analytics.preference.default-enabled=${ANALYTICS_PREFERENCE_DEFAULT_ENABLED:false}
```

The default remains `false` until the privacy policy and Google Play Data Safety
updates are approved for the Firebase Analytics release. After approval, deploy
owners may set `ANALYTICS_PREFERENCE_DEFAULT_ENABLED=true`.

Rows created before the default is flipped are marked as not user-overridden.
When the service reads a non-overridden row, it may align that row to the current
deploy default. Once a user explicitly updates the preference, the row is marked
as user-overridden and future default flips do not change that choice.

## Get Analytics Preference

```http
GET /users/me/analytics-preference
Authorization: Bearer <access token>
```

Successful response:

```json
{
"status": "success",
"code": 200,
"message": "OK",
"data": {
"enabled": false,
"updatedAt": "2026-05-26T12:00:00Z"
}
}
```

## Update Analytics Preference

```http
PUT /users/me/analytics-preference
Authorization: Bearer <access token>
Content-Type: application/json

{
"enabled": true
}
```

Successful response:

```json
{
"status": "success",
"code": 200,
"message": "OK",
"data": {
"enabled": true,
"updatedAt": "2026-05-26T12:00:05Z"
}
}
```

`enabled` is required and must be a JSON boolean. Missing, null, non-boolean, or
unknown fields are rejected with the existing validation-style `400` response.

## Persistence And Deletion

The preference is stored in `user_analytics_preference` with a unique foreign key
to `user(user_id)`, `enabled`, `updated_at`, and the internal
`user_overridden` flag.

On account deletion, the local analytics preference row is deleted by foreign-key
cascade. Future user-linked Product Usage Events stop when the account
preference is disabled or the account is deleted. Historical analytics may be
retained only in aggregate or de-identified form, subject to the approved privacy
policy and Firebase/analytics project configuration.
Original file line numberDiff line numberDiff line change
Expand Up@@ -17,6 +17,7 @@
import devkor.ontime_back.global.oauth.google.GoogleLoginFilter;
import devkor.ontime_back.repository.UserAlarmSettingRepository;
import devkor.ontime_back.repository.UserRepository;
import devkor.ontime_back.service.AnalyticsPreferenceService;
import jakarta.validation.Validator;
import lombok.RequiredArgsConstructor;
import org.springframework.beans.factory.annotation.Value;
Expand DownExpand Up@@ -58,6 +59,7 @@ public class SecurityConfig {
private final Validator validator;
private final AppleLoginService appleLoginService;
private final GoogleLoginService googleLoginService;
private final AnalyticsPreferenceService analyticsPreferenceService;

@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
Expand All@@ -79,7 +81,7 @@ public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
.requestMatchers("/health").permitAll() // 로드밸런서 연결 확인용 url
.anyRequest().authenticated()
)
.addFilterBefore(new KakaoLoginFilter("/oauth2/kakao/login", objectMapper, validator, jwtTokenProvider, userRepository, userAlarmSettingRepository),
.addFilterBefore(new KakaoLoginFilter("/oauth2/kakao/login", objectMapper, validator, jwtTokenProvider, userRepository, userAlarmSettingRepository, analyticsPreferenceService),
UsernamePasswordAuthenticationFilter.class)
.addFilterBefore(new GoogleLoginFilter("/oauth2/google/login", objectMapper, validator, googleLoginService, userRepository),
UsernamePasswordAuthenticationFilter.class)
Expand Down
Original file line numberDiff line numberDiff line change
Expand Up@@ -281,6 +281,11 @@ private Map<String, NamedExample> requestExamples(String operationKey) {
examples.put("valid_partial_update", json("Valid partial update", "Only provided fields are updated.", "{\"alarmsEnabled\":true,\"defaultAlarmOffsetMinutes\":10}"));
examples.put("invalid_unknown_field", json("Invalid unknown field", "Unknown fields are rejected.", "{\"alarmsEnabled\":\"true\",\"unknown\":1}"));
}
case "PUT /users/me/analytics-preference" -> {
examples.put("enabled", json("Enable analytics", "Allows optional Product Usage Events for the signed-in account.", "{\"enabled\":true}"));
examples.put("disabled", json("Disable analytics", "Stops optional Product Usage Events for the signed-in account.", "{\"enabled\":false}"));
examples.put("invalid_unknown_field", json("Invalid unknown field", "Only enabled is accepted.", "{\"enabled\":\"false\",\"unknown\":1}"));
}
case "PUT /users/me/devices/current" -> {
examples.put("valid_ios_device", json("Valid iOS device", "Registers the current access-token session to the device.", "{\"deviceId\":\"ios-device-000001\",\"platform\":\"ios\",\"appVersion\":\"1.2.3\",\"osVersion\":\"iOS 18.0\",\"supportsNativeAlarm\":true,\"nativeAlarmProvider\":\"iosAlarmKit\",\"fallbackProvider\":\"localNotification\"}"));
examples.put("invalid_device_id", json("Invalid device ID", "deviceId must be 16-128 allowed characters.", "{\"deviceId\":\"short\",\"platform\":\"ios\",\"supportsNativeAlarm\":true,\"nativeAlarmProvider\":\"iosAlarmKit\",\"fallbackProvider\":\"localNotification\"}"));
Expand DownExpand Up@@ -389,6 +394,8 @@ private String successData(String operationKey) {
return switch (operationKey) {
case "GET /users/me/alarm-settings" -> "{\"alarmsEnabled\":true,\"defaultAlarmOffsetMinutes\":10,\"updatedAt\":\"2026-05-05T00:00:00Z\"}";
case "PATCH /users/me/alarm-settings" -> "{\"alarmsEnabled\":false,\"defaultAlarmOffsetMinutes\":5,\"updatedAt\":\"2026-05-05T00:00:00Z\"}";
case "GET /users/me/analytics-preference" -> "{\"enabled\":false,\"updatedAt\":\"2026-05-26T12:00:00Z\"}";
case "PUT /users/me/analytics-preference" -> "{\"enabled\":true,\"updatedAt\":\"2026-05-26T12:00:05Z\"}";
case "PUT /users/me/devices/current" -> "{\"deviceId\":\"ios-device-000001\",\"active\":true,\"lastSeenAt\":\"2026-05-05T00:00:00Z\"}";
case "DELETE /users/me/devices/current" -> "{\"active\":false}";
case "POST /users/me/alarm-status" -> "{\"received\":true}";
Expand Down
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
package devkor.ontime_back.controller;

import devkor.ontime_back.dto.AnalyticsPreferenceResponseDto;
import devkor.ontime_back.dto.AnalyticsPreferenceUpdateDto;
import devkor.ontime_back.response.ApiResponseForm;
import devkor.ontime_back.service.AnalyticsPreferenceService;
import devkor.ontime_back.service.UserAuthService;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.media.Content;
import io.swagger.v3.oas.annotations.media.Schema;
import io.swagger.v3.oas.annotations.responses.ApiResponse;
import io.swagger.v3.oas.annotations.responses.ApiResponses;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PutMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RestController;

@RestController
@RequiredArgsConstructor
public class AnalyticsPreferenceController {

private final UserAuthService userAuthService;
private final AnalyticsPreferenceService analyticsPreferenceService;

@Operation(summary = "Get current user's analytics preference")
@ApiResponses(value = {
@ApiResponse(responseCode = "200", description = "Analytics preference lookup succeeded", content = @Content(
mediaType = "application/json",
schema = @Schema(example = "{\n \"status\": \"success\",\n \"code\": 200,\n \"message\": \"OK\",\n \"data\": {\n \"enabled\": false,\n \"updatedAt\": \"2026-05-26T12:00:00Z\"\n }\n}")
)),
@ApiResponse(responseCode = "4XX", description = "Analytics preference lookup failed", content = @Content(mediaType = "application/json", schema = @Schema(example = "Failure message")))
})
@GetMapping("/users/me/analytics-preference")
public ResponseEntity<ApiResponseForm<AnalyticsPreferenceResponseDto>> getAnalyticsPreference(HttpServletRequest request) {
Long userId = userAuthService.getUserIdFromToken(request);
return ResponseEntity.status(HttpStatus.OK)
.body(ApiResponseForm.success(analyticsPreferenceService.getAnalyticsPreference(userId)));
}

@Operation(
summary = "Update current user's analytics preference",
requestBody = @io.swagger.v3.oas.annotations.parameters.RequestBody(
description = "Account-scoped analytics preference update.",
required = true,
content = @Content(schema = @Schema(
type = "object",
example = "{\"enabled\": false}"
))
)
)
@ApiResponses(value = {
@ApiResponse(responseCode = "200", description = "Analytics preference update succeeded", content = @Content(
mediaType = "application/json",
schema = @Schema(example = "{\n \"status\": \"success\",\n \"code\": 200,\n \"message\": \"OK\",\n \"data\": {\n \"enabled\": false,\n \"updatedAt\": \"2026-05-26T12:00:05Z\"\n }\n}")
)),
@ApiResponse(responseCode = "4XX", description = "Analytics preference update failed", content = @Content(mediaType = "application/json", schema = @Schema(example = "Failure message")))
})
@PutMapping("/users/me/analytics-preference")
public ResponseEntity<ApiResponseForm<AnalyticsPreferenceResponseDto>> updateAnalyticsPreference(
HttpServletRequest request,
@Valid @RequestBody AnalyticsPreferenceUpdateDto requestDto) {
Long userId = userAuthService.getUserIdFromToken(request);
return ResponseEntity.status(HttpStatus.OK)
.body(ApiResponseForm.success(analyticsPreferenceService.updateAnalyticsPreference(userId, requestDto)));
}
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
package devkor.ontime_back.dto;

import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Getter;

import java.time.Instant;

@Getter
@Builder
@AllArgsConstructor
public class AnalyticsPreferenceResponseDto {
private Boolean enabled;
private Instant updatedAt;
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
package devkor.ontime_back.dto;

import com.fasterxml.jackson.annotation.JsonAnySetter;
import jakarta.validation.constraints.AssertTrue;
import lombok.Getter;
import lombok.NoArgsConstructor;

import java.util.HashMap;
import java.util.Map;

@Getter
@NoArgsConstructor
public class AnalyticsPreferenceUpdateDto {
private Object enabled;
private final Map<String, Object> unknownFields = new HashMap<>();

@JsonAnySetter
public void addUnknownField(String name, Object value) {
unknownFields.put(name, value);
}

@AssertTrue(message = "알 수 없는 분석 설정 필드입니다.")
public boolean isKnownFieldsOnly() {
return unknownFields.isEmpty();
}

@AssertTrue(message = "enabled는 필수 값입니다.")
public boolean isEnabledPresent() {
return enabled != null;
}

@AssertTrue(message = "enabled는 boolean 값이어야 합니다.")
public boolean isEnabledBoolean() {
return enabled == null || enabled instanceof Boolean;
}

public Boolean getEnabledValue() {
return enabled instanceof Boolean value ? value : null;
}
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,85 @@
package devkor.ontime_back.entity;

import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.FetchType;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.JoinColumn;
import jakarta.persistence.OneToOne;
import jakarta.persistence.PrePersist;
import jakarta.persistence.Table;
import jakarta.persistence.UniqueConstraint;
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.util.Objects;

@Getter
@Entity
@Builder
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@AllArgsConstructor
@Table(
uniqueConstraints = {
@UniqueConstraint(name = "uk_user_analytics_preference_user", columnNames = "user_id")
}
)
public class UserAnalyticsPreference {

@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long userAnalyticsPreferenceId;

@OneToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "user_id", nullable = false)
@OnDelete(action = OnDeleteAction.CASCADE)
private User user;

@Column(nullable = false)
private Boolean enabled;

@Column(nullable = false)
private Instant updatedAt;

@Column(nullable = false)
private Boolean userOverridden;

public static UserAnalyticsPreference defaultFor(User user, boolean defaultEnabled) {
return UserAnalyticsPreference.builder()
.user(user)
.enabled(defaultEnabled)
.updatedAt(Instant.now())
.userOverridden(false)
.build();
}

@PrePersist
private void initializeDefaults() {
if (enabled == null) enabled = false;
if (updatedAt == null) updatedAt = Instant.now();
if (userOverridden == null) userOverridden = false;
}

public boolean alignToDefault(boolean defaultEnabled) {
if (Boolean.TRUE.equals(userOverridden) || Objects.equals(enabled, defaultEnabled)) {
return false;
}
this.enabled = defaultEnabled;
this.updatedAt = Instant.now();
return true;
}

public void update(boolean enabled) {
this.enabled = enabled;
this.userOverridden = true;
this.updatedAt = Instant.now();
}
}
Original file line numberDiff line numberDiff line change
Expand Up@@ -14,6 +14,7 @@
import devkor.ontime_back.repository.UserAlarmSettingRepository;
import devkor.ontime_back.repository.UserRepository;
import devkor.ontime_back.response.InvalidTokenException;
import devkor.ontime_back.service.AnalyticsPreferenceService;
import io.jsonwebtoken.Claims;
import io.jsonwebtoken.Jwts;
import io.jsonwebtoken.SignatureAlgorithm;
Expand DownExpand Up@@ -71,6 +72,7 @@ public class AppleLoginService {
private final UserRepository userRepository;
private final UserAlarmSettingRepository userAlarmSettingRepository;
private final JwtTokenProvider jwtTokenProvider;
private final AnalyticsPreferenceService analyticsPreferenceService;

private final RestTemplate restTemplate = new RestTemplate();
public Authentication handleLogin(String appleRefreshToken, User user, HttpServletResponse response) throws IOException {
Expand DownExpand Up@@ -141,6 +143,7 @@ public Authentication handleRegister(String appleRefreshToken, OAuthAppleUserDto
savedUser.updateRefreshToken(refreshToken);
userRepository.save(savedUser);
userAlarmSettingRepository.save(UserAlarmSetting.defaultFor(savedUser));
analyticsPreferenceService.createDefaultPreference(savedUser);

Authentication authentication = new UsernamePasswordAuthenticationToken(
savedUser, null, Collections.singletonList(new SimpleGrantedAuthority(savedUser.getRole().name()))
Expand Down
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Force GitHub README to respect dark mode\n(function() {\n var style = document.createElement('style');\n style.textContent = '\n .markdown-body {\n color-scheme: dark light;\n }\n .markdown-body pre { background: #161b22 !important; }\n .markdown-body code { background: rgba(110, 118, 129, 0.4) !important; }\n .markdown-body table th, .markdown-body table td { border-color: #30363d !important; }\n .markdown-body img { background: #0d1117; }\n .markdown-body blockquote { border-left-color: #8b949e; }\n .markdown-body hr { border-color: #30363d; }\n ';\n document.head.appendChild(style);\n})();", "GitHub Dark Mode README Fix"); } } catch(__e) { console.warn('[Userscript:GitHub Dark Mode README Fix]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions docs/account-deletion-api.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -8,6 +8,8 @@ Account deletion hard-deletes the user from OnTime. The request can optionally i

For Google and Apple social accounts, the backend first tries to revoke the social login token, then deletes the local OnTime account. If provider token revocation fails, the backend logs a warning and still deletes the local OnTime account.

Account deletion also deletes account-scoped settings and preferences, including the analytics preference stored in `user_analytics_preference`. Future user-linked Product Usage Events stop after deletion; historical analytics may be retained only in aggregate or de-identified form under the approved privacy policy and analytics provider configuration.

For release/privacy evidence by data category, see `docs/account-deletion-verification-evidence.md`.

## Authentication
Expand Down
1 change: 1 addition & 0 deletions docs/account-deletion-verification-evidence.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -49,6 +49,7 @@ request timestamp, response, owner, and evidence link.
| Access and refresh tokens | `user.access_token`, `user.refresh_token`, `user_device.session_access_token`, `user_device.session_refresh_token` | Deleted in local integration test | N/A after deletion | Account deletion hard-delete and device cascade | Test asserts user and device rows are absent | Backend repo; release env owner TBD |
| Device records and FCM tokens | `user.firebase_token`, `user_device`, `user_device.firebase_token` | Deleted in local integration test | N/A after deletion | Account deletion hard-delete and device cascade | Test asserts user device row is absent | Backend repo; release env owner TBD |
| Alarm settings and alarm status | `user_alarm_setting`, `user_alarm_status` | Deleted in local integration test | N/A after deletion | Foreign-key cascade from user/device | Test asserts alarm setting and status rows are absent | Backend repo; release env owner TBD |
| Analytics preference | `user_analytics_preference` | Deleted in local integration test | N/A after deletion | Foreign-key cascade from user | Test asserts analytics preference row is absent | Backend repo; release env owner TBD |
| Default preparation settings | `preparation_user` | Deleted in local integration test | N/A after deletion | Foreign-key cascade from user | Test asserts preparation user rows are absent | Backend repo; release env owner TBD |
| Schedules | `schedule`, `notification_schedule` | Deleted in local integration test | N/A after deletion | Foreign-key cascade from user and schedule | Test asserts schedule and notification schedule rows are absent | Backend repo; release env owner TBD |
| Schedule preparation steps | `preparation_schedule` | Deleted in local integration test | N/A after deletion | Foreign-key cascade from schedule | Test asserts preparation schedule rows are absent | Backend repo; release env owner TBD |
Expand Down
91 changes: 91 additions & 0 deletions docs/analytics-preference-api.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,91 @@
# Analytics Preference API

Issue: #318

## Summary

The analytics preference API stores whether the signed-in account allows optional
Product Usage Events. The preference is account-scoped, not device-scoped.

This API does not define Firebase event names, frontend instrumentation, local
pre-login preference storage, UI copy, marketing analytics, personalization, or
Firebase Remote Config behavior.

## Default And Release Gate

Backend default is controlled by:

```properties
analytics.preference.default-enabled=${ANALYTICS_PREFERENCE_DEFAULT_ENABLED:false}
```

The default remains `false` until the privacy policy and Google Play Data Safety
updates are approved for the Firebase Analytics release. After approval, deploy
owners may set `ANALYTICS_PREFERENCE_DEFAULT_ENABLED=true`.

Rows created before the default is flipped are marked as not user-overridden.
When the service reads a non-overridden row, it may align that row to the current
deploy default. Once a user explicitly updates the preference, the row is marked
as user-overridden and future default flips do not change that choice.

## Get Analytics Preference

```http
GET /users/me/analytics-preference
Authorization: Bearer <access token>
```

Successful response:

```json
{
"status": "success",
"code": 200,
"message": "OK",
"data": {
"enabled": false,
"updatedAt": "2026-05-26T12:00:00Z"
}
}
```

## Update Analytics Preference

```http
PUT /users/me/analytics-preference
Authorization: Bearer <access token>
Content-Type: application/json

{
"enabled": true
}
```

Successful response:

```json
{
"status": "success",
"code": 200,
"message": "OK",
"data": {
"enabled": true,
"updatedAt": "2026-05-26T12:00:05Z"
}
}
```

`enabled` is required and must be a JSON boolean. Missing, null, non-boolean, or
unknown fields are rejected with the existing validation-style `400` response.

## Persistence And Deletion

The preference is stored in `user_analytics_preference` with a unique foreign key
to `user(user_id)`, `enabled`, `updated_at`, and the internal
`user_overridden` flag.

On account deletion, the local analytics preference row is deleted by foreign-key
cascade. Future user-linked Product Usage Events stop when the account
preference is disabled or the account is deleted. Historical analytics may be
retained only in aggregate or de-identified form, subject to the approved privacy
policy and Firebase/analytics project configuration.
Original file line numberDiff line numberDiff line change
Expand Up@@ -17,6 +17,7 @@
import devkor.ontime_back.global.oauth.google.GoogleLoginFilter;
import devkor.ontime_back.repository.UserAlarmSettingRepository;
import devkor.ontime_back.repository.UserRepository;
import devkor.ontime_back.service.AnalyticsPreferenceService;
import jakarta.validation.Validator;
import lombok.RequiredArgsConstructor;
import org.springframework.beans.factory.annotation.Value;
Expand DownExpand Up@@ -58,6 +59,7 @@ public class SecurityConfig {
private final Validator validator;
private final AppleLoginService appleLoginService;
private final GoogleLoginService googleLoginService;
private final AnalyticsPreferenceService analyticsPreferenceService;

@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
Expand All@@ -79,7 +81,7 @@ public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
.requestMatchers("/health").permitAll() // 로드밸런서 연결 확인용 url
.anyRequest().authenticated()
)
.addFilterBefore(new KakaoLoginFilter("/oauth2/kakao/login", objectMapper, validator, jwtTokenProvider, userRepository, userAlarmSettingRepository),
.addFilterBefore(new KakaoLoginFilter("/oauth2/kakao/login", objectMapper, validator, jwtTokenProvider, userRepository, userAlarmSettingRepository, analyticsPreferenceService),
UsernamePasswordAuthenticationFilter.class)
.addFilterBefore(new GoogleLoginFilter("/oauth2/google/login", objectMapper, validator, googleLoginService, userRepository),
UsernamePasswordAuthenticationFilter.class)
Expand Down
Original file line numberDiff line numberDiff line change
Expand Up@@ -281,6 +281,11 @@ private Map<String, NamedExample> requestExamples(String operationKey) {
examples.put("valid_partial_update", json("Valid partial update", "Only provided fields are updated.", "{\"alarmsEnabled\":true,\"defaultAlarmOffsetMinutes\":10}"));
examples.put("invalid_unknown_field", json("Invalid unknown field", "Unknown fields are rejected.", "{\"alarmsEnabled\":\"true\",\"unknown\":1}"));
}
case "PUT /users/me/analytics-preference" -> {
examples.put("enabled", json("Enable analytics", "Allows optional Product Usage Events for the signed-in account.", "{\"enabled\":true}"));
examples.put("disabled", json("Disable analytics", "Stops optional Product Usage Events for the signed-in account.", "{\"enabled\":false}"));
examples.put("invalid_unknown_field", json("Invalid unknown field", "Only enabled is accepted.", "{\"enabled\":\"false\",\"unknown\":1}"));
}
case "PUT /users/me/devices/current" -> {
examples.put("valid_ios_device", json("Valid iOS device", "Registers the current access-token session to the device.", "{\"deviceId\":\"ios-device-000001\",\"platform\":\"ios\",\"appVersion\":\"1.2.3\",\"osVersion\":\"iOS 18.0\",\"supportsNativeAlarm\":true,\"nativeAlarmProvider\":\"iosAlarmKit\",\"fallbackProvider\":\"localNotification\"}"));
examples.put("invalid_device_id", json("Invalid device ID", "deviceId must be 16-128 allowed characters.", "{\"deviceId\":\"short\",\"platform\":\"ios\",\"supportsNativeAlarm\":true,\"nativeAlarmProvider\":\"iosAlarmKit\",\"fallbackProvider\":\"localNotification\"}"));
Expand DownExpand Up@@ -389,6 +394,8 @@ private String successData(String operationKey) {
return switch (operationKey) {
case "GET /users/me/alarm-settings" -> "{\"alarmsEnabled\":true,\"defaultAlarmOffsetMinutes\":10,\"updatedAt\":\"2026-05-05T00:00:00Z\"}";
case "PATCH /users/me/alarm-settings" -> "{\"alarmsEnabled\":false,\"defaultAlarmOffsetMinutes\":5,\"updatedAt\":\"2026-05-05T00:00:00Z\"}";
case "GET /users/me/analytics-preference" -> "{\"enabled\":false,\"updatedAt\":\"2026-05-26T12:00:00Z\"}";
case "PUT /users/me/analytics-preference" -> "{\"enabled\":true,\"updatedAt\":\"2026-05-26T12:00:05Z\"}";
case "PUT /users/me/devices/current" -> "{\"deviceId\":\"ios-device-000001\",\"active\":true,\"lastSeenAt\":\"2026-05-05T00:00:00Z\"}";
case "DELETE /users/me/devices/current" -> "{\"active\":false}";
case "POST /users/me/alarm-status" -> "{\"received\":true}";
Expand Down
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
package devkor.ontime_back.controller;

import devkor.ontime_back.dto.AnalyticsPreferenceResponseDto;
import devkor.ontime_back.dto.AnalyticsPreferenceUpdateDto;
import devkor.ontime_back.response.ApiResponseForm;
import devkor.ontime_back.service.AnalyticsPreferenceService;
import devkor.ontime_back.service.UserAuthService;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.media.Content;
import io.swagger.v3.oas.annotations.media.Schema;
import io.swagger.v3.oas.annotations.responses.ApiResponse;
import io.swagger.v3.oas.annotations.responses.ApiResponses;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PutMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RestController;

@RestController
@RequiredArgsConstructor
public class AnalyticsPreferenceController {

private final UserAuthService userAuthService;
private final AnalyticsPreferenceService analyticsPreferenceService;

@Operation(summary = "Get current user's analytics preference")
@ApiResponses(value = {
@ApiResponse(responseCode = "200", description = "Analytics preference lookup succeeded", content = @Content(
mediaType = "application/json",
schema = @Schema(example = "{\n \"status\": \"success\",\n \"code\": 200,\n \"message\": \"OK\",\n \"data\": {\n \"enabled\": false,\n \"updatedAt\": \"2026-05-26T12:00:00Z\"\n }\n}")
)),
@ApiResponse(responseCode = "4XX", description = "Analytics preference lookup failed", content = @Content(mediaType = "application/json", schema = @Schema(example = "Failure message")))
})
@GetMapping("/users/me/analytics-preference")
public ResponseEntity<ApiResponseForm<AnalyticsPreferenceResponseDto>> getAnalyticsPreference(HttpServletRequest request) {
Long userId = userAuthService.getUserIdFromToken(request);
return ResponseEntity.status(HttpStatus.OK)
.body(ApiResponseForm.success(analyticsPreferenceService.getAnalyticsPreference(userId)));
}

@Operation(
summary = "Update current user's analytics preference",
requestBody = @io.swagger.v3.oas.annotations.parameters.RequestBody(
description = "Account-scoped analytics preference update.",
required = true,
content = @Content(schema = @Schema(
type = "object",
example = "{\"enabled\": false}"
))
)
)
@ApiResponses(value = {
@ApiResponse(responseCode = "200", description = "Analytics preference update succeeded", content = @Content(
mediaType = "application/json",
schema = @Schema(example = "{\n \"status\": \"success\",\n \"code\": 200,\n \"message\": \"OK\",\n \"data\": {\n \"enabled\": false,\n \"updatedAt\": \"2026-05-26T12:00:05Z\"\n }\n}")
)),
@ApiResponse(responseCode = "4XX", description = "Analytics preference update failed", content = @Content(mediaType = "application/json", schema = @Schema(example = "Failure message")))
})
@PutMapping("/users/me/analytics-preference")
public ResponseEntity<ApiResponseForm<AnalyticsPreferenceResponseDto>> updateAnalyticsPreference(
HttpServletRequest request,
@Valid @RequestBody AnalyticsPreferenceUpdateDto requestDto) {
Long userId = userAuthService.getUserIdFromToken(request);
return ResponseEntity.status(HttpStatus.OK)
.body(ApiResponseForm.success(analyticsPreferenceService.updateAnalyticsPreference(userId, requestDto)));
}
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
package devkor.ontime_back.dto;

import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Getter;

import java.time.Instant;

@Getter
@Builder
@AllArgsConstructor
public class AnalyticsPreferenceResponseDto {
private Boolean enabled;
private Instant updatedAt;
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
package devkor.ontime_back.dto;

import com.fasterxml.jackson.annotation.JsonAnySetter;
import jakarta.validation.constraints.AssertTrue;
import lombok.Getter;
import lombok.NoArgsConstructor;

import java.util.HashMap;
import java.util.Map;

@Getter
@NoArgsConstructor
public class AnalyticsPreferenceUpdateDto {
private Object enabled;
private final Map<String, Object> unknownFields = new HashMap<>();

@JsonAnySetter
public void addUnknownField(String name, Object value) {
unknownFields.put(name, value);
}

@AssertTrue(message = "알 수 없는 분석 설정 필드입니다.")
public boolean isKnownFieldsOnly() {
return unknownFields.isEmpty();
}

@AssertTrue(message = "enabled는 필수 값입니다.")
public boolean isEnabledPresent() {
return enabled != null;
}

@AssertTrue(message = "enabled는 boolean 값이어야 합니다.")
public boolean isEnabledBoolean() {
return enabled == null || enabled instanceof Boolean;
}

public Boolean getEnabledValue() {
return enabled instanceof Boolean value ? value : null;
}
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,85 @@
package devkor.ontime_back.entity;

import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.FetchType;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.JoinColumn;
import jakarta.persistence.OneToOne;
import jakarta.persistence.PrePersist;
import jakarta.persistence.Table;
import jakarta.persistence.UniqueConstraint;
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.util.Objects;

@Getter
@Entity
@Builder
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@AllArgsConstructor
@Table(
uniqueConstraints = {
@UniqueConstraint(name = "uk_user_analytics_preference_user", columnNames = "user_id")
}
)
public class UserAnalyticsPreference {

@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long userAnalyticsPreferenceId;

@OneToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "user_id", nullable = false)
@OnDelete(action = OnDeleteAction.CASCADE)
private User user;

@Column(nullable = false)
private Boolean enabled;

@Column(nullable = false)
private Instant updatedAt;

@Column(nullable = false)
private Boolean userOverridden;

public static UserAnalyticsPreference defaultFor(User user, boolean defaultEnabled) {
return UserAnalyticsPreference.builder()
.user(user)
.enabled(defaultEnabled)
.updatedAt(Instant.now())
.userOverridden(false)
.build();
}

@PrePersist
private void initializeDefaults() {
if (enabled == null) enabled = false;
if (updatedAt == null) updatedAt = Instant.now();
if (userOverridden == null) userOverridden = false;
}

public boolean alignToDefault(boolean defaultEnabled) {
if (Boolean.TRUE.equals(userOverridden) || Objects.equals(enabled, defaultEnabled)) {
return false;
}
this.enabled = defaultEnabled;
this.updatedAt = Instant.now();
return true;
}

public void update(boolean enabled) {
this.enabled = enabled;
this.userOverridden = true;
this.updatedAt = Instant.now();
}
}
Original file line numberDiff line numberDiff line change
Expand Up@@ -14,6 +14,7 @@
import devkor.ontime_back.repository.UserAlarmSettingRepository;
import devkor.ontime_back.repository.UserRepository;
import devkor.ontime_back.response.InvalidTokenException;
import devkor.ontime_back.service.AnalyticsPreferenceService;
import io.jsonwebtoken.Claims;
import io.jsonwebtoken.Jwts;
import io.jsonwebtoken.SignatureAlgorithm;
Expand DownExpand Up@@ -71,6 +72,7 @@ public class AppleLoginService {
private final UserRepository userRepository;
private final UserAlarmSettingRepository userAlarmSettingRepository;
private final JwtTokenProvider jwtTokenProvider;
private final AnalyticsPreferenceService analyticsPreferenceService;

private final RestTemplate restTemplate = new RestTemplate();
public Authentication handleLogin(String appleRefreshToken, User user, HttpServletResponse response) throws IOException {
Expand DownExpand Up@@ -141,6 +143,7 @@ public Authentication handleRegister(String appleRefreshToken, OAuthAppleUserDto
savedUser.updateRefreshToken(refreshToken);
userRepository.save(savedUser);
userAlarmSettingRepository.save(UserAlarmSetting.defaultFor(savedUser));
analyticsPreferenceService.createDefaultPreference(savedUser);

Authentication authentication = new UsernamePasswordAuthenticationToken(
savedUser, null, Collections.singletonList(new SimpleGrantedAuthority(savedUser.getRole().name()))
Expand Down
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Highlight search terms from Google/DuckDuckGo/Bing referrer\n(function() {\n var ref = document.referrer;\n var terms = [];\n \n if (ref.includes('google.com') || ref.includes('duckduckgo.com') || ref.includes('bing.com')) {\n var url = new URL(ref);\n var q = url.searchParams.get('q') || url.searchParams.get('p');\n if (q) {\n terms = q.split(/\\s+/).filter(function(t) { return t.length > 2; });\n }\n }\n \n if (terms.length === 0) return;\n \n var style = document.createElement('style');\n style.textContent = '.userscript-highlight { background: #fbbf24; color: #1a1a2e; padding: 1px 3px; border-radius: 2px; }';\n document.head.appendChild(style);\n \n function highlight(node) {\n if (node.nodeType === 3) { // text node\n var text = node.textContent;\n var found = false;\n terms.forEach(function(term) {\n var regex = new RegExp('(' + term.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\\\') + ')', 'gi');\n if (regex.test(text)) {\n found = true;\n var frag = document.createDocumentFragment();\n var parts = text.split(regex);\n parts.forEach(function(part, i) {\n if (i % 2 === 0) {\n frag.appendChild(document.createTextNode(part));\n } else {\n var span = document.createElement('span');\n span.className = 'userscript-highlight';\n span.textContent = part;\n frag.appendChild(span);\n }\n });\n node.parentNode.replaceChild(frag, node);\n }\n });\n } else if (node.nodeType === 1 && node.childNodes) { // element\n var skipTags = ['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEXTAREA', 'INPUT', 'SELECT'];\n if (!skipTags.includes(node.tagName)) {\n Array.from(node.childNodes).forEach(highlight);\n }\n }\n }\n \n highlight(document.body);\n \n // Re-highlight on dynamic content\n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1 || node.nodeType === 3) highlight(node);\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Highlight Search Terms"); } } catch(__e) { console.warn('[Userscript:Highlight Search Terms]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions docs/account-deletion-api.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -8,6 +8,8 @@ Account deletion hard-deletes the user from OnTime. The request can optionally i

For Google and Apple social accounts, the backend first tries to revoke the social login token, then deletes the local OnTime account. If provider token revocation fails, the backend logs a warning and still deletes the local OnTime account.

Account deletion also deletes account-scoped settings and preferences, including the analytics preference stored in `user_analytics_preference`. Future user-linked Product Usage Events stop after deletion; historical analytics may be retained only in aggregate or de-identified form under the approved privacy policy and analytics provider configuration.

For release/privacy evidence by data category, see `docs/account-deletion-verification-evidence.md`.

## Authentication
Expand Down
1 change: 1 addition & 0 deletions docs/account-deletion-verification-evidence.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -49,6 +49,7 @@ request timestamp, response, owner, and evidence link.
| Access and refresh tokens | `user.access_token`, `user.refresh_token`, `user_device.session_access_token`, `user_device.session_refresh_token` | Deleted in local integration test | N/A after deletion | Account deletion hard-delete and device cascade | Test asserts user and device rows are absent | Backend repo; release env owner TBD |
| Device records and FCM tokens | `user.firebase_token`, `user_device`, `user_device.firebase_token` | Deleted in local integration test | N/A after deletion | Account deletion hard-delete and device cascade | Test asserts user device row is absent | Backend repo; release env owner TBD |
| Alarm settings and alarm status | `user_alarm_setting`, `user_alarm_status` | Deleted in local integration test | N/A after deletion | Foreign-key cascade from user/device | Test asserts alarm setting and status rows are absent | Backend repo; release env owner TBD |
| Analytics preference | `user_analytics_preference` | Deleted in local integration test | N/A after deletion | Foreign-key cascade from user | Test asserts analytics preference row is absent | Backend repo; release env owner TBD |
| Default preparation settings | `preparation_user` | Deleted in local integration test | N/A after deletion | Foreign-key cascade from user | Test asserts preparation user rows are absent | Backend repo; release env owner TBD |
| Schedules | `schedule`, `notification_schedule` | Deleted in local integration test | N/A after deletion | Foreign-key cascade from user and schedule | Test asserts schedule and notification schedule rows are absent | Backend repo; release env owner TBD |
| Schedule preparation steps | `preparation_schedule` | Deleted in local integration test | N/A after deletion | Foreign-key cascade from schedule | Test asserts preparation schedule rows are absent | Backend repo; release env owner TBD |
Expand Down
91 changes: 91 additions & 0 deletions docs/analytics-preference-api.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,91 @@
# Analytics Preference API

Issue: #318

## Summary

The analytics preference API stores whether the signed-in account allows optional
Product Usage Events. The preference is account-scoped, not device-scoped.

This API does not define Firebase event names, frontend instrumentation, local
pre-login preference storage, UI copy, marketing analytics, personalization, or
Firebase Remote Config behavior.

## Default And Release Gate

Backend default is controlled by:

```properties
analytics.preference.default-enabled=${ANALYTICS_PREFERENCE_DEFAULT_ENABLED:false}
```

The default remains `false` until the privacy policy and Google Play Data Safety
updates are approved for the Firebase Analytics release. After approval, deploy
owners may set `ANALYTICS_PREFERENCE_DEFAULT_ENABLED=true`.

Rows created before the default is flipped are marked as not user-overridden.
When the service reads a non-overridden row, it may align that row to the current
deploy default. Once a user explicitly updates the preference, the row is marked
as user-overridden and future default flips do not change that choice.

## Get Analytics Preference

```http
GET /users/me/analytics-preference
Authorization: Bearer <access token>
```

Successful response:

```json
{
"status": "success",
"code": 200,
"message": "OK",
"data": {
"enabled": false,
"updatedAt": "2026-05-26T12:00:00Z"
}
}
```

## Update Analytics Preference

```http
PUT /users/me/analytics-preference
Authorization: Bearer <access token>
Content-Type: application/json

{
"enabled": true
}
```

Successful response:

```json
{
"status": "success",
"code": 200,
"message": "OK",
"data": {
"enabled": true,
"updatedAt": "2026-05-26T12:00:05Z"
}
}
```

`enabled` is required and must be a JSON boolean. Missing, null, non-boolean, or
unknown fields are rejected with the existing validation-style `400` response.

## Persistence And Deletion

The preference is stored in `user_analytics_preference` with a unique foreign key
to `user(user_id)`, `enabled`, `updated_at`, and the internal
`user_overridden` flag.

On account deletion, the local analytics preference row is deleted by foreign-key
cascade. Future user-linked Product Usage Events stop when the account
preference is disabled or the account is deleted. Historical analytics may be
retained only in aggregate or de-identified form, subject to the approved privacy
policy and Firebase/analytics project configuration.
Original file line numberDiff line numberDiff line change
Expand Up@@ -17,6 +17,7 @@
import devkor.ontime_back.global.oauth.google.GoogleLoginFilter;
import devkor.ontime_back.repository.UserAlarmSettingRepository;
import devkor.ontime_back.repository.UserRepository;
import devkor.ontime_back.service.AnalyticsPreferenceService;
import jakarta.validation.Validator;
import lombok.RequiredArgsConstructor;
import org.springframework.beans.factory.annotation.Value;
Expand DownExpand Up@@ -58,6 +59,7 @@ public class SecurityConfig {
private final Validator validator;
private final AppleLoginService appleLoginService;
private final GoogleLoginService googleLoginService;
private final AnalyticsPreferenceService analyticsPreferenceService;

@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
Expand All@@ -79,7 +81,7 @@ public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
.requestMatchers("/health").permitAll() // 로드밸런서 연결 확인용 url
.anyRequest().authenticated()
)
.addFilterBefore(new KakaoLoginFilter("/oauth2/kakao/login", objectMapper, validator, jwtTokenProvider, userRepository, userAlarmSettingRepository),
.addFilterBefore(new KakaoLoginFilter("/oauth2/kakao/login", objectMapper, validator, jwtTokenProvider, userRepository, userAlarmSettingRepository, analyticsPreferenceService),
UsernamePasswordAuthenticationFilter.class)
.addFilterBefore(new GoogleLoginFilter("/oauth2/google/login", objectMapper, validator, googleLoginService, userRepository),
UsernamePasswordAuthenticationFilter.class)
Expand Down
Original file line numberDiff line numberDiff line change
Expand Up@@ -281,6 +281,11 @@ private Map<String, NamedExample> requestExamples(String operationKey) {
examples.put("valid_partial_update", json("Valid partial update", "Only provided fields are updated.", "{\"alarmsEnabled\":true,\"defaultAlarmOffsetMinutes\":10}"));
examples.put("invalid_unknown_field", json("Invalid unknown field", "Unknown fields are rejected.", "{\"alarmsEnabled\":\"true\",\"unknown\":1}"));
}
case "PUT /users/me/analytics-preference" -> {
examples.put("enabled", json("Enable analytics", "Allows optional Product Usage Events for the signed-in account.", "{\"enabled\":true}"));
examples.put("disabled", json("Disable analytics", "Stops optional Product Usage Events for the signed-in account.", "{\"enabled\":false}"));
examples.put("invalid_unknown_field", json("Invalid unknown field", "Only enabled is accepted.", "{\"enabled\":\"false\",\"unknown\":1}"));
}
case "PUT /users/me/devices/current" -> {
examples.put("valid_ios_device", json("Valid iOS device", "Registers the current access-token session to the device.", "{\"deviceId\":\"ios-device-000001\",\"platform\":\"ios\",\"appVersion\":\"1.2.3\",\"osVersion\":\"iOS 18.0\",\"supportsNativeAlarm\":true,\"nativeAlarmProvider\":\"iosAlarmKit\",\"fallbackProvider\":\"localNotification\"}"));
examples.put("invalid_device_id", json("Invalid device ID", "deviceId must be 16-128 allowed characters.", "{\"deviceId\":\"short\",\"platform\":\"ios\",\"supportsNativeAlarm\":true,\"nativeAlarmProvider\":\"iosAlarmKit\",\"fallbackProvider\":\"localNotification\"}"));
Expand DownExpand Up@@ -389,6 +394,8 @@ private String successData(String operationKey) {
return switch (operationKey) {
case "GET /users/me/alarm-settings" -> "{\"alarmsEnabled\":true,\"defaultAlarmOffsetMinutes\":10,\"updatedAt\":\"2026-05-05T00:00:00Z\"}";
case "PATCH /users/me/alarm-settings" -> "{\"alarmsEnabled\":false,\"defaultAlarmOffsetMinutes\":5,\"updatedAt\":\"2026-05-05T00:00:00Z\"}";
case "GET /users/me/analytics-preference" -> "{\"enabled\":false,\"updatedAt\":\"2026-05-26T12:00:00Z\"}";
case "PUT /users/me/analytics-preference" -> "{\"enabled\":true,\"updatedAt\":\"2026-05-26T12:00:05Z\"}";
case "PUT /users/me/devices/current" -> "{\"deviceId\":\"ios-device-000001\",\"active\":true,\"lastSeenAt\":\"2026-05-05T00:00:00Z\"}";
case "DELETE /users/me/devices/current" -> "{\"active\":false}";
case "POST /users/me/alarm-status" -> "{\"received\":true}";
Expand Down
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
package devkor.ontime_back.controller;

import devkor.ontime_back.dto.AnalyticsPreferenceResponseDto;
import devkor.ontime_back.dto.AnalyticsPreferenceUpdateDto;
import devkor.ontime_back.response.ApiResponseForm;
import devkor.ontime_back.service.AnalyticsPreferenceService;
import devkor.ontime_back.service.UserAuthService;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.media.Content;
import io.swagger.v3.oas.annotations.media.Schema;
import io.swagger.v3.oas.annotations.responses.ApiResponse;
import io.swagger.v3.oas.annotations.responses.ApiResponses;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PutMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RestController;

@RestController
@RequiredArgsConstructor
public class AnalyticsPreferenceController {

private final UserAuthService userAuthService;
private final AnalyticsPreferenceService analyticsPreferenceService;

@Operation(summary = "Get current user's analytics preference")
@ApiResponses(value = {
@ApiResponse(responseCode = "200", description = "Analytics preference lookup succeeded", content = @Content(
mediaType = "application/json",
schema = @Schema(example = "{\n \"status\": \"success\",\n \"code\": 200,\n \"message\": \"OK\",\n \"data\": {\n \"enabled\": false,\n \"updatedAt\": \"2026-05-26T12:00:00Z\"\n }\n}")
)),
@ApiResponse(responseCode = "4XX", description = "Analytics preference lookup failed", content = @Content(mediaType = "application/json", schema = @Schema(example = "Failure message")))
})
@GetMapping("/users/me/analytics-preference")
public ResponseEntity<ApiResponseForm<AnalyticsPreferenceResponseDto>> getAnalyticsPreference(HttpServletRequest request) {
Long userId = userAuthService.getUserIdFromToken(request);
return ResponseEntity.status(HttpStatus.OK)
.body(ApiResponseForm.success(analyticsPreferenceService.getAnalyticsPreference(userId)));
}

@Operation(
summary = "Update current user's analytics preference",
requestBody = @io.swagger.v3.oas.annotations.parameters.RequestBody(
description = "Account-scoped analytics preference update.",
required = true,
content = @Content(schema = @Schema(
type = "object",
example = "{\"enabled\": false}"
))
)
)
@ApiResponses(value = {
@ApiResponse(responseCode = "200", description = "Analytics preference update succeeded", content = @Content(
mediaType = "application/json",
schema = @Schema(example = "{\n \"status\": \"success\",\n \"code\": 200,\n \"message\": \"OK\",\n \"data\": {\n \"enabled\": false,\n \"updatedAt\": \"2026-05-26T12:00:05Z\"\n }\n}")
)),
@ApiResponse(responseCode = "4XX", description = "Analytics preference update failed", content = @Content(mediaType = "application/json", schema = @Schema(example = "Failure message")))
})
@PutMapping("/users/me/analytics-preference")
public ResponseEntity<ApiResponseForm<AnalyticsPreferenceResponseDto>> updateAnalyticsPreference(
HttpServletRequest request,
@Valid @RequestBody AnalyticsPreferenceUpdateDto requestDto) {
Long userId = userAuthService.getUserIdFromToken(request);
return ResponseEntity.status(HttpStatus.OK)
.body(ApiResponseForm.success(analyticsPreferenceService.updateAnalyticsPreference(userId, requestDto)));
}
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
package devkor.ontime_back.dto;

import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Getter;

import java.time.Instant;

@Getter
@Builder
@AllArgsConstructor
public class AnalyticsPreferenceResponseDto {
private Boolean enabled;
private Instant updatedAt;
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
package devkor.ontime_back.dto;

import com.fasterxml.jackson.annotation.JsonAnySetter;
import jakarta.validation.constraints.AssertTrue;
import lombok.Getter;
import lombok.NoArgsConstructor;

import java.util.HashMap;
import java.util.Map;

@Getter
@NoArgsConstructor
public class AnalyticsPreferenceUpdateDto {
private Object enabled;
private final Map<String, Object> unknownFields = new HashMap<>();

@JsonAnySetter
public void addUnknownField(String name, Object value) {
unknownFields.put(name, value);
}

@AssertTrue(message = "알 수 없는 분석 설정 필드입니다.")
public boolean isKnownFieldsOnly() {
return unknownFields.isEmpty();
}

@AssertTrue(message = "enabled는 필수 값입니다.")
public boolean isEnabledPresent() {
return enabled != null;
}

@AssertTrue(message = "enabled는 boolean 값이어야 합니다.")
public boolean isEnabledBoolean() {
return enabled == null || enabled instanceof Boolean;
}

public Boolean getEnabledValue() {
return enabled instanceof Boolean value ? value : null;
}
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,85 @@
package devkor.ontime_back.entity;

import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.FetchType;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.JoinColumn;
import jakarta.persistence.OneToOne;
import jakarta.persistence.PrePersist;
import jakarta.persistence.Table;
import jakarta.persistence.UniqueConstraint;
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.util.Objects;

@Getter
@Entity
@Builder
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@AllArgsConstructor
@Table(
uniqueConstraints = {
@UniqueConstraint(name = "uk_user_analytics_preference_user", columnNames = "user_id")
}
)
public class UserAnalyticsPreference {

@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long userAnalyticsPreferenceId;

@OneToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "user_id", nullable = false)
@OnDelete(action = OnDeleteAction.CASCADE)
private User user;

@Column(nullable = false)
private Boolean enabled;

@Column(nullable = false)
private Instant updatedAt;

@Column(nullable = false)
private Boolean userOverridden;

public static UserAnalyticsPreference defaultFor(User user, boolean defaultEnabled) {
return UserAnalyticsPreference.builder()
.user(user)
.enabled(defaultEnabled)
.updatedAt(Instant.now())
.userOverridden(false)
.build();
}

@PrePersist
private void initializeDefaults() {
if (enabled == null) enabled = false;
if (updatedAt == null) updatedAt = Instant.now();
if (userOverridden == null) userOverridden = false;
}

public boolean alignToDefault(boolean defaultEnabled) {
if (Boolean.TRUE.equals(userOverridden) || Objects.equals(enabled, defaultEnabled)) {
return false;
}
this.enabled = defaultEnabled;
this.updatedAt = Instant.now();
return true;
}

public void update(boolean enabled) {
this.enabled = enabled;
this.userOverridden = true;
this.updatedAt = Instant.now();
}
}
Original file line numberDiff line numberDiff line change
Expand Up@@ -14,6 +14,7 @@
import devkor.ontime_back.repository.UserAlarmSettingRepository;
import devkor.ontime_back.repository.UserRepository;
import devkor.ontime_back.response.InvalidTokenException;
import devkor.ontime_back.service.AnalyticsPreferenceService;
import io.jsonwebtoken.Claims;
import io.jsonwebtoken.Jwts;
import io.jsonwebtoken.SignatureAlgorithm;
Expand DownExpand Up@@ -71,6 +72,7 @@ public class AppleLoginService {
private final UserRepository userRepository;
private final UserAlarmSettingRepository userAlarmSettingRepository;
private final JwtTokenProvider jwtTokenProvider;
private final AnalyticsPreferenceService analyticsPreferenceService;

private final RestTemplate restTemplate = new RestTemplate();
public Authentication handleLogin(String appleRefreshToken, User user, HttpServletResponse response) throws IOException {
Expand DownExpand Up@@ -141,6 +143,7 @@ public Authentication handleRegister(String appleRefreshToken, OAuthAppleUserDto
savedUser.updateRefreshToken(refreshToken);
userRepository.save(savedUser);
userAlarmSettingRepository.save(UserAlarmSetting.defaultFor(savedUser));
analyticsPreferenceService.createDefaultPreference(savedUser);

Authentication authentication = new UsernamePasswordAuthenticationToken(
savedUser, null, Collections.singletonList(new SimpleGrantedAuthority(savedUser.getRole().name()))
Expand Down
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Strip utm_, fbclid, gclid, etc. from all links on page\n(function() {\n var trackingParams = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content',\n 'fbclid', 'gclid', 'dclid', 'msclkid', 'yclid',\n 'ref', 'ref_src', 'source', 'medium', 'campaign'];\n \n function cleanUrl(url) {\n try {\n var u = new URL(url, window.location.origin);\n var changed = false;\n trackingParams.forEach(function(p) {\n if (u.searchParams.has(p)) {\n u.searchParams.delete(p);\n changed = true;\n }\n });\n return changed ? u.toString() : url;\n } catch (e) {\n return url;\n }\n }\n \n function cleanLinks() {\n document.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n \n cleanLinks();\n \n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1) {\n if (node.tagName === 'A') cleanLinks();\n node.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Remove Tracking Parameters from Links"); } } catch(__e) { console.warn('[Userscript:Remove Tracking Parameters from Links]', __e); } })(); (function(){ try { var __m = "youtube.com"; var __re = new RegExp('^' + "youtube\\.com" + '
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions docs/account-deletion-api.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -8,6 +8,8 @@ Account deletion hard-deletes the user from OnTime. The request can optionally i

For Google and Apple social accounts, the backend first tries to revoke the social login token, then deletes the local OnTime account. If provider token revocation fails, the backend logs a warning and still deletes the local OnTime account.

Account deletion also deletes account-scoped settings and preferences, including the analytics preference stored in `user_analytics_preference`. Future user-linked Product Usage Events stop after deletion; historical analytics may be retained only in aggregate or de-identified form under the approved privacy policy and analytics provider configuration.

For release/privacy evidence by data category, see `docs/account-deletion-verification-evidence.md`.

## Authentication
Expand Down
1 change: 1 addition & 0 deletions docs/account-deletion-verification-evidence.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -49,6 +49,7 @@ request timestamp, response, owner, and evidence link.
| Access and refresh tokens | `user.access_token`, `user.refresh_token`, `user_device.session_access_token`, `user_device.session_refresh_token` | Deleted in local integration test | N/A after deletion | Account deletion hard-delete and device cascade | Test asserts user and device rows are absent | Backend repo; release env owner TBD |
| Device records and FCM tokens | `user.firebase_token`, `user_device`, `user_device.firebase_token` | Deleted in local integration test | N/A after deletion | Account deletion hard-delete and device cascade | Test asserts user device row is absent | Backend repo; release env owner TBD |
| Alarm settings and alarm status | `user_alarm_setting`, `user_alarm_status` | Deleted in local integration test | N/A after deletion | Foreign-key cascade from user/device | Test asserts alarm setting and status rows are absent | Backend repo; release env owner TBD |
| Analytics preference | `user_analytics_preference` | Deleted in local integration test | N/A after deletion | Foreign-key cascade from user | Test asserts analytics preference row is absent | Backend repo; release env owner TBD |
| Default preparation settings | `preparation_user` | Deleted in local integration test | N/A after deletion | Foreign-key cascade from user | Test asserts preparation user rows are absent | Backend repo; release env owner TBD |
| Schedules | `schedule`, `notification_schedule` | Deleted in local integration test | N/A after deletion | Foreign-key cascade from user and schedule | Test asserts schedule and notification schedule rows are absent | Backend repo; release env owner TBD |
| Schedule preparation steps | `preparation_schedule` | Deleted in local integration test | N/A after deletion | Foreign-key cascade from schedule | Test asserts preparation schedule rows are absent | Backend repo; release env owner TBD |
Expand Down
91 changes: 91 additions & 0 deletions docs/analytics-preference-api.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,91 @@
# Analytics Preference API

Issue: #318

## Summary

The analytics preference API stores whether the signed-in account allows optional
Product Usage Events. The preference is account-scoped, not device-scoped.

This API does not define Firebase event names, frontend instrumentation, local
pre-login preference storage, UI copy, marketing analytics, personalization, or
Firebase Remote Config behavior.

## Default And Release Gate

Backend default is controlled by:

```properties
analytics.preference.default-enabled=${ANALYTICS_PREFERENCE_DEFAULT_ENABLED:false}
```

The default remains `false` until the privacy policy and Google Play Data Safety
updates are approved for the Firebase Analytics release. After approval, deploy
owners may set `ANALYTICS_PREFERENCE_DEFAULT_ENABLED=true`.

Rows created before the default is flipped are marked as not user-overridden.
When the service reads a non-overridden row, it may align that row to the current
deploy default. Once a user explicitly updates the preference, the row is marked
as user-overridden and future default flips do not change that choice.

## Get Analytics Preference

```http
GET /users/me/analytics-preference
Authorization: Bearer <access token>
```

Successful response:

```json
{
"status": "success",
"code": 200,
"message": "OK",
"data": {
"enabled": false,
"updatedAt": "2026-05-26T12:00:00Z"
}
}
```

## Update Analytics Preference

```http
PUT /users/me/analytics-preference
Authorization: Bearer <access token>
Content-Type: application/json

{
"enabled": true
}
```

Successful response:

```json
{
"status": "success",
"code": 200,
"message": "OK",
"data": {
"enabled": true,
"updatedAt": "2026-05-26T12:00:05Z"
}
}
```

`enabled` is required and must be a JSON boolean. Missing, null, non-boolean, or
unknown fields are rejected with the existing validation-style `400` response.

## Persistence And Deletion

The preference is stored in `user_analytics_preference` with a unique foreign key
to `user(user_id)`, `enabled`, `updated_at`, and the internal
`user_overridden` flag.

On account deletion, the local analytics preference row is deleted by foreign-key
cascade. Future user-linked Product Usage Events stop when the account
preference is disabled or the account is deleted. Historical analytics may be
retained only in aggregate or de-identified form, subject to the approved privacy
policy and Firebase/analytics project configuration.
Original file line numberDiff line numberDiff line change
Expand Up@@ -17,6 +17,7 @@
import devkor.ontime_back.global.oauth.google.GoogleLoginFilter;
import devkor.ontime_back.repository.UserAlarmSettingRepository;
import devkor.ontime_back.repository.UserRepository;
import devkor.ontime_back.service.AnalyticsPreferenceService;
import jakarta.validation.Validator;
import lombok.RequiredArgsConstructor;
import org.springframework.beans.factory.annotation.Value;
Expand DownExpand Up@@ -58,6 +59,7 @@ public class SecurityConfig {
private final Validator validator;
private final AppleLoginService appleLoginService;
private final GoogleLoginService googleLoginService;
private final AnalyticsPreferenceService analyticsPreferenceService;

@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
Expand All@@ -79,7 +81,7 @@ public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
.requestMatchers("/health").permitAll() // 로드밸런서 연결 확인용 url
.anyRequest().authenticated()
)
.addFilterBefore(new KakaoLoginFilter("/oauth2/kakao/login", objectMapper, validator, jwtTokenProvider, userRepository, userAlarmSettingRepository),
.addFilterBefore(new KakaoLoginFilter("/oauth2/kakao/login", objectMapper, validator, jwtTokenProvider, userRepository, userAlarmSettingRepository, analyticsPreferenceService),
UsernamePasswordAuthenticationFilter.class)
.addFilterBefore(new GoogleLoginFilter("/oauth2/google/login", objectMapper, validator, googleLoginService, userRepository),
UsernamePasswordAuthenticationFilter.class)
Expand Down
Original file line numberDiff line numberDiff line change
Expand Up@@ -281,6 +281,11 @@ private Map<String, NamedExample> requestExamples(String operationKey) {
examples.put("valid_partial_update", json("Valid partial update", "Only provided fields are updated.", "{\"alarmsEnabled\":true,\"defaultAlarmOffsetMinutes\":10}"));
examples.put("invalid_unknown_field", json("Invalid unknown field", "Unknown fields are rejected.", "{\"alarmsEnabled\":\"true\",\"unknown\":1}"));
}
case "PUT /users/me/analytics-preference" -> {
examples.put("enabled", json("Enable analytics", "Allows optional Product Usage Events for the signed-in account.", "{\"enabled\":true}"));
examples.put("disabled", json("Disable analytics", "Stops optional Product Usage Events for the signed-in account.", "{\"enabled\":false}"));
examples.put("invalid_unknown_field", json("Invalid unknown field", "Only enabled is accepted.", "{\"enabled\":\"false\",\"unknown\":1}"));
}
case "PUT /users/me/devices/current" -> {
examples.put("valid_ios_device", json("Valid iOS device", "Registers the current access-token session to the device.", "{\"deviceId\":\"ios-device-000001\",\"platform\":\"ios\",\"appVersion\":\"1.2.3\",\"osVersion\":\"iOS 18.0\",\"supportsNativeAlarm\":true,\"nativeAlarmProvider\":\"iosAlarmKit\",\"fallbackProvider\":\"localNotification\"}"));
examples.put("invalid_device_id", json("Invalid device ID", "deviceId must be 16-128 allowed characters.", "{\"deviceId\":\"short\",\"platform\":\"ios\",\"supportsNativeAlarm\":true,\"nativeAlarmProvider\":\"iosAlarmKit\",\"fallbackProvider\":\"localNotification\"}"));
Expand DownExpand Up@@ -389,6 +394,8 @@ private String successData(String operationKey) {
return switch (operationKey) {
case "GET /users/me/alarm-settings" -> "{\"alarmsEnabled\":true,\"defaultAlarmOffsetMinutes\":10,\"updatedAt\":\"2026-05-05T00:00:00Z\"}";
case "PATCH /users/me/alarm-settings" -> "{\"alarmsEnabled\":false,\"defaultAlarmOffsetMinutes\":5,\"updatedAt\":\"2026-05-05T00:00:00Z\"}";
case "GET /users/me/analytics-preference" -> "{\"enabled\":false,\"updatedAt\":\"2026-05-26T12:00:00Z\"}";
case "PUT /users/me/analytics-preference" -> "{\"enabled\":true,\"updatedAt\":\"2026-05-26T12:00:05Z\"}";
case "PUT /users/me/devices/current" -> "{\"deviceId\":\"ios-device-000001\",\"active\":true,\"lastSeenAt\":\"2026-05-05T00:00:00Z\"}";
case "DELETE /users/me/devices/current" -> "{\"active\":false}";
case "POST /users/me/alarm-status" -> "{\"received\":true}";
Expand Down
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
package devkor.ontime_back.controller;

import devkor.ontime_back.dto.AnalyticsPreferenceResponseDto;
import devkor.ontime_back.dto.AnalyticsPreferenceUpdateDto;
import devkor.ontime_back.response.ApiResponseForm;
import devkor.ontime_back.service.AnalyticsPreferenceService;
import devkor.ontime_back.service.UserAuthService;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.media.Content;
import io.swagger.v3.oas.annotations.media.Schema;
import io.swagger.v3.oas.annotations.responses.ApiResponse;
import io.swagger.v3.oas.annotations.responses.ApiResponses;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PutMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RestController;

@RestController
@RequiredArgsConstructor
public class AnalyticsPreferenceController {

private final UserAuthService userAuthService;
private final AnalyticsPreferenceService analyticsPreferenceService;

@Operation(summary = "Get current user's analytics preference")
@ApiResponses(value = {
@ApiResponse(responseCode = "200", description = "Analytics preference lookup succeeded", content = @Content(
mediaType = "application/json",
schema = @Schema(example = "{\n \"status\": \"success\",\n \"code\": 200,\n \"message\": \"OK\",\n \"data\": {\n \"enabled\": false,\n \"updatedAt\": \"2026-05-26T12:00:00Z\"\n }\n}")
)),
@ApiResponse(responseCode = "4XX", description = "Analytics preference lookup failed", content = @Content(mediaType = "application/json", schema = @Schema(example = "Failure message")))
})
@GetMapping("/users/me/analytics-preference")
public ResponseEntity<ApiResponseForm<AnalyticsPreferenceResponseDto>> getAnalyticsPreference(HttpServletRequest request) {
Long userId = userAuthService.getUserIdFromToken(request);
return ResponseEntity.status(HttpStatus.OK)
.body(ApiResponseForm.success(analyticsPreferenceService.getAnalyticsPreference(userId)));
}

@Operation(
summary = "Update current user's analytics preference",
requestBody = @io.swagger.v3.oas.annotations.parameters.RequestBody(
description = "Account-scoped analytics preference update.",
required = true,
content = @Content(schema = @Schema(
type = "object",
example = "{\"enabled\": false}"
))
)
)
@ApiResponses(value = {
@ApiResponse(responseCode = "200", description = "Analytics preference update succeeded", content = @Content(
mediaType = "application/json",
schema = @Schema(example = "{\n \"status\": \"success\",\n \"code\": 200,\n \"message\": \"OK\",\n \"data\": {\n \"enabled\": false,\n \"updatedAt\": \"2026-05-26T12:00:05Z\"\n }\n}")
)),
@ApiResponse(responseCode = "4XX", description = "Analytics preference update failed", content = @Content(mediaType = "application/json", schema = @Schema(example = "Failure message")))
})
@PutMapping("/users/me/analytics-preference")
public ResponseEntity<ApiResponseForm<AnalyticsPreferenceResponseDto>> updateAnalyticsPreference(
HttpServletRequest request,
@Valid @RequestBody AnalyticsPreferenceUpdateDto requestDto) {
Long userId = userAuthService.getUserIdFromToken(request);
return ResponseEntity.status(HttpStatus.OK)
.body(ApiResponseForm.success(analyticsPreferenceService.updateAnalyticsPreference(userId, requestDto)));
}
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
package devkor.ontime_back.dto;

import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Getter;

import java.time.Instant;

@Getter
@Builder
@AllArgsConstructor
public class AnalyticsPreferenceResponseDto {
private Boolean enabled;
private Instant updatedAt;
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
package devkor.ontime_back.dto;

import com.fasterxml.jackson.annotation.JsonAnySetter;
import jakarta.validation.constraints.AssertTrue;
import lombok.Getter;
import lombok.NoArgsConstructor;

import java.util.HashMap;
import java.util.Map;

@Getter
@NoArgsConstructor
public class AnalyticsPreferenceUpdateDto {
private Object enabled;
private final Map<String, Object> unknownFields = new HashMap<>();

@JsonAnySetter
public void addUnknownField(String name, Object value) {
unknownFields.put(name, value);
}

@AssertTrue(message = "알 수 없는 분석 설정 필드입니다.")
public boolean isKnownFieldsOnly() {
return unknownFields.isEmpty();
}

@AssertTrue(message = "enabled는 필수 값입니다.")
public boolean isEnabledPresent() {
return enabled != null;
}

@AssertTrue(message = "enabled는 boolean 값이어야 합니다.")
public boolean isEnabledBoolean() {
return enabled == null || enabled instanceof Boolean;
}

public Boolean getEnabledValue() {
return enabled instanceof Boolean value ? value : null;
}
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,85 @@
package devkor.ontime_back.entity;

import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.FetchType;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.JoinColumn;
import jakarta.persistence.OneToOne;
import jakarta.persistence.PrePersist;
import jakarta.persistence.Table;
import jakarta.persistence.UniqueConstraint;
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.util.Objects;

@Getter
@Entity
@Builder
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@AllArgsConstructor
@Table(
uniqueConstraints = {
@UniqueConstraint(name = "uk_user_analytics_preference_user", columnNames = "user_id")
}
)
public class UserAnalyticsPreference {

@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long userAnalyticsPreferenceId;

@OneToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "user_id", nullable = false)
@OnDelete(action = OnDeleteAction.CASCADE)
private User user;

@Column(nullable = false)
private Boolean enabled;

@Column(nullable = false)
private Instant updatedAt;

@Column(nullable = false)
private Boolean userOverridden;

public static UserAnalyticsPreference defaultFor(User user, boolean defaultEnabled) {
return UserAnalyticsPreference.builder()
.user(user)
.enabled(defaultEnabled)
.updatedAt(Instant.now())
.userOverridden(false)
.build();
}

@PrePersist
private void initializeDefaults() {
if (enabled == null) enabled = false;
if (updatedAt == null) updatedAt = Instant.now();
if (userOverridden == null) userOverridden = false;
}

public boolean alignToDefault(boolean defaultEnabled) {
if (Boolean.TRUE.equals(userOverridden) || Objects.equals(enabled, defaultEnabled)) {
return false;
}
this.enabled = defaultEnabled;
this.updatedAt = Instant.now();
return true;
}

public void update(boolean enabled) {
this.enabled = enabled;
this.userOverridden = true;
this.updatedAt = Instant.now();
}
}
Original file line numberDiff line numberDiff line change
Expand Up@@ -14,6 +14,7 @@
import devkor.ontime_back.repository.UserAlarmSettingRepository;
import devkor.ontime_back.repository.UserRepository;
import devkor.ontime_back.response.InvalidTokenException;
import devkor.ontime_back.service.AnalyticsPreferenceService;
import io.jsonwebtoken.Claims;
import io.jsonwebtoken.Jwts;
import io.jsonwebtoken.SignatureAlgorithm;
Expand DownExpand Up@@ -71,6 +72,7 @@ public class AppleLoginService {
private final UserRepository userRepository;
private final UserAlarmSettingRepository userAlarmSettingRepository;
private final JwtTokenProvider jwtTokenProvider;
private final AnalyticsPreferenceService analyticsPreferenceService;

private final RestTemplate restTemplate = new RestTemplate();
public Authentication handleLogin(String appleRefreshToken, User user, HttpServletResponse response) throws IOException {
Expand DownExpand Up@@ -141,6 +143,7 @@ public Authentication handleRegister(String appleRefreshToken, OAuthAppleUserDto
savedUser.updateRefreshToken(refreshToken);
userRepository.save(savedUser);
userAlarmSettingRepository.save(UserAlarmSetting.defaultFor(savedUser));
analyticsPreferenceService.createDefaultPreference(savedUser);

Authentication authentication = new UsernamePasswordAuthenticationToken(
savedUser, null, Collections.singletonList(new SimpleGrantedAuthority(savedUser.getRole().name()))
Expand Down
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Auto-enable theater mode on YouTube\n(function() {\n function tryTheater() {\n var btn = document.querySelector('button[aria-label=\"Theater mode\"], ytd-player #player button[title=\"Theater mode\"]');\n if (btn && !btn.classList.contains('activated')) {\n btn.click();\n }\n }\n \n // Try immediately\n tryTheater();\n \n // Try after navigation (SPA)\n var lastUrl = location.href;\n setInterval(function() {\n if (location.href !== lastUrl) {\n lastUrl = location.href;\n setTimeout(tryTheater, 500);\n }\n }, 1000);\n \n // Also try on player load\n var observer = new MutationObserver(tryTheater);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "YouTube Theater Mode Default"); } } catch(__e) { console.warn('[Userscript:YouTube Theater Mode Default]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions docs/account-deletion-api.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -8,6 +8,8 @@ Account deletion hard-deletes the user from OnTime. The request can optionally i

For Google and Apple social accounts, the backend first tries to revoke the social login token, then deletes the local OnTime account. If provider token revocation fails, the backend logs a warning and still deletes the local OnTime account.

Account deletion also deletes account-scoped settings and preferences, including the analytics preference stored in `user_analytics_preference`. Future user-linked Product Usage Events stop after deletion; historical analytics may be retained only in aggregate or de-identified form under the approved privacy policy and analytics provider configuration.

For release/privacy evidence by data category, see `docs/account-deletion-verification-evidence.md`.

## Authentication
Expand Down
1 change: 1 addition & 0 deletions docs/account-deletion-verification-evidence.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -49,6 +49,7 @@ request timestamp, response, owner, and evidence link.
| Access and refresh tokens | `user.access_token`, `user.refresh_token`, `user_device.session_access_token`, `user_device.session_refresh_token` | Deleted in local integration test | N/A after deletion | Account deletion hard-delete and device cascade | Test asserts user and device rows are absent | Backend repo; release env owner TBD |
| Device records and FCM tokens | `user.firebase_token`, `user_device`, `user_device.firebase_token` | Deleted in local integration test | N/A after deletion | Account deletion hard-delete and device cascade | Test asserts user device row is absent | Backend repo; release env owner TBD |
| Alarm settings and alarm status | `user_alarm_setting`, `user_alarm_status` | Deleted in local integration test | N/A after deletion | Foreign-key cascade from user/device | Test asserts alarm setting and status rows are absent | Backend repo; release env owner TBD |
| Analytics preference | `user_analytics_preference` | Deleted in local integration test | N/A after deletion | Foreign-key cascade from user | Test asserts analytics preference row is absent | Backend repo; release env owner TBD |
| Default preparation settings | `preparation_user` | Deleted in local integration test | N/A after deletion | Foreign-key cascade from user | Test asserts preparation user rows are absent | Backend repo; release env owner TBD |
| Schedules | `schedule`, `notification_schedule` | Deleted in local integration test | N/A after deletion | Foreign-key cascade from user and schedule | Test asserts schedule and notification schedule rows are absent | Backend repo; release env owner TBD |
| Schedule preparation steps | `preparation_schedule` | Deleted in local integration test | N/A after deletion | Foreign-key cascade from schedule | Test asserts preparation schedule rows are absent | Backend repo; release env owner TBD |
Expand Down
91 changes: 91 additions & 0 deletions docs/analytics-preference-api.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,91 @@
# Analytics Preference API

Issue: #318

## Summary

The analytics preference API stores whether the signed-in account allows optional
Product Usage Events. The preference is account-scoped, not device-scoped.

This API does not define Firebase event names, frontend instrumentation, local
pre-login preference storage, UI copy, marketing analytics, personalization, or
Firebase Remote Config behavior.

## Default And Release Gate

Backend default is controlled by:

```properties
analytics.preference.default-enabled=${ANALYTICS_PREFERENCE_DEFAULT_ENABLED:false}
```

The default remains `false` until the privacy policy and Google Play Data Safety
updates are approved for the Firebase Analytics release. After approval, deploy
owners may set `ANALYTICS_PREFERENCE_DEFAULT_ENABLED=true`.

Rows created before the default is flipped are marked as not user-overridden.
When the service reads a non-overridden row, it may align that row to the current
deploy default. Once a user explicitly updates the preference, the row is marked
as user-overridden and future default flips do not change that choice.

## Get Analytics Preference

```http
GET /users/me/analytics-preference
Authorization: Bearer <access token>
```

Successful response:

```json
{
"status": "success",
"code": 200,
"message": "OK",
"data": {
"enabled": false,
"updatedAt": "2026-05-26T12:00:00Z"
}
}
```

## Update Analytics Preference

```http
PUT /users/me/analytics-preference
Authorization: Bearer <access token>
Content-Type: application/json

{
"enabled": true
}
```

Successful response:

```json
{
"status": "success",
"code": 200,
"message": "OK",
"data": {
"enabled": true,
"updatedAt": "2026-05-26T12:00:05Z"
}
}
```

`enabled` is required and must be a JSON boolean. Missing, null, non-boolean, or
unknown fields are rejected with the existing validation-style `400` response.

## Persistence And Deletion

The preference is stored in `user_analytics_preference` with a unique foreign key
to `user(user_id)`, `enabled`, `updated_at`, and the internal
`user_overridden` flag.

On account deletion, the local analytics preference row is deleted by foreign-key
cascade. Future user-linked Product Usage Events stop when the account
preference is disabled or the account is deleted. Historical analytics may be
retained only in aggregate or de-identified form, subject to the approved privacy
policy and Firebase/analytics project configuration.
Original file line numberDiff line numberDiff line change
Expand Up@@ -17,6 +17,7 @@
import devkor.ontime_back.global.oauth.google.GoogleLoginFilter;
import devkor.ontime_back.repository.UserAlarmSettingRepository;
import devkor.ontime_back.repository.UserRepository;
import devkor.ontime_back.service.AnalyticsPreferenceService;
import jakarta.validation.Validator;
import lombok.RequiredArgsConstructor;
import org.springframework.beans.factory.annotation.Value;
Expand DownExpand Up@@ -58,6 +59,7 @@ public class SecurityConfig {
private final Validator validator;
private final AppleLoginService appleLoginService;
private final GoogleLoginService googleLoginService;
private final AnalyticsPreferenceService analyticsPreferenceService;

@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
Expand All@@ -79,7 +81,7 @@ public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
.requestMatchers("/health").permitAll() // 로드밸런서 연결 확인용 url
.anyRequest().authenticated()
)
.addFilterBefore(new KakaoLoginFilter("/oauth2/kakao/login", objectMapper, validator, jwtTokenProvider, userRepository, userAlarmSettingRepository),
.addFilterBefore(new KakaoLoginFilter("/oauth2/kakao/login", objectMapper, validator, jwtTokenProvider, userRepository, userAlarmSettingRepository, analyticsPreferenceService),
UsernamePasswordAuthenticationFilter.class)
.addFilterBefore(new GoogleLoginFilter("/oauth2/google/login", objectMapper, validator, googleLoginService, userRepository),
UsernamePasswordAuthenticationFilter.class)
Expand Down
Original file line numberDiff line numberDiff line change
Expand Up@@ -281,6 +281,11 @@ private Map<String, NamedExample> requestExamples(String operationKey) {
examples.put("valid_partial_update", json("Valid partial update", "Only provided fields are updated.", "{\"alarmsEnabled\":true,\"defaultAlarmOffsetMinutes\":10}"));
examples.put("invalid_unknown_field", json("Invalid unknown field", "Unknown fields are rejected.", "{\"alarmsEnabled\":\"true\",\"unknown\":1}"));
}
case "PUT /users/me/analytics-preference" -> {
examples.put("enabled", json("Enable analytics", "Allows optional Product Usage Events for the signed-in account.", "{\"enabled\":true}"));
examples.put("disabled", json("Disable analytics", "Stops optional Product Usage Events for the signed-in account.", "{\"enabled\":false}"));
examples.put("invalid_unknown_field", json("Invalid unknown field", "Only enabled is accepted.", "{\"enabled\":\"false\",\"unknown\":1}"));
}
case "PUT /users/me/devices/current" -> {
examples.put("valid_ios_device", json("Valid iOS device", "Registers the current access-token session to the device.", "{\"deviceId\":\"ios-device-000001\",\"platform\":\"ios\",\"appVersion\":\"1.2.3\",\"osVersion\":\"iOS 18.0\",\"supportsNativeAlarm\":true,\"nativeAlarmProvider\":\"iosAlarmKit\",\"fallbackProvider\":\"localNotification\"}"));
examples.put("invalid_device_id", json("Invalid device ID", "deviceId must be 16-128 allowed characters.", "{\"deviceId\":\"short\",\"platform\":\"ios\",\"supportsNativeAlarm\":true,\"nativeAlarmProvider\":\"iosAlarmKit\",\"fallbackProvider\":\"localNotification\"}"));
Expand DownExpand Up@@ -389,6 +394,8 @@ private String successData(String operationKey) {
return switch (operationKey) {
case "GET /users/me/alarm-settings" -> "{\"alarmsEnabled\":true,\"defaultAlarmOffsetMinutes\":10,\"updatedAt\":\"2026-05-05T00:00:00Z\"}";
case "PATCH /users/me/alarm-settings" -> "{\"alarmsEnabled\":false,\"defaultAlarmOffsetMinutes\":5,\"updatedAt\":\"2026-05-05T00:00:00Z\"}";
case "GET /users/me/analytics-preference" -> "{\"enabled\":false,\"updatedAt\":\"2026-05-26T12:00:00Z\"}";
case "PUT /users/me/analytics-preference" -> "{\"enabled\":true,\"updatedAt\":\"2026-05-26T12:00:05Z\"}";
case "PUT /users/me/devices/current" -> "{\"deviceId\":\"ios-device-000001\",\"active\":true,\"lastSeenAt\":\"2026-05-05T00:00:00Z\"}";
case "DELETE /users/me/devices/current" -> "{\"active\":false}";
case "POST /users/me/alarm-status" -> "{\"received\":true}";
Expand Down
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
package devkor.ontime_back.controller;

import devkor.ontime_back.dto.AnalyticsPreferenceResponseDto;
import devkor.ontime_back.dto.AnalyticsPreferenceUpdateDto;
import devkor.ontime_back.response.ApiResponseForm;
import devkor.ontime_back.service.AnalyticsPreferenceService;
import devkor.ontime_back.service.UserAuthService;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.media.Content;
import io.swagger.v3.oas.annotations.media.Schema;
import io.swagger.v3.oas.annotations.responses.ApiResponse;
import io.swagger.v3.oas.annotations.responses.ApiResponses;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PutMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RestController;

@RestController
@RequiredArgsConstructor
public class AnalyticsPreferenceController {

private final UserAuthService userAuthService;
private final AnalyticsPreferenceService analyticsPreferenceService;

@Operation(summary = "Get current user's analytics preference")
@ApiResponses(value = {
@ApiResponse(responseCode = "200", description = "Analytics preference lookup succeeded", content = @Content(
mediaType = "application/json",
schema = @Schema(example = "{\n \"status\": \"success\",\n \"code\": 200,\n \"message\": \"OK\",\n \"data\": {\n \"enabled\": false,\n \"updatedAt\": \"2026-05-26T12:00:00Z\"\n }\n}")
)),
@ApiResponse(responseCode = "4XX", description = "Analytics preference lookup failed", content = @Content(mediaType = "application/json", schema = @Schema(example = "Failure message")))
})
@GetMapping("/users/me/analytics-preference")
public ResponseEntity<ApiResponseForm<AnalyticsPreferenceResponseDto>> getAnalyticsPreference(HttpServletRequest request) {
Long userId = userAuthService.getUserIdFromToken(request);
return ResponseEntity.status(HttpStatus.OK)
.body(ApiResponseForm.success(analyticsPreferenceService.getAnalyticsPreference(userId)));
}

@Operation(
summary = "Update current user's analytics preference",
requestBody = @io.swagger.v3.oas.annotations.parameters.RequestBody(
description = "Account-scoped analytics preference update.",
required = true,
content = @Content(schema = @Schema(
type = "object",
example = "{\"enabled\": false}"
))
)
)
@ApiResponses(value = {
@ApiResponse(responseCode = "200", description = "Analytics preference update succeeded", content = @Content(
mediaType = "application/json",
schema = @Schema(example = "{\n \"status\": \"success\",\n \"code\": 200,\n \"message\": \"OK\",\n \"data\": {\n \"enabled\": false,\n \"updatedAt\": \"2026-05-26T12:00:05Z\"\n }\n}")
)),
@ApiResponse(responseCode = "4XX", description = "Analytics preference update failed", content = @Content(mediaType = "application/json", schema = @Schema(example = "Failure message")))
})
@PutMapping("/users/me/analytics-preference")
public ResponseEntity<ApiResponseForm<AnalyticsPreferenceResponseDto>> updateAnalyticsPreference(
HttpServletRequest request,
@Valid @RequestBody AnalyticsPreferenceUpdateDto requestDto) {
Long userId = userAuthService.getUserIdFromToken(request);
return ResponseEntity.status(HttpStatus.OK)
.body(ApiResponseForm.success(analyticsPreferenceService.updateAnalyticsPreference(userId, requestDto)));
}
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
package devkor.ontime_back.dto;

import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Getter;

import java.time.Instant;

@Getter
@Builder
@AllArgsConstructor
public class AnalyticsPreferenceResponseDto {
private Boolean enabled;
private Instant updatedAt;
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
package devkor.ontime_back.dto;

import com.fasterxml.jackson.annotation.JsonAnySetter;
import jakarta.validation.constraints.AssertTrue;
import lombok.Getter;
import lombok.NoArgsConstructor;

import java.util.HashMap;
import java.util.Map;

@Getter
@NoArgsConstructor
public class AnalyticsPreferenceUpdateDto {
private Object enabled;
private final Map<String, Object> unknownFields = new HashMap<>();

@JsonAnySetter
public void addUnknownField(String name, Object value) {
unknownFields.put(name, value);
}

@AssertTrue(message = "알 수 없는 분석 설정 필드입니다.")
public boolean isKnownFieldsOnly() {
return unknownFields.isEmpty();
}

@AssertTrue(message = "enabled는 필수 값입니다.")
public boolean isEnabledPresent() {
return enabled != null;
}

@AssertTrue(message = "enabled는 boolean 값이어야 합니다.")
public boolean isEnabledBoolean() {
return enabled == null || enabled instanceof Boolean;
}

public Boolean getEnabledValue() {
return enabled instanceof Boolean value ? value : null;
}
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,85 @@
package devkor.ontime_back.entity;

import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.FetchType;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.JoinColumn;
import jakarta.persistence.OneToOne;
import jakarta.persistence.PrePersist;
import jakarta.persistence.Table;
import jakarta.persistence.UniqueConstraint;
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.util.Objects;

@Getter
@Entity
@Builder
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@AllArgsConstructor
@Table(
uniqueConstraints = {
@UniqueConstraint(name = "uk_user_analytics_preference_user", columnNames = "user_id")
}
)
public class UserAnalyticsPreference {

@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long userAnalyticsPreferenceId;

@OneToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "user_id", nullable = false)
@OnDelete(action = OnDeleteAction.CASCADE)
private User user;

@Column(nullable = false)
private Boolean enabled;

@Column(nullable = false)
private Instant updatedAt;

@Column(nullable = false)
private Boolean userOverridden;

public static UserAnalyticsPreference defaultFor(User user, boolean defaultEnabled) {
return UserAnalyticsPreference.builder()
.user(user)
.enabled(defaultEnabled)
.updatedAt(Instant.now())
.userOverridden(false)
.build();
}

@PrePersist
private void initializeDefaults() {
if (enabled == null) enabled = false;
if (updatedAt == null) updatedAt = Instant.now();
if (userOverridden == null) userOverridden = false;
}

public boolean alignToDefault(boolean defaultEnabled) {
if (Boolean.TRUE.equals(userOverridden) || Objects.equals(enabled, defaultEnabled)) {
return false;
}
this.enabled = defaultEnabled;
this.updatedAt = Instant.now();
return true;
}

public void update(boolean enabled) {
this.enabled = enabled;
this.userOverridden = true;
this.updatedAt = Instant.now();
}
}
Original file line numberDiff line numberDiff line change
Expand Up@@ -14,6 +14,7 @@
import devkor.ontime_back.repository.UserAlarmSettingRepository;
import devkor.ontime_back.repository.UserRepository;
import devkor.ontime_back.response.InvalidTokenException;
import devkor.ontime_back.service.AnalyticsPreferenceService;
import io.jsonwebtoken.Claims;
import io.jsonwebtoken.Jwts;
import io.jsonwebtoken.SignatureAlgorithm;
Expand DownExpand Up@@ -71,6 +72,7 @@ public class AppleLoginService {
private final UserRepository userRepository;
private final UserAlarmSettingRepository userAlarmSettingRepository;
private final JwtTokenProvider jwtTokenProvider;
private final AnalyticsPreferenceService analyticsPreferenceService;

private final RestTemplate restTemplate = new RestTemplate();
public Authentication handleLogin(String appleRefreshToken, User user, HttpServletResponse response) throws IOException {
Expand DownExpand Up@@ -141,6 +143,7 @@ public Authentication handleRegister(String appleRefreshToken, OAuthAppleUserDto
savedUser.updateRefreshToken(refreshToken);
userRepository.save(savedUser);
userAlarmSettingRepository.save(UserAlarmSetting.defaultFor(savedUser));
analyticsPreferenceService.createDefaultPreference(savedUser);

Authentication authentication = new UsernamePasswordAuthenticationToken(
savedUser, null, Collections.singletonList(new SimpleGrantedAuthority(savedUser.getRole().name()))
Expand Down
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Remove or un-stick sticky/fixed headers that block content\n(function() {\n function unstick() {\n document.querySelectorAll('header, nav, [role=\"banner\"], .header, .navbar, .sticky, .fixed-top, [style*=\"position: fixed\"], [style*=\"position:sticky\"]').forEach(function(el) {\n if (el.style.position === 'fixed' || el.style.position === 'sticky' || \n getComputedStyle(el).position === 'fixed' || getComputedStyle(el).position === 'sticky') {\n el.style.position = 'static';\n el.style.top = 'auto';\n el.style.zIndex = 'auto';\n }\n });\n }\n \n unstick();\n \n var observer = new MutationObserver(unstick);\n observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['style', 'class'] });\n})();", "Kill Sticky Headers"); } } catch(__e) { console.warn('[Userscript:Kill Sticky Headers]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions docs/account-deletion-api.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -8,6 +8,8 @@ Account deletion hard-deletes the user from OnTime. The request can optionally i

For Google and Apple social accounts, the backend first tries to revoke the social login token, then deletes the local OnTime account. If provider token revocation fails, the backend logs a warning and still deletes the local OnTime account.

Account deletion also deletes account-scoped settings and preferences, including the analytics preference stored in `user_analytics_preference`. Future user-linked Product Usage Events stop after deletion; historical analytics may be retained only in aggregate or de-identified form under the approved privacy policy and analytics provider configuration.

For release/privacy evidence by data category, see `docs/account-deletion-verification-evidence.md`.

## Authentication
Expand Down
1 change: 1 addition & 0 deletions docs/account-deletion-verification-evidence.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -49,6 +49,7 @@ request timestamp, response, owner, and evidence link.
| Access and refresh tokens | `user.access_token`, `user.refresh_token`, `user_device.session_access_token`, `user_device.session_refresh_token` | Deleted in local integration test | N/A after deletion | Account deletion hard-delete and device cascade | Test asserts user and device rows are absent | Backend repo; release env owner TBD |
| Device records and FCM tokens | `user.firebase_token`, `user_device`, `user_device.firebase_token` | Deleted in local integration test | N/A after deletion | Account deletion hard-delete and device cascade | Test asserts user device row is absent | Backend repo; release env owner TBD |
| Alarm settings and alarm status | `user_alarm_setting`, `user_alarm_status` | Deleted in local integration test | N/A after deletion | Foreign-key cascade from user/device | Test asserts alarm setting and status rows are absent | Backend repo; release env owner TBD |
| Analytics preference | `user_analytics_preference` | Deleted in local integration test | N/A after deletion | Foreign-key cascade from user | Test asserts analytics preference row is absent | Backend repo; release env owner TBD |
| Default preparation settings | `preparation_user` | Deleted in local integration test | N/A after deletion | Foreign-key cascade from user | Test asserts preparation user rows are absent | Backend repo; release env owner TBD |
| Schedules | `schedule`, `notification_schedule` | Deleted in local integration test | N/A after deletion | Foreign-key cascade from user and schedule | Test asserts schedule and notification schedule rows are absent | Backend repo; release env owner TBD |
| Schedule preparation steps | `preparation_schedule` | Deleted in local integration test | N/A after deletion | Foreign-key cascade from schedule | Test asserts preparation schedule rows are absent | Backend repo; release env owner TBD |
Expand Down
91 changes: 91 additions & 0 deletions docs/analytics-preference-api.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,91 @@
# Analytics Preference API

Issue: #318

## Summary

The analytics preference API stores whether the signed-in account allows optional
Product Usage Events. The preference is account-scoped, not device-scoped.

This API does not define Firebase event names, frontend instrumentation, local
pre-login preference storage, UI copy, marketing analytics, personalization, or
Firebase Remote Config behavior.

## Default And Release Gate

Backend default is controlled by:

```properties
analytics.preference.default-enabled=${ANALYTICS_PREFERENCE_DEFAULT_ENABLED:false}
```

The default remains `false` until the privacy policy and Google Play Data Safety
updates are approved for the Firebase Analytics release. After approval, deploy
owners may set `ANALYTICS_PREFERENCE_DEFAULT_ENABLED=true`.

Rows created before the default is flipped are marked as not user-overridden.
When the service reads a non-overridden row, it may align that row to the current
deploy default. Once a user explicitly updates the preference, the row is marked
as user-overridden and future default flips do not change that choice.

## Get Analytics Preference

```http
GET /users/me/analytics-preference
Authorization: Bearer <access token>
```

Successful response:

```json
{
"status": "success",
"code": 200,
"message": "OK",
"data": {
"enabled": false,
"updatedAt": "2026-05-26T12:00:00Z"
}
}
```

## Update Analytics Preference

```http
PUT /users/me/analytics-preference
Authorization: Bearer <access token>
Content-Type: application/json

{
"enabled": true
}
```

Successful response:

```json
{
"status": "success",
"code": 200,
"message": "OK",
"data": {
"enabled": true,
"updatedAt": "2026-05-26T12:00:05Z"
}
}
```

`enabled` is required and must be a JSON boolean. Missing, null, non-boolean, or
unknown fields are rejected with the existing validation-style `400` response.

## Persistence And Deletion

The preference is stored in `user_analytics_preference` with a unique foreign key
to `user(user_id)`, `enabled`, `updated_at`, and the internal
`user_overridden` flag.

On account deletion, the local analytics preference row is deleted by foreign-key
cascade. Future user-linked Product Usage Events stop when the account
preference is disabled or the account is deleted. Historical analytics may be
retained only in aggregate or de-identified form, subject to the approved privacy
policy and Firebase/analytics project configuration.
Original file line numberDiff line numberDiff line change
Expand Up@@ -17,6 +17,7 @@
import devkor.ontime_back.global.oauth.google.GoogleLoginFilter;
import devkor.ontime_back.repository.UserAlarmSettingRepository;
import devkor.ontime_back.repository.UserRepository;
import devkor.ontime_back.service.AnalyticsPreferenceService;
import jakarta.validation.Validator;
import lombok.RequiredArgsConstructor;
import org.springframework.beans.factory.annotation.Value;
Expand DownExpand Up@@ -58,6 +59,7 @@ public class SecurityConfig {
private final Validator validator;
private final AppleLoginService appleLoginService;
private final GoogleLoginService googleLoginService;
private final AnalyticsPreferenceService analyticsPreferenceService;

@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
Expand All@@ -79,7 +81,7 @@ public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
.requestMatchers("/health").permitAll() // 로드밸런서 연결 확인용 url
.anyRequest().authenticated()
)
.addFilterBefore(new KakaoLoginFilter("/oauth2/kakao/login", objectMapper, validator, jwtTokenProvider, userRepository, userAlarmSettingRepository),
.addFilterBefore(new KakaoLoginFilter("/oauth2/kakao/login", objectMapper, validator, jwtTokenProvider, userRepository, userAlarmSettingRepository, analyticsPreferenceService),
UsernamePasswordAuthenticationFilter.class)
.addFilterBefore(new GoogleLoginFilter("/oauth2/google/login", objectMapper, validator, googleLoginService, userRepository),
UsernamePasswordAuthenticationFilter.class)
Expand Down
Original file line numberDiff line numberDiff line change
Expand Up@@ -281,6 +281,11 @@ private Map<String, NamedExample> requestExamples(String operationKey) {
examples.put("valid_partial_update", json("Valid partial update", "Only provided fields are updated.", "{\"alarmsEnabled\":true,\"defaultAlarmOffsetMinutes\":10}"));
examples.put("invalid_unknown_field", json("Invalid unknown field", "Unknown fields are rejected.", "{\"alarmsEnabled\":\"true\",\"unknown\":1}"));
}
case "PUT /users/me/analytics-preference" -> {
examples.put("enabled", json("Enable analytics", "Allows optional Product Usage Events for the signed-in account.", "{\"enabled\":true}"));
examples.put("disabled", json("Disable analytics", "Stops optional Product Usage Events for the signed-in account.", "{\"enabled\":false}"));
examples.put("invalid_unknown_field", json("Invalid unknown field", "Only enabled is accepted.", "{\"enabled\":\"false\",\"unknown\":1}"));
}
case "PUT /users/me/devices/current" -> {
examples.put("valid_ios_device", json("Valid iOS device", "Registers the current access-token session to the device.", "{\"deviceId\":\"ios-device-000001\",\"platform\":\"ios\",\"appVersion\":\"1.2.3\",\"osVersion\":\"iOS 18.0\",\"supportsNativeAlarm\":true,\"nativeAlarmProvider\":\"iosAlarmKit\",\"fallbackProvider\":\"localNotification\"}"));
examples.put("invalid_device_id", json("Invalid device ID", "deviceId must be 16-128 allowed characters.", "{\"deviceId\":\"short\",\"platform\":\"ios\",\"supportsNativeAlarm\":true,\"nativeAlarmProvider\":\"iosAlarmKit\",\"fallbackProvider\":\"localNotification\"}"));
Expand DownExpand Up@@ -389,6 +394,8 @@ private String successData(String operationKey) {
return switch (operationKey) {
case "GET /users/me/alarm-settings" -> "{\"alarmsEnabled\":true,\"defaultAlarmOffsetMinutes\":10,\"updatedAt\":\"2026-05-05T00:00:00Z\"}";
case "PATCH /users/me/alarm-settings" -> "{\"alarmsEnabled\":false,\"defaultAlarmOffsetMinutes\":5,\"updatedAt\":\"2026-05-05T00:00:00Z\"}";
case "GET /users/me/analytics-preference" -> "{\"enabled\":false,\"updatedAt\":\"2026-05-26T12:00:00Z\"}";
case "PUT /users/me/analytics-preference" -> "{\"enabled\":true,\"updatedAt\":\"2026-05-26T12:00:05Z\"}";
case "PUT /users/me/devices/current" -> "{\"deviceId\":\"ios-device-000001\",\"active\":true,\"lastSeenAt\":\"2026-05-05T00:00:00Z\"}";
case "DELETE /users/me/devices/current" -> "{\"active\":false}";
case "POST /users/me/alarm-status" -> "{\"received\":true}";
Expand Down
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
package devkor.ontime_back.controller;

import devkor.ontime_back.dto.AnalyticsPreferenceResponseDto;
import devkor.ontime_back.dto.AnalyticsPreferenceUpdateDto;
import devkor.ontime_back.response.ApiResponseForm;
import devkor.ontime_back.service.AnalyticsPreferenceService;
import devkor.ontime_back.service.UserAuthService;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.media.Content;
import io.swagger.v3.oas.annotations.media.Schema;
import io.swagger.v3.oas.annotations.responses.ApiResponse;
import io.swagger.v3.oas.annotations.responses.ApiResponses;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PutMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RestController;

@RestController
@RequiredArgsConstructor
public class AnalyticsPreferenceController {

private final UserAuthService userAuthService;
private final AnalyticsPreferenceService analyticsPreferenceService;

@Operation(summary = "Get current user's analytics preference")
@ApiResponses(value = {
@ApiResponse(responseCode = "200", description = "Analytics preference lookup succeeded", content = @Content(
mediaType = "application/json",
schema = @Schema(example = "{\n \"status\": \"success\",\n \"code\": 200,\n \"message\": \"OK\",\n \"data\": {\n \"enabled\": false,\n \"updatedAt\": \"2026-05-26T12:00:00Z\"\n }\n}")
)),
@ApiResponse(responseCode = "4XX", description = "Analytics preference lookup failed", content = @Content(mediaType = "application/json", schema = @Schema(example = "Failure message")))
})
@GetMapping("/users/me/analytics-preference")
public ResponseEntity<ApiResponseForm<AnalyticsPreferenceResponseDto>> getAnalyticsPreference(HttpServletRequest request) {
Long userId = userAuthService.getUserIdFromToken(request);
return ResponseEntity.status(HttpStatus.OK)
.body(ApiResponseForm.success(analyticsPreferenceService.getAnalyticsPreference(userId)));
}

@Operation(
summary = "Update current user's analytics preference",
requestBody = @io.swagger.v3.oas.annotations.parameters.RequestBody(
description = "Account-scoped analytics preference update.",
required = true,
content = @Content(schema = @Schema(
type = "object",
example = "{\"enabled\": false}"
))
)
)
@ApiResponses(value = {
@ApiResponse(responseCode = "200", description = "Analytics preference update succeeded", content = @Content(
mediaType = "application/json",
schema = @Schema(example = "{\n \"status\": \"success\",\n \"code\": 200,\n \"message\": \"OK\",\n \"data\": {\n \"enabled\": false,\n \"updatedAt\": \"2026-05-26T12:00:05Z\"\n }\n}")
)),
@ApiResponse(responseCode = "4XX", description = "Analytics preference update failed", content = @Content(mediaType = "application/json", schema = @Schema(example = "Failure message")))
})
@PutMapping("/users/me/analytics-preference")
public ResponseEntity<ApiResponseForm<AnalyticsPreferenceResponseDto>> updateAnalyticsPreference(
HttpServletRequest request,
@Valid @RequestBody AnalyticsPreferenceUpdateDto requestDto) {
Long userId = userAuthService.getUserIdFromToken(request);
return ResponseEntity.status(HttpStatus.OK)
.body(ApiResponseForm.success(analyticsPreferenceService.updateAnalyticsPreference(userId, requestDto)));
}
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
package devkor.ontime_back.dto;

import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Getter;

import java.time.Instant;

@Getter
@Builder
@AllArgsConstructor
public class AnalyticsPreferenceResponseDto {
private Boolean enabled;
private Instant updatedAt;
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
package devkor.ontime_back.dto;

import com.fasterxml.jackson.annotation.JsonAnySetter;
import jakarta.validation.constraints.AssertTrue;
import lombok.Getter;
import lombok.NoArgsConstructor;

import java.util.HashMap;
import java.util.Map;

@Getter
@NoArgsConstructor
public class AnalyticsPreferenceUpdateDto {
private Object enabled;
private final Map<String, Object> unknownFields = new HashMap<>();

@JsonAnySetter
public void addUnknownField(String name, Object value) {
unknownFields.put(name, value);
}

@AssertTrue(message = "알 수 없는 분석 설정 필드입니다.")
public boolean isKnownFieldsOnly() {
return unknownFields.isEmpty();
}

@AssertTrue(message = "enabled는 필수 값입니다.")
public boolean isEnabledPresent() {
return enabled != null;
}

@AssertTrue(message = "enabled는 boolean 값이어야 합니다.")
public boolean isEnabledBoolean() {
return enabled == null || enabled instanceof Boolean;
}

public Boolean getEnabledValue() {
return enabled instanceof Boolean value ? value : null;
}
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,85 @@
package devkor.ontime_back.entity;

import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.FetchType;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.JoinColumn;
import jakarta.persistence.OneToOne;
import jakarta.persistence.PrePersist;
import jakarta.persistence.Table;
import jakarta.persistence.UniqueConstraint;
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.util.Objects;

@Getter
@Entity
@Builder
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@AllArgsConstructor
@Table(
uniqueConstraints = {
@UniqueConstraint(name = "uk_user_analytics_preference_user", columnNames = "user_id")
}
)
public class UserAnalyticsPreference {

@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long userAnalyticsPreferenceId;

@OneToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "user_id", nullable = false)
@OnDelete(action = OnDeleteAction.CASCADE)
private User user;

@Column(nullable = false)
private Boolean enabled;

@Column(nullable = false)
private Instant updatedAt;

@Column(nullable = false)
private Boolean userOverridden;

public static UserAnalyticsPreference defaultFor(User user, boolean defaultEnabled) {
return UserAnalyticsPreference.builder()
.user(user)
.enabled(defaultEnabled)
.updatedAt(Instant.now())
.userOverridden(false)
.build();
}

@PrePersist
private void initializeDefaults() {
if (enabled == null) enabled = false;
if (updatedAt == null) updatedAt = Instant.now();
if (userOverridden == null) userOverridden = false;
}

public boolean alignToDefault(boolean defaultEnabled) {
if (Boolean.TRUE.equals(userOverridden) || Objects.equals(enabled, defaultEnabled)) {
return false;
}
this.enabled = defaultEnabled;
this.updatedAt = Instant.now();
return true;
}

public void update(boolean enabled) {
this.enabled = enabled;
this.userOverridden = true;
this.updatedAt = Instant.now();
}
}
Original file line numberDiff line numberDiff line change
Expand Up@@ -14,6 +14,7 @@
import devkor.ontime_back.repository.UserAlarmSettingRepository;
import devkor.ontime_back.repository.UserRepository;
import devkor.ontime_back.response.InvalidTokenException;
import devkor.ontime_back.service.AnalyticsPreferenceService;
import io.jsonwebtoken.Claims;
import io.jsonwebtoken.Jwts;
import io.jsonwebtoken.SignatureAlgorithm;
Expand DownExpand Up@@ -71,6 +72,7 @@ public class AppleLoginService {
private final UserRepository userRepository;
private final UserAlarmSettingRepository userAlarmSettingRepository;
private final JwtTokenProvider jwtTokenProvider;
private final AnalyticsPreferenceService analyticsPreferenceService;

private final RestTemplate restTemplate = new RestTemplate();
public Authentication handleLogin(String appleRefreshToken, User user, HttpServletResponse response) throws IOException {
Expand DownExpand Up@@ -141,6 +143,7 @@ public Authentication handleRegister(String appleRefreshToken, OAuthAppleUserDto
savedUser.updateRefreshToken(refreshToken);
userRepository.save(savedUser);
userAlarmSettingRepository.save(UserAlarmSetting.defaultFor(savedUser));
analyticsPreferenceService.createDefaultPreference(savedUser);

Authentication authentication = new UsernamePasswordAuthenticationToken(
savedUser, null, Collections.singletonList(new SimpleGrantedAuthority(savedUser.getRole().name()))
Expand Down
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Universal Dark Mode - works on any site\n(function() {\n var enabled = true;\n \n function applyDarkMode() {\n if (!enabled) return;\n \n // Create style element if it doesn't exist\n var style = document.getElementById('universal-dark-mode-style');\n if (!style) {\n style = document.createElement('style');\n style.id = 'universal-dark-mode-style';\n document.head.appendChild(style);\n }\n \n // Dark mode CSS - inverts colors but preserves images/video\n style.textContent = '\n /* Invert everything except media */\n html {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #1a1a2e !important;\n }\n \n /* Restore images, videos, iframes, canvas */\n img, video, iframe, canvas, svg, picture, [style*=\"background-image\"] {\n filter: invert(1) hue-rotate(180deg) !important;\n }\n \n /* Preserve specific elements that should not be inverted */\n .no-dark-mode, .no-dark-mode *,\n [data-theme=\"light\"], [data-theme=\"light\"],\n .ace_editor, .ace_editor *,\n .CodeMirror, .CodeMirror *,\n .monaco-editor, .monaco-editor *,\n .markdown-body pre, .markdown-body pre *,\n .highlight, .highlight *,\n pre code, pre code * {\n filter: none !important;\n }\n \n /* Fix common UI elements */\n .modal, .popup, .dropdown-menu, .tooltip, .popover {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #2d2d44 !important;\n border-color: #444 !important;\n }\n \n /* Scrollbars */\n ::-webkit-scrollbar { background: #1a1a2e !important; }\n ::-webkit-scrollbar-thumb { background: #444 !important; }\n ::-webkit-scrollbar-thumb:hover { background: #555 !important; }\n \n /* Selection */\n ::selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ::-moz-selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ';\n }\n \n function removeDarkMode() {\n var style = document.getElementById('universal-dark-mode-style');\n if (style) style.remove();\n }\n \n // Toggle with Alt+Shift+D\n document.addEventListener('keydown', function(e) {\n if (e.altKey && e.shiftKey && e.key === 'D') {\n e.preventDefault();\n enabled = !enabled;\n if (enabled) {\n applyDarkMode();\n console.log('[Universal Dark Mode] Enabled');\n } else {\n removeDarkMode();\n console.log('[Universal Dark Mode] Disabled');\n }\n }\n });\n \n // Apply on load\n applyDarkMode();\n \n // Re-apply on dynamic content\n var observer = new MutationObserver(function(mutations) {\n if (enabled && !document.getElementById('universal-dark-mode-style')) {\n applyDarkMode();\n }\n });\n observer.observe(document.head, { childList: true });\n \n console.log('[Universal Dark Mode] Loaded - Press Alt+Shift+D to toggle');\n})();", "Universal Dark Mode"); } } catch(__e) { console.warn('[Userscript:Universal Dark Mode]', __e); } })(); })();
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions docs/account-deletion-api.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -8,6 +8,8 @@ Account deletion hard-deletes the user from OnTime. The request can optionally i

For Google and Apple social accounts, the backend first tries to revoke the social login token, then deletes the local OnTime account. If provider token revocation fails, the backend logs a warning and still deletes the local OnTime account.

Account deletion also deletes account-scoped settings and preferences, including the analytics preference stored in `user_analytics_preference`. Future user-linked Product Usage Events stop after deletion; historical analytics may be retained only in aggregate or de-identified form under the approved privacy policy and analytics provider configuration.

For release/privacy evidence by data category, see `docs/account-deletion-verification-evidence.md`.

## Authentication
Expand Down
1 change: 1 addition & 0 deletions docs/account-deletion-verification-evidence.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -49,6 +49,7 @@ request timestamp, response, owner, and evidence link.
| Access and refresh tokens | `user.access_token`, `user.refresh_token`, `user_device.session_access_token`, `user_device.session_refresh_token` | Deleted in local integration test | N/A after deletion | Account deletion hard-delete and device cascade | Test asserts user and device rows are absent | Backend repo; release env owner TBD |
| Device records and FCM tokens | `user.firebase_token`, `user_device`, `user_device.firebase_token` | Deleted in local integration test | N/A after deletion | Account deletion hard-delete and device cascade | Test asserts user device row is absent | Backend repo; release env owner TBD |
| Alarm settings and alarm status | `user_alarm_setting`, `user_alarm_status` | Deleted in local integration test | N/A after deletion | Foreign-key cascade from user/device | Test asserts alarm setting and status rows are absent | Backend repo; release env owner TBD |
| Analytics preference | `user_analytics_preference` | Deleted in local integration test | N/A after deletion | Foreign-key cascade from user | Test asserts analytics preference row is absent | Backend repo; release env owner TBD |
| Default preparation settings | `preparation_user` | Deleted in local integration test | N/A after deletion | Foreign-key cascade from user | Test asserts preparation user rows are absent | Backend repo; release env owner TBD |
| Schedules | `schedule`, `notification_schedule` | Deleted in local integration test | N/A after deletion | Foreign-key cascade from user and schedule | Test asserts schedule and notification schedule rows are absent | Backend repo; release env owner TBD |
| Schedule preparation steps | `preparation_schedule` | Deleted in local integration test | N/A after deletion | Foreign-key cascade from schedule | Test asserts preparation schedule rows are absent | Backend repo; release env owner TBD |
Expand Down
91 changes: 91 additions & 0 deletions docs/analytics-preference-api.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,91 @@
# Analytics Preference API

Issue: #318

## Summary

The analytics preference API stores whether the signed-in account allows optional
Product Usage Events. The preference is account-scoped, not device-scoped.

This API does not define Firebase event names, frontend instrumentation, local
pre-login preference storage, UI copy, marketing analytics, personalization, or
Firebase Remote Config behavior.

## Default And Release Gate

Backend default is controlled by:

```properties
analytics.preference.default-enabled=${ANALYTICS_PREFERENCE_DEFAULT_ENABLED:false}
```

The default remains `false` until the privacy policy and Google Play Data Safety
updates are approved for the Firebase Analytics release. After approval, deploy
owners may set `ANALYTICS_PREFERENCE_DEFAULT_ENABLED=true`.

Rows created before the default is flipped are marked as not user-overridden.
When the service reads a non-overridden row, it may align that row to the current
deploy default. Once a user explicitly updates the preference, the row is marked
as user-overridden and future default flips do not change that choice.

## Get Analytics Preference

```http
GET /users/me/analytics-preference
Authorization: Bearer <access token>
```

Successful response:

```json
{
"status": "success",
"code": 200,
"message": "OK",
"data": {
"enabled": false,
"updatedAt": "2026-05-26T12:00:00Z"
}
}
```

## Update Analytics Preference

```http
PUT /users/me/analytics-preference
Authorization: Bearer <access token>
Content-Type: application/json

{
"enabled": true
}
```

Successful response:

```json
{
"status": "success",
"code": 200,
"message": "OK",
"data": {
"enabled": true,
"updatedAt": "2026-05-26T12:00:05Z"
}
}
```

`enabled` is required and must be a JSON boolean. Missing, null, non-boolean, or
unknown fields are rejected with the existing validation-style `400` response.

## Persistence And Deletion

The preference is stored in `user_analytics_preference` with a unique foreign key
to `user(user_id)`, `enabled`, `updated_at`, and the internal
`user_overridden` flag.

On account deletion, the local analytics preference row is deleted by foreign-key
cascade. Future user-linked Product Usage Events stop when the account
preference is disabled or the account is deleted. Historical analytics may be
retained only in aggregate or de-identified form, subject to the approved privacy
policy and Firebase/analytics project configuration.
Original file line numberDiff line numberDiff line change
Expand Up@@ -17,6 +17,7 @@
import devkor.ontime_back.global.oauth.google.GoogleLoginFilter;
import devkor.ontime_back.repository.UserAlarmSettingRepository;
import devkor.ontime_back.repository.UserRepository;
import devkor.ontime_back.service.AnalyticsPreferenceService;
import jakarta.validation.Validator;
import lombok.RequiredArgsConstructor;
import org.springframework.beans.factory.annotation.Value;
Expand DownExpand Up@@ -58,6 +59,7 @@ public class SecurityConfig {
private final Validator validator;
private final AppleLoginService appleLoginService;
private final GoogleLoginService googleLoginService;
private final AnalyticsPreferenceService analyticsPreferenceService;

@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
Expand All@@ -79,7 +81,7 @@ public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
.requestMatchers("/health").permitAll() // 로드밸런서 연결 확인용 url
.anyRequest().authenticated()
)
.addFilterBefore(new KakaoLoginFilter("/oauth2/kakao/login", objectMapper, validator, jwtTokenProvider, userRepository, userAlarmSettingRepository),
.addFilterBefore(new KakaoLoginFilter("/oauth2/kakao/login", objectMapper, validator, jwtTokenProvider, userRepository, userAlarmSettingRepository, analyticsPreferenceService),
UsernamePasswordAuthenticationFilter.class)
.addFilterBefore(new GoogleLoginFilter("/oauth2/google/login", objectMapper, validator, googleLoginService, userRepository),
UsernamePasswordAuthenticationFilter.class)
Expand Down
Original file line numberDiff line numberDiff line change
Expand Up@@ -281,6 +281,11 @@ private Map<String, NamedExample> requestExamples(String operationKey) {
examples.put("valid_partial_update", json("Valid partial update", "Only provided fields are updated.", "{\"alarmsEnabled\":true,\"defaultAlarmOffsetMinutes\":10}"));
examples.put("invalid_unknown_field", json("Invalid unknown field", "Unknown fields are rejected.", "{\"alarmsEnabled\":\"true\",\"unknown\":1}"));
}
case "PUT /users/me/analytics-preference" -> {
examples.put("enabled", json("Enable analytics", "Allows optional Product Usage Events for the signed-in account.", "{\"enabled\":true}"));
examples.put("disabled", json("Disable analytics", "Stops optional Product Usage Events for the signed-in account.", "{\"enabled\":false}"));
examples.put("invalid_unknown_field", json("Invalid unknown field", "Only enabled is accepted.", "{\"enabled\":\"false\",\"unknown\":1}"));
}
case "PUT /users/me/devices/current" -> {
examples.put("valid_ios_device", json("Valid iOS device", "Registers the current access-token session to the device.", "{\"deviceId\":\"ios-device-000001\",\"platform\":\"ios\",\"appVersion\":\"1.2.3\",\"osVersion\":\"iOS 18.0\",\"supportsNativeAlarm\":true,\"nativeAlarmProvider\":\"iosAlarmKit\",\"fallbackProvider\":\"localNotification\"}"));
examples.put("invalid_device_id", json("Invalid device ID", "deviceId must be 16-128 allowed characters.", "{\"deviceId\":\"short\",\"platform\":\"ios\",\"supportsNativeAlarm\":true,\"nativeAlarmProvider\":\"iosAlarmKit\",\"fallbackProvider\":\"localNotification\"}"));
Expand DownExpand Up@@ -389,6 +394,8 @@ private String successData(String operationKey) {
return switch (operationKey) {
case "GET /users/me/alarm-settings" -> "{\"alarmsEnabled\":true,\"defaultAlarmOffsetMinutes\":10,\"updatedAt\":\"2026-05-05T00:00:00Z\"}";
case "PATCH /users/me/alarm-settings" -> "{\"alarmsEnabled\":false,\"defaultAlarmOffsetMinutes\":5,\"updatedAt\":\"2026-05-05T00:00:00Z\"}";
case "GET /users/me/analytics-preference" -> "{\"enabled\":false,\"updatedAt\":\"2026-05-26T12:00:00Z\"}";
case "PUT /users/me/analytics-preference" -> "{\"enabled\":true,\"updatedAt\":\"2026-05-26T12:00:05Z\"}";
case "PUT /users/me/devices/current" -> "{\"deviceId\":\"ios-device-000001\",\"active\":true,\"lastSeenAt\":\"2026-05-05T00:00:00Z\"}";
case "DELETE /users/me/devices/current" -> "{\"active\":false}";
case "POST /users/me/alarm-status" -> "{\"received\":true}";
Expand Down
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
package devkor.ontime_back.controller;

import devkor.ontime_back.dto.AnalyticsPreferenceResponseDto;
import devkor.ontime_back.dto.AnalyticsPreferenceUpdateDto;
import devkor.ontime_back.response.ApiResponseForm;
import devkor.ontime_back.service.AnalyticsPreferenceService;
import devkor.ontime_back.service.UserAuthService;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.media.Content;
import io.swagger.v3.oas.annotations.media.Schema;
import io.swagger.v3.oas.annotations.responses.ApiResponse;
import io.swagger.v3.oas.annotations.responses.ApiResponses;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PutMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RestController;

@RestController
@RequiredArgsConstructor
public class AnalyticsPreferenceController {

private final UserAuthService userAuthService;
private final AnalyticsPreferenceService analyticsPreferenceService;

@Operation(summary = "Get current user's analytics preference")
@ApiResponses(value = {
@ApiResponse(responseCode = "200", description = "Analytics preference lookup succeeded", content = @Content(
mediaType = "application/json",
schema = @Schema(example = "{\n \"status\": \"success\",\n \"code\": 200,\n \"message\": \"OK\",\n \"data\": {\n \"enabled\": false,\n \"updatedAt\": \"2026-05-26T12:00:00Z\"\n }\n}")
)),
@ApiResponse(responseCode = "4XX", description = "Analytics preference lookup failed", content = @Content(mediaType = "application/json", schema = @Schema(example = "Failure message")))
})
@GetMapping("/users/me/analytics-preference")
public ResponseEntity<ApiResponseForm<AnalyticsPreferenceResponseDto>> getAnalyticsPreference(HttpServletRequest request) {
Long userId = userAuthService.getUserIdFromToken(request);
return ResponseEntity.status(HttpStatus.OK)
.body(ApiResponseForm.success(analyticsPreferenceService.getAnalyticsPreference(userId)));
}

@Operation(
summary = "Update current user's analytics preference",
requestBody = @io.swagger.v3.oas.annotations.parameters.RequestBody(
description = "Account-scoped analytics preference update.",
required = true,
content = @Content(schema = @Schema(
type = "object",
example = "{\"enabled\": false}"
))
)
)
@ApiResponses(value = {
@ApiResponse(responseCode = "200", description = "Analytics preference update succeeded", content = @Content(
mediaType = "application/json",
schema = @Schema(example = "{\n \"status\": \"success\",\n \"code\": 200,\n \"message\": \"OK\",\n \"data\": {\n \"enabled\": false,\n \"updatedAt\": \"2026-05-26T12:00:05Z\"\n }\n}")
)),
@ApiResponse(responseCode = "4XX", description = "Analytics preference update failed", content = @Content(mediaType = "application/json", schema = @Schema(example = "Failure message")))
})
@PutMapping("/users/me/analytics-preference")
public ResponseEntity<ApiResponseForm<AnalyticsPreferenceResponseDto>> updateAnalyticsPreference(
HttpServletRequest request,
@Valid @RequestBody AnalyticsPreferenceUpdateDto requestDto) {
Long userId = userAuthService.getUserIdFromToken(request);
return ResponseEntity.status(HttpStatus.OK)
.body(ApiResponseForm.success(analyticsPreferenceService.updateAnalyticsPreference(userId, requestDto)));
}
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
package devkor.ontime_back.dto;

import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Getter;

import java.time.Instant;

@Getter
@Builder
@AllArgsConstructor
public class AnalyticsPreferenceResponseDto {
private Boolean enabled;
private Instant updatedAt;
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
package devkor.ontime_back.dto;

import com.fasterxml.jackson.annotation.JsonAnySetter;
import jakarta.validation.constraints.AssertTrue;
import lombok.Getter;
import lombok.NoArgsConstructor;

import java.util.HashMap;
import java.util.Map;

@Getter
@NoArgsConstructor
public class AnalyticsPreferenceUpdateDto {
private Object enabled;
private final Map<String, Object> unknownFields = new HashMap<>();

@JsonAnySetter
public void addUnknownField(String name, Object value) {
unknownFields.put(name, value);
}

@AssertTrue(message = "알 수 없는 분석 설정 필드입니다.")
public boolean isKnownFieldsOnly() {
return unknownFields.isEmpty();
}

@AssertTrue(message = "enabled는 필수 값입니다.")
public boolean isEnabledPresent() {
return enabled != null;
}

@AssertTrue(message = "enabled는 boolean 값이어야 합니다.")
public boolean isEnabledBoolean() {
return enabled == null || enabled instanceof Boolean;
}

public Boolean getEnabledValue() {
return enabled instanceof Boolean value ? value : null;
}
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,85 @@
package devkor.ontime_back.entity;

import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.FetchType;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.JoinColumn;
import jakarta.persistence.OneToOne;
import jakarta.persistence.PrePersist;
import jakarta.persistence.Table;
import jakarta.persistence.UniqueConstraint;
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.util.Objects;

@Getter
@Entity
@Builder
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@AllArgsConstructor
@Table(
uniqueConstraints = {
@UniqueConstraint(name = "uk_user_analytics_preference_user", columnNames = "user_id")
}
)
public class UserAnalyticsPreference {

@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long userAnalyticsPreferenceId;

@OneToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "user_id", nullable = false)
@OnDelete(action = OnDeleteAction.CASCADE)
private User user;

@Column(nullable = false)
private Boolean enabled;

@Column(nullable = false)
private Instant updatedAt;

@Column(nullable = false)
private Boolean userOverridden;

public static UserAnalyticsPreference defaultFor(User user, boolean defaultEnabled) {
return UserAnalyticsPreference.builder()
.user(user)
.enabled(defaultEnabled)
.updatedAt(Instant.now())
.userOverridden(false)
.build();
}

@PrePersist
private void initializeDefaults() {
if (enabled == null) enabled = false;
if (updatedAt == null) updatedAt = Instant.now();
if (userOverridden == null) userOverridden = false;
}

public boolean alignToDefault(boolean defaultEnabled) {
if (Boolean.TRUE.equals(userOverridden) || Objects.equals(enabled, defaultEnabled)) {
return false;
}
this.enabled = defaultEnabled;
this.updatedAt = Instant.now();
return true;
}

public void update(boolean enabled) {
this.enabled = enabled;
this.userOverridden = true;
this.updatedAt = Instant.now();
}
}
Original file line numberDiff line numberDiff line change
Expand Up@@ -14,6 +14,7 @@
import devkor.ontime_back.repository.UserAlarmSettingRepository;
import devkor.ontime_back.repository.UserRepository;
import devkor.ontime_back.response.InvalidTokenException;
import devkor.ontime_back.service.AnalyticsPreferenceService;
import io.jsonwebtoken.Claims;
import io.jsonwebtoken.Jwts;
import io.jsonwebtoken.SignatureAlgorithm;
Expand DownExpand Up@@ -71,6 +72,7 @@ public class AppleLoginService {
private final UserRepository userRepository;
private final UserAlarmSettingRepository userAlarmSettingRepository;
private final JwtTokenProvider jwtTokenProvider;
private final AnalyticsPreferenceService analyticsPreferenceService;

private final RestTemplate restTemplate = new RestTemplate();
public Authentication handleLogin(String appleRefreshToken, User user, HttpServletResponse response) throws IOException {
Expand DownExpand Up@@ -141,6 +143,7 @@ public Authentication handleRegister(String appleRefreshToken, OAuthAppleUserDto
savedUser.updateRefreshToken(refreshToken);
userRepository.save(savedUser);
userAlarmSettingRepository.save(UserAlarmSetting.defaultFor(savedUser));
analyticsPreferenceService.createDefaultPreference(savedUser);

Authentication authentication = new UsernamePasswordAuthenticationToken(
savedUser, null, Collections.singletonList(new SimpleGrantedAuthority(savedUser.getRole().name()))
Expand Down
Loading
Loading