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 number Diff line number Diff 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 number Diff line number Diff 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 number Diff line number Diff 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 number Diff line number Diff 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 Down Expand 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 number Diff line number Diff 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 Down Expand 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 number Diff line number Diff 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 number Diff line number Diff 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 number Diff line number Diff 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 number Diff line number Diff 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 number Diff line number Diff 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 Down Expand 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 Down Expand 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