Skip to content

Commit 82f497f

Browse files
authored
Merge pull request #320 from DevKor-github/feature/analytics-preference-api
[codex] Add analytics preference API
2 parents 03c50db + 4a1b541 commit 82f497f

28 files changed

Lines changed: 853 additions & 4 deletions

docs/account-deletion-api.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,8 @@ Account deletion hard-deletes the user from OnTime. The request can optionally i
88

99
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.
1010

11+
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.
12+
1113
For release/privacy evidence by data category, see `docs/account-deletion-verification-evidence.md`.
1214

1315
## Authentication

docs/account-deletion-verification-evidence.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,7 @@ request timestamp, response, owner, and evidence link.
4949
| 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 |
5050
| 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 |
5151
| 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 |
52+
| 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 |
5253
| 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 |
5354
| 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 |
5455
| 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 |

docs/analytics-preference-api.md

Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,91 @@
1+
# Analytics Preference API
2+
3+
Issue: #318
4+
5+
## Summary
6+
7+
The analytics preference API stores whether the signed-in account allows optional
8+
Product Usage Events. The preference is account-scoped, not device-scoped.
9+
10+
This API does not define Firebase event names, frontend instrumentation, local
11+
pre-login preference storage, UI copy, marketing analytics, personalization, or
12+
Firebase Remote Config behavior.
13+
14+
## Default And Release Gate
15+
16+
Backend default is controlled by:
17+
18+
```properties
19+
analytics.preference.default-enabled=${ANALYTICS_PREFERENCE_DEFAULT_ENABLED:false}
20+
```
21+
22+
The default remains `false` until the privacy policy and Google Play Data Safety
23+
updates are approved for the Firebase Analytics release. After approval, deploy
24+
owners may set `ANALYTICS_PREFERENCE_DEFAULT_ENABLED=true`.
25+
26+
Rows created before the default is flipped are marked as not user-overridden.
27+
When the service reads a non-overridden row, it may align that row to the current
28+
deploy default. Once a user explicitly updates the preference, the row is marked
29+
as user-overridden and future default flips do not change that choice.
30+
31+
## Get Analytics Preference
32+
33+
```http
34+
GET /users/me/analytics-preference
35+
Authorization: Bearer <access token>
36+
```
37+
38+
Successful response:
39+
40+
```json
41+
{
42+
"status": "success",
43+
"code": 200,
44+
"message": "OK",
45+
"data": {
46+
"enabled": false,
47+
"updatedAt": "2026-05-26T12:00:00Z"
48+
}
49+
}
50+
```
51+
52+
## Update Analytics Preference
53+
54+
```http
55+
PUT /users/me/analytics-preference
56+
Authorization: Bearer <access token>
57+
Content-Type: application/json
58+
59+
{
60+
"enabled": true
61+
}
62+
```
63+
64+
Successful response:
65+
66+
```json
67+
{
68+
"status": "success",
69+
"code": 200,
70+
"message": "OK",
71+
"data": {
72+
"enabled": true,
73+
"updatedAt": "2026-05-26T12:00:05Z"
74+
}
75+
}
76+
```
77+
78+
`enabled` is required and must be a JSON boolean. Missing, null, non-boolean, or
79+
unknown fields are rejected with the existing validation-style `400` response.
80+
81+
## Persistence And Deletion
82+
83+
The preference is stored in `user_analytics_preference` with a unique foreign key
84+
to `user(user_id)`, `enabled`, `updated_at`, and the internal
85+
`user_overridden` flag.
86+
87+
On account deletion, the local analytics preference row is deleted by foreign-key
88+
cascade. Future user-linked Product Usage Events stop when the account
89+
preference is disabled or the account is deleted. Historical analytics may be
90+
retained only in aggregate or de-identified form, subject to the approved privacy
91+
policy and Firebase/analytics project configuration.

ontime-back/src/main/java/devkor/ontime_back/config/SecurityConfig.java

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@
1717
import devkor.ontime_back.global.oauth.google.GoogleLoginFilter;
1818
import devkor.ontime_back.repository.UserAlarmSettingRepository;
1919
import devkor.ontime_back.repository.UserRepository;
20+
import devkor.ontime_back.service.AnalyticsPreferenceService;
2021
import jakarta.validation.Validator;
2122
import lombok.RequiredArgsConstructor;
2223
import org.springframework.beans.factory.annotation.Value;
@@ -58,6 +59,7 @@ public class SecurityConfig {
5859
private final Validator validator;
5960
private final AppleLoginService appleLoginService;
6061
private final GoogleLoginService googleLoginService;
62+
private final AnalyticsPreferenceService analyticsPreferenceService;
6163

6264
@Bean
6365
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
@@ -79,7 +81,7 @@ public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
7981
.requestMatchers("/health").permitAll() // 로드밸런서 연결 확인용 url
8082
.anyRequest().authenticated()
8183
)
82-
.addFilterBefore(new KakaoLoginFilter("/oauth2/kakao/login", objectMapper, validator, jwtTokenProvider, userRepository, userAlarmSettingRepository),
84+
.addFilterBefore(new KakaoLoginFilter("/oauth2/kakao/login", objectMapper, validator, jwtTokenProvider, userRepository, userAlarmSettingRepository, analyticsPreferenceService),
8385
UsernamePasswordAuthenticationFilter.class)
8486
.addFilterBefore(new GoogleLoginFilter("/oauth2/google/login", objectMapper, validator, googleLoginService, userRepository),
8587
UsernamePasswordAuthenticationFilter.class)

ontime-back/src/main/java/devkor/ontime_back/config/SwaggerConfig.java

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -281,6 +281,11 @@ private Map<String, NamedExample> requestExamples(String operationKey) {
281281
examples.put("valid_partial_update", json("Valid partial update", "Only provided fields are updated.", "{\"alarmsEnabled\":true,\"defaultAlarmOffsetMinutes\":10}"));
282282
examples.put("invalid_unknown_field", json("Invalid unknown field", "Unknown fields are rejected.", "{\"alarmsEnabled\":\"true\",\"unknown\":1}"));
283283
}
284+
case "PUT /users/me/analytics-preference" -> {
285+
examples.put("enabled", json("Enable analytics", "Allows optional Product Usage Events for the signed-in account.", "{\"enabled\":true}"));
286+
examples.put("disabled", json("Disable analytics", "Stops optional Product Usage Events for the signed-in account.", "{\"enabled\":false}"));
287+
examples.put("invalid_unknown_field", json("Invalid unknown field", "Only enabled is accepted.", "{\"enabled\":\"false\",\"unknown\":1}"));
288+
}
284289
case "PUT /users/me/devices/current" -> {
285290
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\"}"));
286291
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\"}"));
@@ -389,6 +394,8 @@ private String successData(String operationKey) {
389394
return switch (operationKey) {
390395
case "GET /users/me/alarm-settings" -> "{\"alarmsEnabled\":true,\"defaultAlarmOffsetMinutes\":10,\"updatedAt\":\"2026-05-05T00:00:00Z\"}";
391396
case "PATCH /users/me/alarm-settings" -> "{\"alarmsEnabled\":false,\"defaultAlarmOffsetMinutes\":5,\"updatedAt\":\"2026-05-05T00:00:00Z\"}";
397+
case "GET /users/me/analytics-preference" -> "{\"enabled\":false,\"updatedAt\":\"2026-05-26T12:00:00Z\"}";
398+
case "PUT /users/me/analytics-preference" -> "{\"enabled\":true,\"updatedAt\":\"2026-05-26T12:00:05Z\"}";
392399
case "PUT /users/me/devices/current" -> "{\"deviceId\":\"ios-device-000001\",\"active\":true,\"lastSeenAt\":\"2026-05-05T00:00:00Z\"}";
393400
case "DELETE /users/me/devices/current" -> "{\"active\":false}";
394401
case "POST /users/me/alarm-status" -> "{\"received\":true}";
Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
1+
package devkor.ontime_back.controller;
2+
3+
import devkor.ontime_back.dto.AnalyticsPreferenceResponseDto;
4+
import devkor.ontime_back.dto.AnalyticsPreferenceUpdateDto;
5+
import devkor.ontime_back.response.ApiResponseForm;
6+
import devkor.ontime_back.service.AnalyticsPreferenceService;
7+
import devkor.ontime_back.service.UserAuthService;
8+
import io.swagger.v3.oas.annotations.Operation;
9+
import io.swagger.v3.oas.annotations.media.Content;
10+
import io.swagger.v3.oas.annotations.media.Schema;
11+
import io.swagger.v3.oas.annotations.responses.ApiResponse;
12+
import io.swagger.v3.oas.annotations.responses.ApiResponses;
13+
import jakarta.servlet.http.HttpServletRequest;
14+
import jakarta.validation.Valid;
15+
import lombok.RequiredArgsConstructor;
16+
import org.springframework.http.HttpStatus;
17+
import org.springframework.http.ResponseEntity;
18+
import org.springframework.web.bind.annotation.GetMapping;
19+
import org.springframework.web.bind.annotation.PutMapping;
20+
import org.springframework.web.bind.annotation.RequestBody;
21+
import org.springframework.web.bind.annotation.RestController;
22+
23+
@RestController
24+
@RequiredArgsConstructor
25+
public class AnalyticsPreferenceController {
26+
27+
private final UserAuthService userAuthService;
28+
private final AnalyticsPreferenceService analyticsPreferenceService;
29+
30+
@Operation(summary = "Get current user's analytics preference")
31+
@ApiResponses(value = {
32+
@ApiResponse(responseCode = "200", description = "Analytics preference lookup succeeded", content = @Content(
33+
mediaType = "application/json",
34+
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}")
35+
)),
36+
@ApiResponse(responseCode = "4XX", description = "Analytics preference lookup failed", content = @Content(mediaType = "application/json", schema = @Schema(example = "Failure message")))
37+
})
38+
@GetMapping("/users/me/analytics-preference")
39+
public ResponseEntity<ApiResponseForm<AnalyticsPreferenceResponseDto>> getAnalyticsPreference(HttpServletRequest request) {
40+
Long userId = userAuthService.getUserIdFromToken(request);
41+
return ResponseEntity.status(HttpStatus.OK)
42+
.body(ApiResponseForm.success(analyticsPreferenceService.getAnalyticsPreference(userId)));
43+
}
44+
45+
@Operation(
46+
summary = "Update current user's analytics preference",
47+
requestBody = @io.swagger.v3.oas.annotations.parameters.RequestBody(
48+
description = "Account-scoped analytics preference update.",
49+
required = true,
50+
content = @Content(schema = @Schema(
51+
type = "object",
52+
example = "{\"enabled\": false}"
53+
))
54+
)
55+
)
56+
@ApiResponses(value = {
57+
@ApiResponse(responseCode = "200", description = "Analytics preference update succeeded", content = @Content(
58+
mediaType = "application/json",
59+
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}")
60+
)),
61+
@ApiResponse(responseCode = "4XX", description = "Analytics preference update failed", content = @Content(mediaType = "application/json", schema = @Schema(example = "Failure message")))
62+
})
63+
@PutMapping("/users/me/analytics-preference")
64+
public ResponseEntity<ApiResponseForm<AnalyticsPreferenceResponseDto>> updateAnalyticsPreference(
65+
HttpServletRequest request,
66+
@Valid @RequestBody AnalyticsPreferenceUpdateDto requestDto) {
67+
Long userId = userAuthService.getUserIdFromToken(request);
68+
return ResponseEntity.status(HttpStatus.OK)
69+
.body(ApiResponseForm.success(analyticsPreferenceService.updateAnalyticsPreference(userId, requestDto)));
70+
}
71+
}
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
package devkor.ontime_back.dto;
2+
3+
import lombok.AllArgsConstructor;
4+
import lombok.Builder;
5+
import lombok.Getter;
6+
7+
import java.time.Instant;
8+
9+
@Getter
10+
@Builder
11+
@AllArgsConstructor
12+
public class AnalyticsPreferenceResponseDto {
13+
private Boolean enabled;
14+
private Instant updatedAt;
15+
}
Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
package devkor.ontime_back.dto;
2+
3+
import com.fasterxml.jackson.annotation.JsonAnySetter;
4+
import jakarta.validation.constraints.AssertTrue;
5+
import lombok.Getter;
6+
import lombok.NoArgsConstructor;
7+
8+
import java.util.HashMap;
9+
import java.util.Map;
10+
11+
@Getter
12+
@NoArgsConstructor
13+
public class AnalyticsPreferenceUpdateDto {
14+
private Object enabled;
15+
private final Map<String, Object> unknownFields = new HashMap<>();
16+
17+
@JsonAnySetter
18+
public void addUnknownField(String name, Object value) {
19+
unknownFields.put(name, value);
20+
}
21+
22+
@AssertTrue(message = "알 수 없는 분석 설정 필드입니다.")
23+
public boolean isKnownFieldsOnly() {
24+
return unknownFields.isEmpty();
25+
}
26+
27+
@AssertTrue(message = "enabled는 필수 값입니다.")
28+
public boolean isEnabledPresent() {
29+
return enabled != null;
30+
}
31+
32+
@AssertTrue(message = "enabled는 boolean 값이어야 합니다.")
33+
public boolean isEnabledBoolean() {
34+
return enabled == null || enabled instanceof Boolean;
35+
}
36+
37+
public Boolean getEnabledValue() {
38+
return enabled instanceof Boolean value ? value : null;
39+
}
40+
}
Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,85 @@
1+
package devkor.ontime_back.entity;
2+
3+
import jakarta.persistence.Column;
4+
import jakarta.persistence.Entity;
5+
import jakarta.persistence.FetchType;
6+
import jakarta.persistence.GeneratedValue;
7+
import jakarta.persistence.GenerationType;
8+
import jakarta.persistence.Id;
9+
import jakarta.persistence.JoinColumn;
10+
import jakarta.persistence.OneToOne;
11+
import jakarta.persistence.PrePersist;
12+
import jakarta.persistence.Table;
13+
import jakarta.persistence.UniqueConstraint;
14+
import lombok.AccessLevel;
15+
import lombok.AllArgsConstructor;
16+
import lombok.Builder;
17+
import lombok.Getter;
18+
import lombok.NoArgsConstructor;
19+
import org.hibernate.annotations.OnDelete;
20+
import org.hibernate.annotations.OnDeleteAction;
21+
22+
import java.time.Instant;
23+
import java.util.Objects;
24+
25+
@Getter
26+
@Entity
27+
@Builder
28+
@NoArgsConstructor(access = AccessLevel.PROTECTED)
29+
@AllArgsConstructor
30+
@Table(
31+
uniqueConstraints = {
32+
@UniqueConstraint(name = "uk_user_analytics_preference_user", columnNames = "user_id")
33+
}
34+
)
35+
public class UserAnalyticsPreference {
36+
37+
@Id
38+
@GeneratedValue(strategy = GenerationType.IDENTITY)
39+
private Long userAnalyticsPreferenceId;
40+
41+
@OneToOne(fetch = FetchType.LAZY)
42+
@JoinColumn(name = "user_id", nullable = false)
43+
@OnDelete(action = OnDeleteAction.CASCADE)
44+
private User user;
45+
46+
@Column(nullable = false)
47+
private Boolean enabled;
48+
49+
@Column(nullable = false)
50+
private Instant updatedAt;
51+
52+
@Column(nullable = false)
53+
private Boolean userOverridden;
54+
55+
public static UserAnalyticsPreference defaultFor(User user, boolean defaultEnabled) {
56+
return UserAnalyticsPreference.builder()
57+
.user(user)
58+
.enabled(defaultEnabled)
59+
.updatedAt(Instant.now())
60+
.userOverridden(false)
61+
.build();
62+
}
63+
64+
@PrePersist
65+
private void initializeDefaults() {
66+
if (enabled == null) enabled = false;
67+
if (updatedAt == null) updatedAt = Instant.now();
68+
if (userOverridden == null) userOverridden = false;
69+
}
70+
71+
public boolean alignToDefault(boolean defaultEnabled) {
72+
if (Boolean.TRUE.equals(userOverridden) || Objects.equals(enabled, defaultEnabled)) {
73+
return false;
74+
}
75+
this.enabled = defaultEnabled;
76+
this.updatedAt = Instant.now();
77+
return true;
78+
}
79+
80+
public void update(boolean enabled) {
81+
this.enabled = enabled;
82+
this.userOverridden = true;
83+
this.updatedAt = Instant.now();
84+
}
85+
}

ontime-back/src/main/java/devkor/ontime_back/global/oauth/apple/AppleLoginService.java

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@
1414
import devkor.ontime_back.repository.UserAlarmSettingRepository;
1515
import devkor.ontime_back.repository.UserRepository;
1616
import devkor.ontime_back.response.InvalidTokenException;
17+
import devkor.ontime_back.service.AnalyticsPreferenceService;
1718
import io.jsonwebtoken.Claims;
1819
import io.jsonwebtoken.Jwts;
1920
import io.jsonwebtoken.SignatureAlgorithm;
@@ -71,6 +72,7 @@ public class AppleLoginService {
7172
private final UserRepository userRepository;
7273
private final UserAlarmSettingRepository userAlarmSettingRepository;
7374
private final JwtTokenProvider jwtTokenProvider;
75+
private final AnalyticsPreferenceService analyticsPreferenceService;
7476

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

145148
Authentication authentication = new UsernamePasswordAuthenticationToken(
146149
savedUser, null, Collections.singletonList(new SimpleGrantedAuthority(savedUser.getRole().name()))

0 commit comments

Comments
 (0)