Skip to content

Commit 96e83d9

Browse files
committed
refactor: todo 삭제 실행 취소를 firebase function으로 처리
1 parent 17db96f commit 96e83d9

10 files changed

Lines changed: 129 additions & 11 deletions

File tree

DevLog/App/Assembler/DomainAssembler.swift

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -66,6 +66,10 @@ private extension DomainAssembler {
6666
container.register(DeleteTodoUseCase.self) {
6767
DeleteTodoUseCaseImpl(container.resolve(TodoRepository.self))
6868
}
69+
70+
container.register(UndoDeleteTodoUseCase.self) {
71+
UndoDeleteTodoUseCaseImpl(container.resolve(TodoRepository.self))
72+
}
6973
}
7074

7175
func registerUserDataUseCases(_ container: DIContainer) {

DevLog/Data/Repository/TodoRepositoryImpl.swift

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,4 +33,8 @@ final class TodoRepositoryImpl: TodoRepository {
3333
func deleteTodo(_ todoId: String) async throws {
3434
try await todoService.deleteTodo(todoId: todoId)
3535
}
36+
37+
func undoDeleteTodo(_ todoId: String) async throws {
38+
try await todoService.undoDeleteTodo(todoId: todoId)
39+
}
3640
}

DevLog/Domain/Protocol/TodoRepository.swift

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,4 +12,5 @@ protocol TodoRepository {
1212
func fetchTodo(_ todoId: String) async throws -> Todo
1313
func upsertTodo(_ todo: Todo) async throws
1414
func deleteTodo(_ todoId: String) async throws
15+
func undoDeleteTodo(_ todoId: String) async throws
1516
}
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
//
2+
// UndoDeleteTodoUseCase.swift
3+
// DevLog
4+
//
5+
// Created by opfic on 3/15/26.
6+
//
7+
8+
protocol UndoDeleteTodoUseCase {
9+
func execute(_ todoId: String) async throws
10+
}
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
//
2+
// UndoDeleteTodoUseCaseImpl.swift
3+
// DevLog
4+
//
5+
// Created by opfic on 3/15/26.
6+
//
7+
8+
final class UndoDeleteTodoUseCaseImpl: UndoDeleteTodoUseCase {
9+
private let repository: TodoRepository
10+
11+
init(_ repository: TodoRepository) {
12+
self.repository = repository
13+
}
14+
15+
func execute(_ todoId: String) async throws {
16+
try await repository.undoDeleteTodo(todoId)
17+
}
18+
}

DevLog/Infra/Service/TodoService.swift

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -188,6 +188,22 @@ final class TodoService {
188188
}
189189
}
190190

191+
func undoDeleteTodo(todoId: String) async throws {
192+
guard Auth.auth().currentUser?.uid != nil else { throw AuthError.notAuthenticated }
193+
194+
logger.info("Undoing todo deletion: \(todoId)")
195+
196+
do {
197+
let function = functions.httpsCallable("undoTodoDeletion")
198+
_ = try await function.call(["todoId": todoId])
199+
200+
logger.info("Successfully undone todo deletion")
201+
} catch {
202+
logger.error("Failed to undo todo deletion", error: error)
203+
throw error
204+
}
205+
}
206+
191207
func fetchTodo(todoId: String) async throws -> TodoResponse {
192208
guard let uid = Auth.auth().currentUser?.uid else { throw AuthError.notAuthenticated }
193209

DevLog/Presentation/ViewModel/TodoListViewModel.swift

Lines changed: 14 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -70,6 +70,7 @@ final class TodoListViewModel: Store {
7070
case search(String)
7171
case upsert(Todo)
7272
case delete(TodoListItem, Int)
73+
case undoDelete(String)
7374
case toggleCompleted(TodoListItem)
7475
case togglePinned(TodoListItem)
7576
}
@@ -81,6 +82,7 @@ final class TodoListViewModel: Store {
8182
private let fetchTodoByIdUseCase: FetchTodoByIdUseCase
8283
private let upsertTodoUseCase: UpsertTodoUseCase
8384
private let deleteTodoUseCase: DeleteTodoUseCase
85+
private let undoDeleteTodoUseCase: UndoDeleteTodoUseCase
8486
private var pendingTask: (TodoListItem, Int)?
8587
private var nextCursor: TodoCursor?
8688

@@ -89,12 +91,14 @@ final class TodoListViewModel: Store {
8991
fetchTodoByIdUseCase: FetchTodoByIdUseCase,
9092
upsertTodoUseCase: UpsertTodoUseCase,
9193
deleteTodoUseCase: DeleteTodoUseCase,
94+
undoDeleteTodoUseCase: UndoDeleteTodoUseCase,
9295
kind: TodoKind
9396
) {
9497
self.fetchTodosUseCase = fetchTodosUseCase
9598
self.fetchTodoByIdUseCase = fetchTodoByIdUseCase
9699
self.upsertTodoUseCase = upsertTodoUseCase
97100
self.deleteTodoUseCase = deleteTodoUseCase
101+
self.undoDeleteTodoUseCase = undoDeleteTodoUseCase
98102
self.state = State(
99103
kind: kind,
100104
query: TodoQuery(kind: kind)
@@ -225,6 +229,15 @@ final class TodoListViewModel: Store {
225229
send(.setAlert(true))
226230
}
227231
}
232+
case .undoDelete(let todoId):
233+
Task {
234+
do {
235+
try await undoDeleteTodoUseCase.execute(todoId)
236+
} catch {
237+
send(.setAlert(true))
238+
send(.refresh)
239+
}
240+
}
228241
}
229242
}
230243
}
@@ -240,19 +253,12 @@ private extension TodoListViewModel {
240253
case .setShowEditor(let value):
241254
state.showEditor = value
242255
case .swipeTodo(let todo):
243-
var effects: [SideEffect] = []
244-
if let (pendingItem, _) = pendingTask {
245-
effects = [.delete(pendingItem.id)]
246-
}
247-
248256
if let index = state.todos.firstIndex(where: { $0.id == todo.id }) {
249257
pendingTask = (todo, index)
250258
state.todos.remove(at: index)
251259
setToast(&state, isPresented: true)
252260
return [.delete(todo, index)]
253261
}
254-
255-
return effects
256262
case .setSortTarget(let target):
257263
state.query.sortTarget = target
258264
self.nextCursor = nil
@@ -293,6 +299,7 @@ private extension TodoListViewModel {
293299
state.todos.insert(todo, at: index)
294300
}
295301
pendingTask = nil
302+
return [.undoDelete(todo.id)]
296303
default:
297304
break
298305
}
@@ -302,11 +309,7 @@ private extension TodoListViewModel {
302309
func reduceByView(_ action: Action, state: inout State) -> [SideEffect] {
303310
switch action {
304311
case .confirmDelete:
305-
guard let (item, _) = pendingTask else {
306-
return []
307-
}
308312
pendingTask = nil
309-
return [.delete(item.id)]
310313
case .onAppear:
311314
return [.fetch]
312315
case .loadNextPage:

DevLog/UI/Home/HomeView.swift

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@ struct HomeView: View {
3030
fetchTodoByIdUseCase: container.resolve(FetchTodoByIdUseCase.self),
3131
upsertTodoUseCase: container.resolve(UpsertTodoUseCase.self),
3232
deleteTodoUseCase: container.resolve(DeleteTodoUseCase.self),
33+
undoDeleteTodoUseCase: container.resolve(UndoDeleteTodoUseCase.self),
3334
kind: todoKind
3435
))
3536
.environment(router)

Firebase/functions/src/index.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,7 @@ import {
4040

4141
import {
4242
requestTodoDeletion,
43+
undoTodoDeletion,
4344
completeTodoDeletion
4445
} from "./todo/deletion";
4546

@@ -84,5 +85,6 @@ export {
8485
removeCompletedTodoReceipts,
8586
removeStaleTodoReceipts,
8687
requestTodoDeletion,
88+
undoTodoDeletion,
8789
completeTodoDeletion
8890
};

Firebase/functions/src/todo/deletion.ts

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -104,6 +104,65 @@ export const requestTodoDeletion = onCall({
104104
}
105105
);
106106

107+
export const undoTodoDeletion = onCall({
108+
cors: true,
109+
maxInstances: 10,
110+
region: LOCATION,
111+
},
112+
async (request) => {
113+
const userId = request.auth?.uid;
114+
const todoId = typeof request.data?.todoId === "string" ? request.data.todoId.trim() : "";
115+
116+
if (!userId) {
117+
throw new HttpsError("unauthenticated", "인증된 사용자가 아닙니다.");
118+
}
119+
120+
if (!todoId) {
121+
throw new HttpsError("invalid-argument", "todoId가 필요합니다.");
122+
}
123+
124+
const taskSnapshot = await admin.firestore()
125+
.collection("todoDeletionTasks")
126+
.where("userId", "==", userId)
127+
.where("todoId", "==", todoId)
128+
.get();
129+
130+
try {
131+
const todoRef = admin.firestore().doc(`users/${userId}/todoLists/${todoId}`);
132+
const todoSnapshot = await todoRef.get();
133+
134+
if (todoSnapshot.exists) {
135+
await todoRef.update({
136+
deletingAt: admin.firestore.FieldValue.delete()
137+
});
138+
}
139+
140+
await updateNotificationsDeletingAt(
141+
userId,
142+
todoId,
143+
admin.firestore.FieldValue.delete()
144+
);
145+
146+
if (!taskSnapshot.empty) {
147+
const batch = admin.firestore().batch();
148+
taskSnapshot.docs.forEach((document) => {
149+
batch.delete(document.ref);
150+
});
151+
await batch.commit();
152+
}
153+
} catch (error) {
154+
logger.error("todo 삭제 취소 실패", {
155+
userId,
156+
todoId,
157+
error: normalizeError(error)
158+
});
159+
throw new HttpsError("internal", "Todo 삭제 취소에 실패했습니다.");
160+
}
161+
162+
return {success: true};
163+
}
164+
);
165+
107166
export const completeTodoDeletion = onTaskDispatched({
108167
region: LOCATION,
109168
retryConfig: {maxAttempts: 3, minBackoffSeconds: 5},

0 commit comments

Comments
 (0)