Skip to content

Commit 46347e6

Browse files
authored
Merge pull request #263 from DevKor-github/codex/account-deletion-feedback
[codex] Add account deletion feedback
2 parents 70cf533 + 683411f commit 46347e6

10 files changed

Lines changed: 392 additions & 14 deletions

File tree

docs/account-deletion-api.md

Lines changed: 180 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,180 @@
1+
# Account Deletion API
2+
3+
Frontend integration guide for deleting an OnTime account with optional withdrawal feedback.
4+
5+
## Summary
6+
7+
Account deletion hard-deletes the user from OnTime. The request can optionally include feedback. If feedback is provided, the backend stores it separately from the `User` table so it remains available after the account is deleted.
8+
9+
For Google and Apple social accounts, the backend first tries to revoke the social login token, then deletes the local OnTime account.
10+
11+
## Authentication
12+
13+
All endpoints require the current OnTime access token.
14+
15+
```http
16+
Authorization: Bearer {accessToken}
17+
Content-Type: application/json
18+
```
19+
20+
## Request Body
21+
22+
The request body is optional for every deletion endpoint.
23+
24+
```json
25+
{
26+
"feedbackId": "d784cde3-9ff9-4054-872a-500bbcc2198a",
27+
"message": "I do not use the app anymore."
28+
}
29+
```
30+
31+
Fields:
32+
33+
| Field | Type | Required | Notes |
34+
| --- | --- | --- | --- |
35+
| `feedbackId` | UUID string | No | Client-generated ID. If omitted, the backend generates one. |
36+
| `message` | string | No | If missing, blank, or only whitespace, feedback is not saved and deletion still proceeds. |
37+
38+
## General Account Deletion
39+
40+
Use this endpoint for normal OnTime account deletion.
41+
42+
```http
43+
DELETE /users/me/delete
44+
```
45+
46+
Example without feedback:
47+
48+
```http
49+
DELETE /users/me/delete
50+
Authorization: Bearer {accessToken}
51+
Content-Type: application/json
52+
53+
{}
54+
```
55+
56+
Example with feedback:
57+
58+
```http
59+
DELETE /users/me/delete
60+
Authorization: Bearer {accessToken}
61+
Content-Type: application/json
62+
63+
{
64+
"feedbackId": "d784cde3-9ff9-4054-872a-500bbcc2198a",
65+
"message": "The notifications were not useful for me."
66+
}
67+
```
68+
69+
Success response:
70+
71+
```json
72+
{
73+
"status": "success",
74+
"code": "200",
75+
"message": "계정이 성공적으로 삭제되었습니다!",
76+
"data": null
77+
}
78+
```
79+
80+
## Google Account Deletion
81+
82+
Use this endpoint when the current account is linked through Google login.
83+
84+
```http
85+
DELETE /oauth2/google/me
86+
```
87+
88+
Example:
89+
90+
```http
91+
DELETE /oauth2/google/me
92+
Authorization: Bearer {accessToken}
93+
Content-Type: application/json
94+
95+
{
96+
"message": "I am switching to another calendar app."
97+
}
98+
```
99+
100+
Success response:
101+
102+
```text
103+
구글 로그인 회원탈퇴 성공
104+
```
105+
106+
Behavior:
107+
108+
- Revokes the stored Google OAuth token for OnTime.
109+
- Deletes the local OnTime account.
110+
- Saves optional feedback if `message` is nonblank.
111+
- Does not delete the user's actual Google account.
112+
113+
Expected frontend result after successful revoke:
114+
115+
- If the user signs up or logs in with the same Google account again, Google may show an "OnTime에 다시 로그인하는 중입니다" confirmation screen.
116+
- The Google account can still be preselected because the user is still signed in to Google on the device/browser.
117+
118+
Important caveat:
119+
120+
- The Google unlink only works if the backend has a valid Google refresh/access token saved in `socialLoginToken`.
121+
- If the client never provided a real Google refresh token, Google revoke may fail and the endpoint may return an error before local deletion.
122+
123+
## Apple Account Deletion
124+
125+
Use this endpoint when the current account is linked through Apple login.
126+
127+
```http
128+
DELETE /oauth2/apple/me
129+
```
130+
131+
Example:
132+
133+
```http
134+
DELETE /oauth2/apple/me
135+
Authorization: Bearer {accessToken}
136+
Content-Type: application/json
137+
138+
{
139+
"feedbackId": "85fc54e0-e6c7-4c7e-9312-7784a52bf120",
140+
"message": "I want to restart with a fresh account."
141+
}
142+
```
143+
144+
Success response:
145+
146+
```text
147+
애플 로그인 회원탈퇴 성공
148+
```
149+
150+
Behavior:
151+
152+
- Revokes the stored Apple OAuth token for OnTime.
153+
- Deletes the local OnTime account.
154+
- Saves optional feedback if `message` is nonblank.
155+
- Does not delete the user's Apple ID.
156+
157+
## What Gets Stored For Feedback
158+
159+
When feedback is provided, the backend stores:
160+
161+
| Stored Field | Notes |
162+
| --- | --- |
163+
| `feedbackId` | Client-provided UUID or backend-generated UUID |
164+
| `deletedUserId` | Previous OnTime user ID |
165+
| `socialType` | `GOOGLE`, `APPLE`, or null for non-social accounts |
166+
| `emailHash` | SHA-256 hash of the normalized email, not plaintext email |
167+
| `message` | User feedback text |
168+
| `createdAt` | Server timestamp |
169+
170+
Feedback is not linked by foreign key to the deleted user.
171+
172+
## Frontend Recommendations
173+
174+
- Treat feedback as optional. Do not block deletion if the user skips it.
175+
- Generate a UUID for `feedbackId` if convenient, but it is safe to omit.
176+
- Use `/oauth2/google/me` for Google-linked accounts if the product requirement is to unlink Google access.
177+
- Use `/oauth2/apple/me` for Apple-linked accounts if the product requirement is to unlink Apple access.
178+
- Use `/users/me/delete` for normal local deletion.
179+
- After a successful deletion response, clear local auth state and navigate to the logged-out screen.
180+
- Do not retry automatically on social revoke errors without showing the user, because the local account may not have been deleted.

ontime-back/src/main/java/devkor/ontime_back/controller/SocialAuthController.java

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
package devkor.ontime_back.controller;
22

3+
import devkor.ontime_back.dto.FeedbackAddDto;
34
import devkor.ontime_back.dto.OAuthAppleRequestDto;
45
import devkor.ontime_back.dto.OAuthGoogleUserDto;
56
import devkor.ontime_back.dto.OAuthKakaoUserDto;
@@ -112,24 +113,24 @@ public String appleRegisterOrLogin(@RequestBody OAuthAppleRequestDto appleLoginR
112113
summary = "애플 소셜 로그인 회원탈퇴"
113114
)
114115
@DeleteMapping("/apple/me")
115-
public String appleDeleteUser(HttpServletRequest request, HttpServletResponse response) throws Exception {
116+
public String appleDeleteUser(HttpServletRequest request, HttpServletResponse response, @RequestBody(required = false) FeedbackAddDto feedbackAddDto) throws Exception {
116117
Long userId = userAuthService.getUserIdFromToken(request);
117118
log.info("userId: {}", userId);
118119
appleLoginService.revokeToken(userId);
119-
userAuthService.deleteUser(userId);
120+
userAuthService.deleteUser(userId, feedbackAddDto);
120121
return "애플 로그인 회원탈퇴 성공";
121122
}
122123

123124
@Operation(
124125
summary = "구글 소셜 로그인 회원탈퇴"
125126
)
126127
@DeleteMapping("/google/me")
127-
public String googleDeleteUser(HttpServletRequest request, HttpServletResponse response) throws Exception {
128+
public String googleDeleteUser(HttpServletRequest request, HttpServletResponse response, @RequestBody(required = false) FeedbackAddDto feedbackAddDto) throws Exception {
128129
Long userId = userAuthService.getUserIdFromToken(request);
129130
log.info("userId: {}", userId);
130131
googleLoginService.revokeToken(userId);
131-
userAuthService.deleteUser(userId);
132-
return "애플 로그인 회원탈퇴 성공";
132+
userAuthService.deleteUser(userId, feedbackAddDto);
133+
return "구글 로그인 회원탈퇴 성공";
133134
}
134135

135136

ontime-back/src/main/java/devkor/ontime_back/controller/UserAuthController.java

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
package devkor.ontime_back.controller;
22

33
import devkor.ontime_back.dto.ChangePasswordDto;
4+
import devkor.ontime_back.dto.FeedbackAddDto;
45
import devkor.ontime_back.dto.UserInfoResponse;
56
import devkor.ontime_back.dto.UserSignUpDto;
67
import devkor.ontime_back.entity.User;
@@ -107,11 +108,11 @@ public ResponseEntity<ApiResponseForm<String>> changePassword(HttpServletRequest
107108
@Operation(
108109
summary = "계정 삭제 (User 데이터 하드 삭제)",
109110
requestBody = @io.swagger.v3.oas.annotations.parameters.RequestBody(
110-
description = "계정 삭제 요청 JSON 데이터는 없음. 헤더에 토큰만 있으면 됨",
111+
description = "계정 삭제 요청 JSON 데이터는 선택사항. 탈퇴 피드백을 남기려면 feedbackId, message를 전달",
111112
content = @Content(
112113
schema = @Schema(
113114
type = "object",
114-
example = "{}"
115+
example = "{\"feedbackId\": \"d784cde3-9ff9-4054-872a-500bbcc2198a\", \"message\": \"탈퇴 피드백입니다.\"}"
115116
)
116117
)
117118
)
@@ -126,9 +127,9 @@ public ResponseEntity<ApiResponseForm<String>> changePassword(HttpServletRequest
126127
@ApiResponse(responseCode = "4XX", description = "계정 삭제 실패", content = @Content(mediaType = "application/json", schema = @Schema(example = "실패 메세지(토큰 오류 제외 비즈니스 로직 오류는 없음)")))
127128
})
128129
@DeleteMapping("/users/me/delete")
129-
public ResponseEntity<ApiResponseForm<?>> deleteUser(HttpServletRequest request) {
130+
public ResponseEntity<ApiResponseForm<?>> deleteUser(HttpServletRequest request, @RequestBody(required = false) FeedbackAddDto feedbackAddDto) {
130131
Long userId = userAuthService.getUserIdFromToken(request);
131-
userAuthService.deleteUser(userId);
132+
userAuthService.deleteUser(userId, feedbackAddDto);
132133
String message = "계정이 성공적으로 삭제되었습니다!";
133134
return ResponseEntity.ok(ApiResponseForm.success(null, message));
134135
}

ontime-back/src/main/java/devkor/ontime_back/dto/FeedbackAddDto.java

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,14 @@
11
package devkor.ontime_back.dto;
22

3-
import lombok.Getter;
4-
import lombok.ToString;
3+
import lombok.*;
54

65
import java.util.UUID;
76

87
@ToString
98
@Getter
9+
@Builder
10+
@NoArgsConstructor
11+
@AllArgsConstructor
1012
public class FeedbackAddDto {
1113
private UUID feedbackId;
1214
private String message;
Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
package devkor.ontime_back.entity;
2+
3+
import jakarta.persistence.*;
4+
import lombok.*;
5+
6+
import java.time.LocalDateTime;
7+
import java.util.UUID;
8+
9+
@Getter
10+
@Entity
11+
@NoArgsConstructor(access = AccessLevel.PROTECTED)
12+
@AllArgsConstructor
13+
@Builder
14+
public class AccountDeletionFeedback {
15+
16+
@Id
17+
private UUID feedbackId;
18+
19+
private Long deletedUserId;
20+
21+
@Enumerated(EnumType.STRING)
22+
private SocialType socialType;
23+
24+
@Column(length = 64)
25+
private String emailHash;
26+
27+
@Lob
28+
@Column(columnDefinition = "TEXT")
29+
private String message;
30+
31+
private LocalDateTime createdAt;
32+
}
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
package devkor.ontime_back.repository;
2+
3+
import devkor.ontime_back.entity.AccountDeletionFeedback;
4+
import org.springframework.data.jpa.repository.JpaRepository;
5+
import org.springframework.stereotype.Repository;
6+
7+
import java.util.UUID;
8+
9+
@Repository
10+
public interface AccountDeletionFeedbackRepository extends JpaRepository<AccountDeletionFeedback, UUID> {
11+
}

0 commit comments

Comments
 (0)