Skip to content

Commit 17db96f

Browse files
committed
refactor: todo 삭제 책임을 firebase function으로 이관
1 parent 0134a9d commit 17db96f

4 files changed

Lines changed: 214 additions & 9 deletions

File tree

DevLog/Infra/Service/PushNotificationService.swift

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -256,6 +256,9 @@ private extension PushNotificationService {
256256

257257
func makeResponse(from snapshot: QueryDocumentSnapshot) -> PushNotificationResponse? {
258258
let data = snapshot.data()
259+
if data[Key.deletingAt.rawValue] is Timestamp {
260+
return nil
261+
}
259262
guard
260263
let title = data[Key.title.rawValue] as? String,
261264
let body = data[Key.body.rawValue] as? String,
@@ -284,5 +287,6 @@ private extension PushNotificationService {
284287
case isRead
285288
case todoId
286289
case todoKind
290+
case deletingAt // 삭제 요청은 되었지만, 5초 유예 후 최종 삭제되기 전 상태
287291
}
288292
}

DevLog/Infra/Service/TodoService.swift

Lines changed: 13 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -7,9 +7,11 @@
77

88
import FirebaseAuth
99
import FirebaseFirestore
10+
import FirebaseFunctions
1011

1112
final class TodoService {
1213
private let store = Firestore.firestore()
14+
private let functions = Functions.functions(region: "asia-northeast3")
1315
private let encoder = Firestore.Encoder()
1416
private let logger = Logger(category: "TodoService")
1517

@@ -171,18 +173,17 @@ final class TodoService {
171173
}
172174

173175
func deleteTodo(todoId: String) async throws {
174-
guard let uid = Auth.auth().currentUser?.uid else { throw AuthError.notAuthenticated }
176+
guard Auth.auth().currentUser?.uid != nil else { throw AuthError.notAuthenticated }
175177

176-
logger.info("Deleting todo: \(todoId)")
178+
logger.info("Requesting todo deletion: \(todoId)")
177179

178180
do {
179-
let collection = store.collection("users/\(uid)/todoLists/")
180-
let docRef = collection.document(todoId)
181-
try await docRef.delete()
181+
let function = functions.httpsCallable("requestTodoDeletion")
182+
_ = try await function.call(["todoId": todoId])
182183

183-
logger.info("Successfully deleted todo")
184+
logger.info("Successfully requested todo deletion")
184185
} catch {
185-
logger.error("Failed to delete todo", error: error)
186+
logger.error("Failed to request todo deletion", error: error)
186187
throw error
187188
}
188189
}
@@ -282,7 +283,10 @@ private extension TodoService {
282283
}
283284

284285
func makeResponse(from snapshot: QueryDocumentSnapshot) -> TodoResponse? {
285-
makeResponse(documentID: snapshot.documentID, data: snapshot.data())
286+
if snapshot.data()[TodoFieldKey.deletingAt.rawValue] is Timestamp {
287+
return nil
288+
}
289+
return makeResponse(documentID: snapshot.documentID, data: snapshot.data())
286290
}
287291

288292
func makeResponse(from snapshot: DocumentSnapshot) -> TodoResponse? {
@@ -337,5 +341,6 @@ private extension TodoService {
337341
case dueDate
338342
case tags
339343
case kind
344+
case deletingAt // 삭제 요청은 되었지만, 5초 유예 후 최종 삭제되기 전 상태
340345
}
341346
}

Firebase/functions/src/index.ts

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,11 @@ import {
3838
removeStaleTodoReceipts
3939
} from "./todo/remove";
4040

41+
import {
42+
requestTodoDeletion,
43+
completeTodoDeletion
44+
} from "./todo/deletion";
45+
4146

4247
// .env 파일 로드
4348
dotenv.config({
@@ -77,5 +82,7 @@ export {
7782
export {
7883
removeTodoNotificationDocuments,
7984
removeCompletedTodoReceipts,
80-
removeStaleTodoReceipts
85+
removeStaleTodoReceipts,
86+
requestTodoDeletion,
87+
completeTodoDeletion
8188
};
Lines changed: 189 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,189 @@
1+
import {onCall, HttpsError} from "firebase-functions/v2/https";
2+
import {onTaskDispatched} from "firebase-functions/v2/tasks";
3+
import {getFunctions} from "firebase-admin/functions";
4+
import * as admin from "firebase-admin";
5+
import * as logger from "firebase-functions/logger";
6+
7+
const LOCATION = "asia-northeast3";
8+
const DELETE_DELAY_SECONDS = 5;
9+
const QUERY_BATCH_SIZE = 200;
10+
11+
type TodoDeletionTaskData = {
12+
userId: string;
13+
todoId: string;
14+
createdAt?: FirebaseFirestore.Timestamp | Date | null;
15+
};
16+
17+
export const requestTodoDeletion = onCall({
18+
cors: true,
19+
maxInstances: 10,
20+
region: LOCATION,
21+
},
22+
async (request) => {
23+
const userId = request.auth?.uid;
24+
const todoId = typeof request.data?.todoId === "string" ? request.data.todoId.trim() : "";
25+
26+
if (!userId) {
27+
throw new HttpsError("unauthenticated", "인증된 사용자가 아닙니다.");
28+
}
29+
30+
if (!todoId) {
31+
throw new HttpsError("invalid-argument", "todoId가 필요합니다.");
32+
}
33+
34+
const todoRef = admin.firestore().doc(`users/${userId}/todoLists/${todoId}`);
35+
const todoSnapshot = await todoRef.get();
36+
37+
if (!todoSnapshot.exists) {
38+
throw new HttpsError("not-found", "Todo를 찾을 수 없습니다.");
39+
}
40+
41+
const taskRef = admin.firestore().collection("todoDeletionTasks").doc();
42+
const taskData = {
43+
userId,
44+
todoId,
45+
createdAt: admin.firestore.FieldValue.serverTimestamp()
46+
};
47+
48+
try {
49+
await taskRef.set(taskData);
50+
const todoRef = admin.firestore().doc(`users/${userId}/todoLists/${todoId}`);
51+
await todoRef.set({
52+
// deletingAt: 삭제 요청은 되었지만, 5초 유예 후 최종 삭제되기 전 상태를 의미한다.
53+
deletingAt: admin.firestore.FieldValue.serverTimestamp()
54+
}, {merge: true});
55+
56+
await updateNotificationsDeletingAt(
57+
userId,
58+
todoId,
59+
admin.firestore.FieldValue.serverTimestamp()
60+
);
61+
62+
const queue = getFunctions().taskQueue(
63+
`locations/${LOCATION}/functions/completeTodoDeletion`
64+
);
65+
await queue.enqueue(
66+
{taskId: taskRef.id},
67+
{scheduleDelaySeconds: DELETE_DELAY_SECONDS}
68+
);
69+
} catch (error) {
70+
try {
71+
await taskRef.delete();
72+
} catch (cleanupError) {
73+
logger.warn("todoDeletionTasks 정리 실패", {
74+
userId,
75+
todoId,
76+
taskId: taskRef.id,
77+
error: normalizeError(cleanupError)
78+
});
79+
}
80+
81+
const todoRef = admin.firestore().doc(`users/${userId}/todoLists/${todoId}`);
82+
const todoSnapshotForCleanup = await todoRef.get();
83+
84+
if (todoSnapshotForCleanup.exists) {
85+
await todoRef.update({
86+
deletingAt: admin.firestore.FieldValue.delete()
87+
});
88+
}
89+
90+
await updateNotificationsDeletingAt(
91+
userId,
92+
todoId,
93+
admin.firestore.FieldValue.delete()
94+
);
95+
logger.error("todo 삭제 요청 실패", {
96+
userId,
97+
todoId,
98+
error: normalizeError(error)
99+
});
100+
throw new HttpsError("internal", "Todo 삭제 요청에 실패했습니다.");
101+
}
102+
103+
return {success: true};
104+
}
105+
);
106+
107+
export const completeTodoDeletion = onTaskDispatched({
108+
region: LOCATION,
109+
retryConfig: {maxAttempts: 3, minBackoffSeconds: 5},
110+
rateLimits: {maxDispatchesPerSecond: 200},
111+
},
112+
async (request) => {
113+
const taskId = typeof request.data?.taskId === "string" ? request.data.taskId.trim() : "";
114+
if (!taskId) {
115+
logger.warn("유효하지 않은 todo 삭제 payload", request.data);
116+
return;
117+
}
118+
119+
const taskRef = admin.firestore().collection("todoDeletionTasks").doc(taskId);
120+
const taskSnapshot = await taskRef.get();
121+
if (!taskSnapshot.exists) { return; }
122+
123+
const taskData = taskSnapshot.data() as TodoDeletionTaskData | undefined;
124+
const userId = typeof taskData?.userId === "string" ? taskData.userId : "";
125+
const todoId = typeof taskData?.todoId === "string" ? taskData.todoId : "";
126+
if (!userId || !todoId) {
127+
logger.warn("todoDeletionTasks 문서 형식이 올바르지 않습니다.", {taskId});
128+
return;
129+
}
130+
131+
const todoRef = admin.firestore().doc(`users/${userId}/todoLists/${todoId}`);
132+
133+
try {
134+
const todoSnapshot = await todoRef.get();
135+
const deletingAt = todoSnapshot.data()?.deletingAt;
136+
137+
if (!todoSnapshot.exists || !deletingAt) {
138+
await taskRef.delete();
139+
return;
140+
}
141+
142+
await todoRef.delete();
143+
await taskRef.delete();
144+
} catch (error) {
145+
logger.error("todo 최종 삭제 실패", {
146+
userId,
147+
todoId,
148+
taskId,
149+
error: normalizeError(error)
150+
});
151+
throw error;
152+
}
153+
}
154+
);
155+
156+
async function updateNotificationsDeletingAt(
157+
userId: string,
158+
todoId: string,
159+
fieldValue: FirebaseFirestore.FieldValue
160+
): Promise<void> {
161+
while (true) {
162+
const snapshot = await admin.firestore()
163+
.collection(`users/${userId}/notifications`)
164+
.where("todoId", "==", todoId)
165+
.limit(QUERY_BATCH_SIZE)
166+
.get();
167+
168+
if (snapshot.empty) { return; }
169+
170+
const batch = admin.firestore().batch();
171+
snapshot.docs.forEach((document) => {
172+
batch.update(document.ref, {
173+
deletingAt: fieldValue
174+
});
175+
});
176+
await batch.commit();
177+
178+
if (snapshot.size < QUERY_BATCH_SIZE) { return; }
179+
}
180+
}
181+
182+
function normalizeError(error: unknown): Record<string, unknown> {
183+
const normalized = error as {code?: unknown; message?: unknown; stack?: unknown};
184+
return {
185+
code: normalized?.code ?? null,
186+
message: normalized?.message ?? String(error),
187+
stack: normalized?.stack ?? null
188+
};
189+
}

0 commit comments

Comments
 (0)