diff --git a/DevLog/App/Assembler/DomainAssembler.swift b/DevLog/App/Assembler/DomainAssembler.swift index a2e5a4e8..00397f35 100644 --- a/DevLog/App/Assembler/DomainAssembler.swift +++ b/DevLog/App/Assembler/DomainAssembler.swift @@ -95,6 +95,10 @@ private extension DomainAssembler { DeletePushNotificationUseCaseImpl(container.resolve(PushNotificationRepository.self)) } + container.register(UndoDeletePushNotificationUseCase.self) { + UndoDeletePushNotificationUseCaseImpl(container.resolve(PushNotificationRepository.self)) + } + container.register(FetchPushNotificationsUseCase.self) { FetchPushNotificationsUseCaseImpl(container.resolve(PushNotificationRepository.self)) } @@ -116,6 +120,10 @@ private extension DomainAssembler { container.register(DeleteWebPageUseCase.self) { DeleteWebPageUseCaseImpl(container.resolve(WebPageRepository.self)) } + + container.register(UndoDeleteWebPageUseCase.self) { + UndoDeleteWebPageUseCaseImpl(container.resolve(WebPageRepository.self)) + } } func registerUserPreferencesUseCases(_ container: DIContainer) { diff --git a/DevLog/Data/Repository/PushNotificationRepositoryImpl.swift b/DevLog/Data/Repository/PushNotificationRepositoryImpl.swift index 1d4fba34..e5b7502d 100644 --- a/DevLog/Data/Repository/PushNotificationRepositoryImpl.swift +++ b/DevLog/Data/Repository/PushNotificationRepositoryImpl.swift @@ -56,6 +56,10 @@ final class PushNotificationRepositoryImpl: PushNotificationRepository { try await service.deleteNotification(notificationID) } + func undoDeleteNotification(_ notificationID: String) async throws { + try await service.undoDeleteNotification(notificationID) + } + // 푸시 알림 읽음/안읽음 토글 func toggleNotificationRead(_ todoId: String) async throws { try await service.toggleNotificationRead(todoId) diff --git a/DevLog/Data/Repository/WebPageRepositoryImpl.swift b/DevLog/Data/Repository/WebPageRepositoryImpl.swift index 0538bfce..21bded87 100644 --- a/DevLog/Data/Repository/WebPageRepositoryImpl.swift +++ b/DevLog/Data/Repository/WebPageRepositoryImpl.swift @@ -57,6 +57,10 @@ final class WebPageRepositoryImpl: WebPageRepository { try await webPageService.deleteWebPage(urlString) await metadataService.removeCachedImage(for: urlString) } + + func undoDelete(_ urlString: String) async throws { + try await webPageService.undoDeleteWebPage(urlString) + } } private extension WebPageRepositoryImpl { diff --git a/DevLog/Domain/Protocol/PushNotificationRepository.swift b/DevLog/Domain/Protocol/PushNotificationRepository.swift index 3b9bd77a..4c689b61 100644 --- a/DevLog/Domain/Protocol/PushNotificationRepository.swift +++ b/DevLog/Domain/Protocol/PushNotificationRepository.swift @@ -21,5 +21,6 @@ protocol PushNotificationRepository { limit: Int ) throws -> AnyPublisher func deleteNotification(_ notificationID: String) async throws + func undoDeleteNotification(_ notificationID: String) async throws func toggleNotificationRead(_ todoId: String) async throws } diff --git a/DevLog/Domain/Protocol/WebPageRepository.swift b/DevLog/Domain/Protocol/WebPageRepository.swift index 3da6ad66..8e399974 100644 --- a/DevLog/Domain/Protocol/WebPageRepository.swift +++ b/DevLog/Domain/Protocol/WebPageRepository.swift @@ -9,4 +9,5 @@ protocol WebPageRepository { func fetch(_ query: String) async throws -> [WebPage] func upsert(_ urlString: String) async throws func delete(_ urlString: String) async throws + func undoDelete(_ urlString: String) async throws } diff --git a/DevLog/Domain/UseCase/PushNotification/Delete/UndoDeletePushNotificationUseCase.swift b/DevLog/Domain/UseCase/PushNotification/Delete/UndoDeletePushNotificationUseCase.swift new file mode 100644 index 00000000..971f7397 --- /dev/null +++ b/DevLog/Domain/UseCase/PushNotification/Delete/UndoDeletePushNotificationUseCase.swift @@ -0,0 +1,10 @@ +// +// UndoDeletePushNotificationUseCase.swift +// DevLog +// +// Created by opfic on 3/16/26. +// + +protocol UndoDeletePushNotificationUseCase { + func execute(_ notificationID: String) async throws +} diff --git a/DevLog/Domain/UseCase/PushNotification/Delete/UndoDeletePushNotificationUseCaseImpl.swift b/DevLog/Domain/UseCase/PushNotification/Delete/UndoDeletePushNotificationUseCaseImpl.swift new file mode 100644 index 00000000..767b507b --- /dev/null +++ b/DevLog/Domain/UseCase/PushNotification/Delete/UndoDeletePushNotificationUseCaseImpl.swift @@ -0,0 +1,18 @@ +// +// UndoDeletePushNotificationUseCaseImpl.swift +// DevLog +// +// Created by opfic on 3/16/26. +// + +final class UndoDeletePushNotificationUseCaseImpl: UndoDeletePushNotificationUseCase { + private let repository: PushNotificationRepository + + init(_ repository: PushNotificationRepository) { + self.repository = repository + } + + func execute(_ notificationID: String) async throws { + try await repository.undoDeleteNotification(notificationID) + } +} diff --git a/DevLog/Domain/UseCase/WebPage/Upsert/UndoDeleteWebPageUseCase.swift b/DevLog/Domain/UseCase/WebPage/Upsert/UndoDeleteWebPageUseCase.swift new file mode 100644 index 00000000..19c653f8 --- /dev/null +++ b/DevLog/Domain/UseCase/WebPage/Upsert/UndoDeleteWebPageUseCase.swift @@ -0,0 +1,10 @@ +// +// UndoDeleteWebPageUseCase.swift +// DevLog +// +// Created by opfic on 3/16/26. +// + +protocol UndoDeleteWebPageUseCase { + func execute(_ urlString: String) async throws +} diff --git a/DevLog/Domain/UseCase/WebPage/Upsert/UndoDeleteWebPageUseCaseImpl.swift b/DevLog/Domain/UseCase/WebPage/Upsert/UndoDeleteWebPageUseCaseImpl.swift new file mode 100644 index 00000000..6afcfdfa --- /dev/null +++ b/DevLog/Domain/UseCase/WebPage/Upsert/UndoDeleteWebPageUseCaseImpl.swift @@ -0,0 +1,18 @@ +// +// UndoDeleteWebPageUseCaseImpl.swift +// DevLog +// +// Created by opfic on 3/16/26. +// + +final class UndoDeleteWebPageUseCaseImpl: UndoDeleteWebPageUseCase { + private let repository: WebPageRepository + + init(_ repository: WebPageRepository) { + self.repository = repository + } + + func execute(_ urlString: String) async throws { + try await repository.undoDelete(urlString) + } +} diff --git a/DevLog/Infra/Service/PushNotificationService.swift b/DevLog/Infra/Service/PushNotificationService.swift index 59e3de77..66d7eb71 100644 --- a/DevLog/Infra/Service/PushNotificationService.swift +++ b/DevLog/Infra/Service/PushNotificationService.swift @@ -8,9 +8,16 @@ import FirebaseAuth import Combine import FirebaseFirestore +import FirebaseFunctions final class PushNotificationService { + private enum FunctionName: String { + case requestPushNotificationDeletion + case undoPushNotificationDeletion + } + private let store = Firestore.firestore() + private let functions = Functions.functions(region: "asia-northeast3") private let logger = Logger(category: "PushNotificationService") /// 푸시 알림 On/Off 설정 @@ -174,13 +181,24 @@ final class PushNotificationService { /// 푸시 알림 기록 삭제 func deleteNotification(_ notificationID: String) async throws { do { - guard let uid = Auth.auth().currentUser?.uid else { throw AuthError.notAuthenticated } + guard Auth.auth().currentUser?.uid != nil else { throw AuthError.notAuthenticated } - let docRef = store.collection("users/\(uid)/notifications").document(notificationID) + let function = functions.httpsCallable(FunctionName.requestPushNotificationDeletion) + _ = try await function.call(["notificationId": notificationID]) + } catch { + logger.error("Failed to request notification deletion", error: error) + throw error + } + } + + func undoDeleteNotification(_ notificationID: String) async throws { + do { + guard Auth.auth().currentUser?.uid != nil else { throw AuthError.notAuthenticated } - try await docRef.delete() + let function = functions.httpsCallable(FunctionName.undoPushNotificationDeletion) + _ = try await function.call(["notificationId": notificationID]) } catch { - logger.error("Failed to delete notification", error: error) + logger.error("Failed to undo notification deletion", error: error) throw error } } diff --git a/DevLog/Infra/Service/WebPageService.swift b/DevLog/Infra/Service/WebPageService.swift index f2b05c97..1595771a 100644 --- a/DevLog/Infra/Service/WebPageService.swift +++ b/DevLog/Infra/Service/WebPageService.swift @@ -7,9 +7,16 @@ import FirebaseAuth import FirebaseFirestore +import FirebaseFunctions final class WebPageService { + private enum FunctionName: String { + case requestWebPageDeletion + case undoWebPageDeletion + } + private let store = Firestore.firestore() + private let functions = Functions.functions(region: "asia-northeast3") private let encoder = Firestore.Encoder() private let logger = Logger(category: "WebPageService") @@ -67,21 +74,37 @@ final class WebPageService { } func deleteWebPage(_ urlString: String) async throws { - logger.info("Deleting web page: \(urlString)") + logger.info("Requesting web page deletion: \(urlString)") - guard let uid = Auth.auth().currentUser?.uid else { + guard Auth.auth().currentUser?.uid != nil else { + logger.error("User not authenticated") + throw AuthError.notAuthenticated + } + + do { + let function = functions.httpsCallable(FunctionName.requestWebPageDeletion) + _ = try await function.call(["urlString": urlString]) + logger.info("Successfully requested web page deletion") + } catch { + logger.error("Failed to request web page deletion", error: error) + throw error + } + } + + func undoDeleteWebPage(_ urlString: String) async throws { + logger.info("Undoing web page deletion: \(urlString)") + + guard Auth.auth().currentUser?.uid != nil else { logger.error("User not authenticated") throw AuthError.notAuthenticated } do { - let documentID = documentID(for: urlString) - let docRef = store - .document("users/\(uid)/webPages/\(documentID)") - try await docRef.delete() - logger.info("Successfully deleted web page") + let function = functions.httpsCallable(FunctionName.undoWebPageDeletion) + _ = try await function.call(["urlString": urlString]) + logger.info("Successfully undone web page deletion") } catch { - logger.error("Failed to delete web page", error: error) + logger.error("Failed to undo web page deletion", error: error) throw error } } @@ -101,6 +124,9 @@ final class WebPageService { private extension WebPageService { func makeResponse(from snapshot: QueryDocumentSnapshot) -> WebPageResponse? { let data = snapshot.data() + if data[WebPageFieldKey.deletingAt.rawValue] is Timestamp { + return nil + } guard let title = data[WebPageFieldKey.title.rawValue] as? String, let url = data[WebPageFieldKey.url.rawValue] as? String, @@ -123,5 +149,6 @@ private extension WebPageService { case url case displayURL case imageURL + case deletingAt // 삭제 요청은 되었지만, 5초 유예 후 최종 삭제되기 전 상태 } } diff --git a/DevLog/Presentation/ViewModel/HomeViewModel.swift b/DevLog/Presentation/ViewModel/HomeViewModel.swift index 3f9e677e..b6868a87 100644 --- a/DevLog/Presentation/ViewModel/HomeViewModel.swift +++ b/DevLog/Presentation/ViewModel/HomeViewModel.swift @@ -23,7 +23,7 @@ final class HomeViewModel: Store { var reorderTodo: Bool = false var isRecentTodosLoading: Bool = false var isWebPageLoading: Bool = false - var isWebPageInputLoading: Bool = false + var isAppending: Bool = false var showAlert: Bool = false var alertTitle: String = "" var alertType: AlertType? @@ -45,23 +45,24 @@ final class HomeViewModel: Store { case updateWebPageURLInput(String) case updateSearching(Bool) case updateSearchText(String) - case upsertTodo(Todo) + case addTodo(Todo) case addWebPage case deleteWebPage(WebPageItem) case undoDeleteWebPage - case confirmDeleteWebPage case setToast(isPresented: Bool, type: ToastType? = nil) case fetchRecentTodos([RecentTodoItem]) case fetchWebPages([WebPageItem]) + case restoreWebPage(WebPageItem, Int) case setRecentTodosLoading(Bool) case setWebPageLoading(Bool) - case setWebPageInputLoading(Bool) + case setAppending(Bool) } enum SideEffect { - case upsertTodo(Todo) + case addTodo(Todo) case addWebPage(String) - case deleteWebPage(String) + case deleteWebPage(WebPageItem, Int) + case undoDeleteWebPage(String) case fetchRecentTodos case fetchWebPages case showModalAfterDelay(ModalType) @@ -86,19 +87,22 @@ final class HomeViewModel: Store { private let upsertTodoUseCase: UpsertTodoUseCase private let addWebPageUseCase: AddWebPageUseCase private let deleteWebPageUseCase: DeleteWebPageUseCase + private let undoDeleteWebPageUseCase: UndoDeleteWebPageUseCase private let fetchTodosUseCase: FetchTodosUseCase private let fetchWebPagesUseCase: FetchWebPagesUseCase - private var pendingTask: (WebPageItem, Int)? + private var deletedWebPageURLString: String? init( addWebPageUseCase: AddWebPageUseCase, deleteWebPageUseCase: DeleteWebPageUseCase, + undoDeleteWebPageUseCase: UndoDeleteWebPageUseCase, upsertTodoUseCase: UpsertTodoUseCase, fetchTodosUseCase: FetchTodosUseCase, fetchWebPagesUseCase: FetchWebPagesUseCase ) { self.addWebPageUseCase = addWebPageUseCase self.deleteWebPageUseCase = deleteWebPageUseCase + self.undoDeleteWebPageUseCase = undoDeleteWebPageUseCase self.upsertTodoUseCase = upsertTodoUseCase self.fetchTodosUseCase = fetchTodosUseCase self.fetchWebPagesUseCase = fetchWebPagesUseCase @@ -115,12 +119,11 @@ final class HomeViewModel: Store { .undoDeleteWebPage, .setToast: effects = reduceByUser(action, state: &state) - case .onAppear, .updateSearching, .updateSearchText, .upsertTodo, - .addWebPage, .confirmDeleteWebPage: + case .onAppear, .updateSearching, .updateSearchText, .addTodo, .addWebPage: effects = reduceByView(action, state: &state) - case .fetchRecentTodos, .fetchWebPages, .setRecentTodosLoading, - .setWebPageLoading, .setWebPageInputLoading: + case .fetchRecentTodos, .fetchWebPages, .restoreWebPage, .setRecentTodosLoading, + .setWebPageLoading, .setAppending: effects = reduceByRun(action, state: &state) } @@ -130,9 +133,10 @@ final class HomeViewModel: Store { func run(_ effect: SideEffect) { switch effect { - case .upsertTodo(let todo): + case .addTodo(let todo): Task { do { + send(.setAppending(true)) try await upsertTodoUseCase.execute(todo) let page = try await fetchRecentTodos() let items = page.items @@ -162,26 +166,47 @@ final class HomeViewModel: Store { case .addWebPage(let urlString): Task { do { - defer { send(.setWebPageInputLoading(false)) } - send(.setWebPageInputLoading(true)) + defer { send(.setAppending(false)) } + send(.setAppending(true)) try await addWebPageUseCase.execute(urlString) let pages = try await fetchWebPagesUseCase.execute("") send(.fetchWebPages(pages.map { WebPageItem(from: $0) })) } catch { - send(.setWebPageInputLoading(false)) send(.setAlert(isPresented: true, type: .error)) } } - case .deleteWebPage(let urlString): + case .deleteWebPage(let page, let index): Task { do { defer { send(.setWebPageLoading(false)) } send(.setWebPageLoading(true)) - try await deleteWebPageUseCase.execute(urlString) + try await deleteWebPageUseCase.execute(page.url.absoluteString) + } catch { + send(.restoreWebPage(page, index)) + send(.setAlert(isPresented: true, type: .error)) + } + } + case .undoDeleteWebPage(let urlString): + Task { + defer { send(.setWebPageLoading(false)) } + send(.setWebPageLoading(true)) + + var shouldPresentError = false + + do { + try await undoDeleteWebPageUseCase.execute(urlString) + } catch { + shouldPresentError = true + } + + do { let pages = try await fetchWebPagesUseCase.execute("") send(.fetchWebPages(pages.map { WebPageItem(from: $0) })) } catch { - send(.setWebPageLoading(false)) + shouldPresentError = true + } + + if shouldPresentError { send(.setAlert(isPresented: true, type: .error)) } } @@ -239,16 +264,20 @@ private extension HomeViewModel { setAlert(&state, isPresented: presented, type: type) case .deleteWebPage(let page): if let index = state.webPages.firstIndex(where: { $0.id == page.id }) { - pendingTask = (page, index) + deletedWebPageURLString = page.url.absoluteString state.webPages.remove(at: index) setToast(&state, isPresented: true, for: .deleteWebPage) + return [.deleteWebPage(page, index)] } case .undoDeleteWebPage: - guard let (page, index) = pendingTask else { return [] } - state.webPages.insert(page, at: index) - pendingTask = nil + guard let deletedWebPageURLString else { return [] } + self.deletedWebPageURLString = nil + return [.undoDeleteWebPage(deletedWebPageURLString)] case .setToast(let isPresented, let type): setToast(&state, isPresented: isPresented, for: type) + if !isPresented { + deletedWebPageURLString = nil + } default: break } @@ -263,8 +292,8 @@ private extension HomeViewModel { state.isSearching = isSearching case .updateSearchText(let text): state.searchText = text - case .upsertTodo(let todo): - return [.upsertTodo(todo)] + case .addTodo(let todo): + return [.addTodo(todo)] case .addWebPage: guard let normalizedURL = normalizedWebPageURL(state.webPageURLInput) else { setAlert(&state, isPresented: true, type: .invalidURL) @@ -272,10 +301,6 @@ private extension HomeViewModel { } setAlert(&state, isPresented: false, type: nil) return [.addWebPage(normalizedURL)] - case .confirmDeleteWebPage: - guard let (page, _) = pendingTask else { return [] } - pendingTask = nil - return [.deleteWebPage(page.url.absoluteString)] default: break } @@ -287,19 +312,23 @@ private extension HomeViewModel { case .fetchRecentTodos(let todos): state.recentTodos = todos case .fetchWebPages(let pages): - let filteredPages: [WebPageItem] - if let (pendingPage, _) = pendingTask { - filteredPages = pages.filter { $0.id != pendingPage.id } + state.webPages = pages + case .restoreWebPage(let page, let index): + if state.webPages.contains(where: { $0.id == page.id }) { break } + if index <= state.webPages.count { + state.webPages.insert(page, at: index) } else { - filteredPages = pages + state.webPages.append(page) + } + if deletedWebPageURLString == page.url.absoluteString { + deletedWebPageURLString = nil } - state.webPages = filteredPages case .setRecentTodosLoading(let isLoading): state.isRecentTodosLoading = isLoading case .setWebPageLoading(let isLoading): state.isWebPageLoading = isLoading - case .setWebPageInputLoading(let isLoading): - state.isWebPageInputLoading = isLoading + case .setAppending(let isLoading): + state.isAppending = isLoading default: break } diff --git a/DevLog/Presentation/ViewModel/PushNotificationListViewModel.swift b/DevLog/Presentation/ViewModel/PushNotificationListViewModel.swift index 8195f131..251a4051 100644 --- a/DevLog/Presentation/ViewModel/PushNotificationListViewModel.swift +++ b/DevLog/Presentation/ViewModel/PushNotificationListViewModel.swift @@ -30,7 +30,6 @@ final class PushNotificationListViewModel: Store { case deleteNotification(PushNotificationItem) case toggleRead(PushNotificationItem) case undoDelete - case confirmDelete case setAlert(isPresented: Bool) case setToast(isPresented: Bool) case setLoading(Bool) @@ -38,6 +37,7 @@ final class PushNotificationListViewModel: Store { case resetPagination case setHasMore(Bool) case syncNotifications([PushNotificationItem], nextCursor: PushNotificationCursor?, hasMore: Bool) + case restoreNotification(PushNotificationItem, Int) case toggleSortOption case setTimeFilter(PushNotificationQuery.TimeFilter) case toggleUnreadOnly @@ -48,28 +48,32 @@ final class PushNotificationListViewModel: Store { enum SideEffect { case fetchNotifications(PushNotificationQuery, cursor: PushNotificationCursor?) - case delete(PushNotificationItem) + case delete(PushNotificationItem, Int) + case undoDelete(String) case toggleRead(String) } private(set) var state: State private let fetchUseCase: FetchPushNotificationsUseCase private let deleteUseCase: DeletePushNotificationUseCase + private let undoDeleteUseCase: UndoDeletePushNotificationUseCase private let toggleReadUseCase: TogglePushNotificationReadUseCase private let fetchQueryUseCase: FetchPushNotificationQueryUseCase private let updateQueryUseCase: UpdatePushNotificationQueryUseCase - private var pendingTask: (PushNotificationItem, Int)? + private var undoDeleteNotificationId: String? private var cancellable: AnyCancellable? init( fetchUseCase: FetchPushNotificationsUseCase, deleteUseCase: DeletePushNotificationUseCase, + undoDeleteUseCase: UndoDeletePushNotificationUseCase, toggleReadUseCase: TogglePushNotificationReadUseCase, fetchQueryUseCase: FetchPushNotificationQueryUseCase, updateQueryUseCase: UpdatePushNotificationQueryUseCase ) { self.fetchUseCase = fetchUseCase self.deleteUseCase = deleteUseCase + self.undoDeleteUseCase = undoDeleteUseCase self.toggleReadUseCase = toggleReadUseCase self.fetchQueryUseCase = fetchQueryUseCase self.updateQueryUseCase = updateQueryUseCase @@ -95,10 +99,11 @@ final class PushNotificationListViewModel: Store { .setTimeFilter, .toggleUnreadOnly, .resetFilters, .tapNotification: effects = reduceByUser(action, state: &state) - case .fetchNotifications, .confirmDelete, .setToast, .setSelectedTodoId, .loadNextPage: + case .fetchNotifications, .setToast, .setSelectedTodoId, .loadNextPage: effects = reduceByView(action, state: &state) - case .setLoading, .appendNotifications, .resetPagination, .setHasMore, .syncNotifications: + case .setLoading, .appendNotifications, .resetPagination, .setHasMore, + .syncNotifications, .restoreNotification: effects = reduceByRun(action, state: &state) } @@ -139,16 +144,31 @@ final class PushNotificationListViewModel: Store { } } - case .delete(let notification): + case .delete(let item, let index): Task { do { defer { send(.setLoading(false)) } send(.setLoading(true)) - try await deleteUseCase.execute(notification.id) + try await deleteUseCase.execute(item.id) } catch { + send(.restoreNotification(item, index)) send(.setAlert(isPresented: true)) } } + case .undoDelete(let notificationId): + Task { + // defer을 통해 setLoading을 false로 제어하지 않는 이유 + // send(.fetchNotifications)를 통해 false로 처리될 것이기 때문 + send(.setLoading(true)) + + do { + try await undoDeleteUseCase.execute(notificationId) + } catch { + send(.setAlert(isPresented: true)) + } + + send(.fetchNotifications) + } case .toggleRead(let todoId): Task { do { @@ -168,27 +188,22 @@ private extension PushNotificationListViewModel { func reduceByUser(_ action: Action, state: inout State) -> [SideEffect] { switch action { case .deleteNotification(let item): - var effects: [SideEffect] = [] - if let (pendingItem, _) = pendingTask { - effects = [.delete(pendingItem)] - } - if let index = state.notifications.firstIndex(where: { $0.id == item.id }) { - pendingTask = (item, index) + undoDeleteNotificationId = item.id state.notifications.remove(at: index) setToast(&state, isPresented: true) + return [.delete(item, index)] } - - return effects + return [] case .toggleRead(let item): if let index = state.notifications.firstIndex(where: { $0.id == item.id }) { state.notifications[index].isRead.toggle() return [.toggleRead(item.todoId)] } case .undoDelete: - guard let (item, index) = pendingTask else { return [] } - state.notifications.insert(item, at: index) - pendingTask = nil + guard let undoDeleteNotificationId else { return [] } + self.undoDeleteNotificationId = nil + return [.undoDelete(undoDeleteNotificationId)] case .setAlert(let isPresented): setAlert(&state, isPresented: isPresented) case .toggleSortOption: @@ -229,14 +244,13 @@ private extension PushNotificationListViewModel { state.nextCursor = nil return [.fetchNotifications(state.query, cursor: nil)] case .loadNextPage: - guard state.hasMore, !state.isLoading, pendingTask == nil else { return [] } + guard state.hasMore, !state.isLoading else { return [] } return [.fetchNotifications(state.query, cursor: state.nextCursor)] - case .confirmDelete: - guard let (item, _) = pendingTask else { return [] } - pendingTask = nil - return [.delete(item)] case .setToast(let isPresented): setToast(&state, isPresented: isPresented) + if !isPresented { + undoDeleteNotificationId = nil + } case .setSelectedTodoId(let todoId): state.selectedTodoId = todoId default: @@ -255,24 +269,24 @@ private extension PushNotificationListViewModel { state.notifications = [] state.nextCursor = nil case .appendNotifications(let notifications, let nextCursor): - let filteredNotifications: [PushNotificationItem] - if let (pendingItem, _) = pendingTask { - filteredNotifications = notifications.filter { $0.id != pendingItem.id } - } else { - filteredNotifications = notifications - } - state.notifications.append(contentsOf: filteredNotifications) + state.notifications.append(contentsOf: notifications) state.nextCursor = nextCursor case .syncNotifications(let notifications, let nextCursor, let hasMore): - let filteredNotifications: [PushNotificationItem] - if let (pendingItem, _) = pendingTask { - filteredNotifications = notifications.filter { $0.id != pendingItem.id } - } else { - filteredNotifications = notifications - } - state.notifications = filteredNotifications + state.notifications = notifications state.nextCursor = nextCursor state.hasMore = hasMore + case .restoreNotification(let notification, let index): + if state.notifications.contains(where: { $0.id == notification.id }) { break } + + if index <= state.notifications.count { + state.notifications.insert(notification, at: index) + } else { + state.notifications.append(notification) + } + + if undoDeleteNotificationId == notification.id { + undoDeleteNotificationId = nil + } default: break } diff --git a/DevLog/Presentation/ViewModel/TodoListViewModel.swift b/DevLog/Presentation/ViewModel/TodoListViewModel.swift index 5bfc4f70..b5480c54 100644 --- a/DevLog/Presentation/ViewModel/TodoListViewModel.swift +++ b/DevLog/Presentation/ViewModel/TodoListViewModel.swift @@ -137,6 +137,7 @@ final class TodoListViewModel: Store { return effects } + // swiftlint:disable function_body_length func run(_ effect: SideEffect) { switch effect { case .fetch: @@ -230,15 +231,21 @@ final class TodoListViewModel: Store { } case .undoDelete(let todoId): Task { + // defer을 통해 setLoading을 false로 제어하지 않는 이유 + // send(.refresh)를 통해 false로 처리될 것이기 때문 + send(.setLoading(true)) + do { try await undoDeleteTodoUseCase.execute(todoId) - send(.refresh) } catch { - send(.setAlert(true)); send(.refresh) + send(.setAlert(true)) } + + send(.refresh) } } } + // swiftlint:enable function_body_length } // MARK: - Reduce Methods diff --git a/DevLog/UI/Common/Component/WebItemRow.swift b/DevLog/UI/Common/Component/WebItemRow.swift index e6e61cc1..9a7d2e9b 100644 --- a/DevLog/UI/Common/Component/WebItemRow.swift +++ b/DevLog/UI/Common/Component/WebItemRow.swift @@ -16,8 +16,8 @@ struct WebItemRow: View { var body: some View { HStack { thumbnail - .frame(width: sceneWidth / 10, height: sceneWidth / 10) - .clipShape(RoundedRectangle(cornerRadius: 10)) + .frame(width: sceneWidth / 10, height: sceneWidth / 10) + .clipShape(RoundedRectangle(cornerRadius: 10)) VStack(alignment: .leading) { Text(item.title) diff --git a/DevLog/UI/Common/MainView.swift b/DevLog/UI/Common/MainView.swift index 46012fa6..c26676c6 100644 --- a/DevLog/UI/Common/MainView.swift +++ b/DevLog/UI/Common/MainView.swift @@ -15,6 +15,7 @@ struct MainView: View { HomeView(viewModel: HomeViewModel( addWebPageUseCase: container.resolve(AddWebPageUseCase.self), deleteWebPageUseCase: container.resolve(DeleteWebPageUseCase.self), + undoDeleteWebPageUseCase: container.resolve(UndoDeleteWebPageUseCase.self), upsertTodoUseCase: container.resolve(UpsertTodoUseCase.self), fetchTodosUseCase: container.resolve(FetchTodosUseCase.self), fetchWebPagesUseCase: container.resolve(FetchWebPagesUseCase.self) @@ -37,6 +38,7 @@ struct MainView: View { PushNotificationListView(viewModel: PushNotificationListViewModel( fetchUseCase: container.resolve(FetchPushNotificationsUseCase.self), deleteUseCase: container.resolve(DeletePushNotificationUseCase.self), + undoDeleteUseCase: container.resolve(UndoDeletePushNotificationUseCase.self), toggleReadUseCase: container.resolve(TogglePushNotificationReadUseCase.self), fetchQueryUseCase: container.resolve(FetchPushNotificationQueryUseCase.self), updateQueryUseCase: container.resolve(UpdatePushNotificationQueryUseCase.self) diff --git a/DevLog/UI/Home/HomeView.swift b/DevLog/UI/Home/HomeView.swift index bae1d16f..0d1fdcfe 100644 --- a/DevLog/UI/Home/HomeView.swift +++ b/DevLog/UI/Home/HomeView.swift @@ -81,7 +81,7 @@ struct HomeView: View { if let selectedKind = viewModel.state.selectedTodoKind { TodoEditorView( viewModel: TodoEditorViewModel(kind: selectedKind), - onSubmit: { viewModel.send(.upsertTodo($0)) } + onSubmit: { viewModel.send(.addTodo($0)) } ) } } @@ -113,8 +113,7 @@ struct HomeView: View { set: { viewModel.send(.setToast(isPresented: $0)) } ), duration: 5, - action: { viewModel.send(.undoDeleteWebPage) }, - onDismiss: { viewModel.send(.confirmDeleteWebPage) } + action: { viewModel.send(.undoDeleteWebPage) } ) { Label(viewModel.state.toastMessage, systemImage: "arrow.uturn.left") .font(.caption) @@ -124,7 +123,7 @@ struct HomeView: View { viewModel.send(.onAppear) } .overlay { - if viewModel.state.isWebPageInputLoading { + if viewModel.state.isAppending { LoadingView() } } @@ -191,16 +190,14 @@ struct HomeView: View { private var recentTodoSection: some View { Section { - if viewModel.state.recentTodos.isEmpty { - if viewModel.state.isRecentTodosLoading { - LoadingView() - } else { - HStack { - Spacer() - Text("최근 수정한 Todo가 없습니다.") - .font(.callout) - Spacer() - } + if viewModel.state.isRecentTodosLoading { + LoadingView() + } else if viewModel.state.recentTodos.isEmpty { + HStack { + Spacer() + Text("최근 수정한 Todo가 없습니다.") + .font(.callout) + Spacer() } } else { ForEach(viewModel.state.recentTodos, id: \.id) { todo in @@ -223,16 +220,15 @@ struct HomeView: View { private var webPageSection: some View { Section { - if viewModel.state.webPages.isEmpty { - if viewModel.state.isWebPageLoading { - LoadingView() - } else { - HStack { - Spacer() - Text("저장한 Web Page가 표시됩니다.") - .font(.callout) - Spacer() - } + if viewModel.state.isWebPageLoading { + LoadingView() + .id(UUID()) // id 부여를 통해 렌더링 강제 + } else if viewModel.state.webPages.isEmpty { + HStack { + Spacer() + Text("저장한 Web Page가 표시됩니다.") + .font(.callout) + Spacer() } } else { ForEach(viewModel.state.webPages, id: \.id) { page in diff --git a/DevLog/UI/PushNotification/PushNotificationListView.swift b/DevLog/UI/PushNotification/PushNotificationListView.swift index 823b9c1a..b60c433a 100644 --- a/DevLog/UI/PushNotification/PushNotificationListView.swift +++ b/DevLog/UI/PushNotification/PushNotificationListView.swift @@ -52,8 +52,7 @@ struct PushNotificationListView: View { get: { viewModel.state.showToast }, set: { viewModel.send(.setToast(isPresented: $0)) }), duration: 5, - action: { viewModel.send(.undoDelete) }, - onDismiss: { viewModel.send(.confirmDelete) } + action: { viewModel.send(.undoDelete) } ) { Label(viewModel.state.toastMessage, systemImage: "arrow.uturn.left") .font(.caption) diff --git a/Firebase/functions/src/common/error.ts b/Firebase/functions/src/common/error.ts new file mode 100644 index 00000000..a0229bcc --- /dev/null +++ b/Firebase/functions/src/common/error.ts @@ -0,0 +1,8 @@ +export function normalizeError(error: unknown): Record { + const normalized = error as {code?: unknown; message?: unknown; stack?: unknown}; + return { + code: normalized?.code ?? null, + message: normalized?.message ?? String(error), + stack: normalized?.stack ?? null + }; +} diff --git a/Firebase/functions/src/index.ts b/Firebase/functions/src/index.ts index 6ab429f8..fb12553b 100644 --- a/Firebase/functions/src/index.ts +++ b/Firebase/functions/src/index.ts @@ -44,6 +44,18 @@ import { completeTodoDeletion } from "./todo/deletion"; +import { + requestPushNotificationDeletion, + undoPushNotificationDeletion, + completePushNotificationDeletion +} from "./notification/deletion"; + +import { + requestWebPageDeletion, + undoWebPageDeletion, + completeWebPageDeletion +} from "./webPage/deletion"; + // .env 파일 로드 dotenv.config({ @@ -86,5 +98,11 @@ export { removeStaleTodoReceipts, requestTodoDeletion, undoTodoDeletion, - completeTodoDeletion + completeTodoDeletion, + requestPushNotificationDeletion, + undoPushNotificationDeletion, + completePushNotificationDeletion, + requestWebPageDeletion, + undoWebPageDeletion, + completeWebPageDeletion }; diff --git a/Firebase/functions/src/notification/deletion.ts b/Firebase/functions/src/notification/deletion.ts new file mode 100644 index 00000000..13a6620e --- /dev/null +++ b/Firebase/functions/src/notification/deletion.ts @@ -0,0 +1,196 @@ +import { onCall, HttpsError } from "firebase-functions/v2/https"; +import { onTaskDispatched } from "firebase-functions/v2/tasks"; +import { getFunctions } from "firebase-admin/functions"; +import * as admin from "firebase-admin"; +import * as logger from "firebase-functions/logger"; +import { normalizeError } from "../common/error"; + +const LOCATION = "asia-northeast3"; +const DELETE_DELAY_SECONDS = 5; + +type NotificationDeletionTaskData = { + userId: string; + notificationId: string; + createdAt?: FirebaseFirestore.Timestamp | Date | null; +}; + +export const requestPushNotificationDeletion = onCall({ + cors: true, + maxInstances: 10, + region: LOCATION, + }, + async (request) => { + const userId = request.auth?.uid; + const notificationId = typeof request.data?.notificationId === "string" ? + request.data.notificationId.trim() : + ""; + + if (!userId) { + throw new HttpsError("unauthenticated", "인증된 사용자가 아닙니다."); + } + + if (!notificationId) { + throw new HttpsError("invalid-argument", "notificationId가 필요합니다."); + } + + const notificationRef = admin.firestore().doc(`users/${userId}/notifications/${notificationId}`); + const notificationSnapshot = await notificationRef.get(); + + if (!notificationSnapshot.exists) { + throw new HttpsError("not-found", "Notification을 찾을 수 없습니다."); + } + + const taskRef = admin.firestore().collection("notificationDeletionTasks").doc(); + const taskData = { + userId, + notificationId, + createdAt: admin.firestore.FieldValue.serverTimestamp() + }; + + try { + await taskRef.set(taskData); + await notificationRef.set({ + // deletingAt: 삭제 요청은 되었지만, 5초 유예 후 최종 삭제되기 전 상태를 의미한다. + deletingAt: admin.firestore.FieldValue.serverTimestamp() + }, {merge: true}); + + const queue = getFunctions().taskQueue( + `locations/${LOCATION}/functions/completePushNotificationDeletion` + ); + await queue.enqueue( + {taskId: taskRef.id}, + {scheduleDelaySeconds: DELETE_DELAY_SECONDS} + ); + } catch (error) { + try { + await taskRef.delete(); + } catch (cleanupError) { + logger.warn("notificationDeletionTasks 정리 실패", { + userId, + notificationId, + taskId: taskRef.id, + error: normalizeError(cleanupError) + }); + } + + const currentNotificationSnapshot = await notificationRef.get(); + if (currentNotificationSnapshot.exists) { + await notificationRef.update({ + deletingAt: admin.firestore.FieldValue.delete() + }); + } + + logger.error("푸시 알림 삭제 요청 실패", { + userId, + notificationId, + error: normalizeError(error) + }); + throw new HttpsError("internal", "푸시 알림 삭제 요청에 실패했습니다."); + } + + return {success: true}; + } +); + +export const undoPushNotificationDeletion = onCall({ + cors: true, + maxInstances: 10, + region: LOCATION, + }, + async (request) => { + const userId = request.auth?.uid; + const notificationId = typeof request.data?.notificationId === "string" ? + request.data.notificationId.trim() : + ""; + + if (!userId) { + throw new HttpsError("unauthenticated", "인증된 사용자가 아닙니다."); + } + + if (!notificationId) { + throw new HttpsError("invalid-argument", "notificationId가 필요합니다."); + } + + const taskSnapshot = await admin.firestore() + .collection("notificationDeletionTasks") + .where("userId", "==", userId) + .where("notificationId", "==", notificationId) + .get(); + const notificationRef = admin.firestore().doc(`users/${userId}/notifications/${notificationId}`); + + try { + const notificationSnapshot = await notificationRef.get(); + if (notificationSnapshot.exists) { + await notificationRef.update({ + deletingAt: admin.firestore.FieldValue.delete() + }); + } + + if (!taskSnapshot.empty) { + const batch = admin.firestore().batch(); + taskSnapshot.docs.forEach((document) => { + batch.delete(document.ref); + }); + await batch.commit(); + } + } catch (error) { + logger.error("푸시 알림 삭제 취소 실패", { + userId, + notificationId, + error: normalizeError(error) + }); + throw new HttpsError("internal", "푸시 알림 삭제 취소에 실패했습니다."); + } + + return {success: true}; + } +); + +export const completePushNotificationDeletion = onTaskDispatched({ + region: LOCATION, + retryConfig: {maxAttempts: 3, minBackoffSeconds: 5}, + rateLimits: {maxDispatchesPerSecond: 200}, + }, + async (request) => { + const taskId = typeof request.data?.taskId === "string" ? request.data.taskId.trim() : ""; + if (!taskId) { + logger.warn("유효하지 않은 푸시 알림 삭제 payload", request.data); + return; + } + + const taskRef = admin.firestore().collection("notificationDeletionTasks").doc(taskId); + const taskSnapshot = await taskRef.get(); + if (!taskSnapshot.exists) { return; } + + const taskData = taskSnapshot.data() as NotificationDeletionTaskData | undefined; + const userId = typeof taskData?.userId === "string" ? taskData.userId : ""; + const notificationId = typeof taskData?.notificationId === "string" ? taskData.notificationId : ""; + if (!userId || !notificationId) { + logger.warn("notificationDeletionTasks 문서 형식이 올바르지 않습니다.", {taskId}); + return; + } + + const notificationRef = admin.firestore().doc(`users/${userId}/notifications/${notificationId}`); + + try { + const notificationSnapshot = await notificationRef.get(); + const deletingAt = notificationSnapshot.data()?.deletingAt; + + if (!notificationSnapshot.exists || !deletingAt) { + await taskRef.delete(); + return; + } + + await notificationRef.delete(); + await taskRef.delete(); + } catch (error) { + logger.error("푸시 알림 최종 삭제 실패", { + userId, + notificationId, + taskId, + error: normalizeError(error) + }); + throw error; + } + } +); diff --git a/Firebase/functions/src/todo/deletion.ts b/Firebase/functions/src/todo/deletion.ts index 0b5c75eb..2490a0a7 100644 --- a/Firebase/functions/src/todo/deletion.ts +++ b/Firebase/functions/src/todo/deletion.ts @@ -3,6 +3,7 @@ import {onTaskDispatched} from "firebase-functions/v2/tasks"; import {getFunctions} from "firebase-admin/functions"; import * as admin from "firebase-admin"; import * as logger from "firebase-functions/logger"; +import {normalizeError} from "../common/error"; const LOCATION = "asia-northeast3"; const DELETE_DELAY_SECONDS = 5; @@ -235,12 +236,3 @@ async function updateNotificationsDeletingAt( if (snapshot.size < QUERY_BATCH_SIZE) { return; } } } - -function normalizeError(error: unknown): Record { - const normalized = error as {code?: unknown; message?: unknown; stack?: unknown}; - return { - code: normalized?.code ?? null, - message: normalized?.message ?? String(error), - stack: normalized?.stack ?? null - }; -} diff --git a/Firebase/functions/src/webPage/deletion.ts b/Firebase/functions/src/webPage/deletion.ts new file mode 100644 index 00000000..52145449 --- /dev/null +++ b/Firebase/functions/src/webPage/deletion.ts @@ -0,0 +1,213 @@ +import { onCall, HttpsError } from "firebase-functions/v2/https"; +import { onTaskDispatched } from "firebase-functions/v2/tasks"; +import { getFunctions } from "firebase-admin/functions"; +import * as admin from "firebase-admin"; +import * as logger from "firebase-functions/logger"; +import { normalizeError } from "../common/error"; + +const LOCATION = "asia-northeast3"; +const DELETE_DELAY_SECONDS = 5; + +type WebPageDeletionTaskData = { + userId: string; + urlString: string; + documentPath: string; + createdAt?: FirebaseFirestore.Timestamp | Date | null; +}; + +export const requestWebPageDeletion = onCall({ + cors: true, + maxInstances: 10, + region: LOCATION, + }, + async (request) => { + const userId = request.auth?.uid; + const urlString = typeof request.data?.urlString === "string" ? + request.data.urlString.trim() : + ""; + + if (!userId) { + throw new HttpsError("unauthenticated", "인증된 사용자가 아닙니다."); + } + + if (!urlString) { + throw new HttpsError("invalid-argument", "urlString이 필요합니다."); + } + + const webPageSnapshot = await admin.firestore() + .collection(`users/${userId}/webPages`) + .where("url", "==", urlString) + .limit(1) + .get(); + + if (webPageSnapshot.empty) { + throw new HttpsError("not-found", "WebPage를 찾을 수 없습니다."); + } + + const webPageRef = webPageSnapshot.docs[0].ref; + + const taskRef = admin.firestore().collection("webPageDeletionTasks").doc(); + const taskData = { + userId, + urlString, + documentPath: webPageRef.path, + createdAt: admin.firestore.FieldValue.serverTimestamp() + }; + + try { + await taskRef.set(taskData); + await webPageRef.set({ + // deletingAt: 삭제 요청은 되었지만, 5초 유예 후 최종 삭제되기 전 상태를 의미한다. + deletingAt: admin.firestore.FieldValue.serverTimestamp() + }, { merge: true }); + + const queue = getFunctions().taskQueue( + `locations/${LOCATION}/functions/completeWebPageDeletion` + ); + await queue.enqueue( + { taskId: taskRef.id }, + { scheduleDelaySeconds: DELETE_DELAY_SECONDS } + ); + } catch (error) { + try { + await taskRef.delete(); + } catch (cleanupError) { + logger.warn("webPageDeletionTasks 정리 실패", { + userId, + urlString, + taskId: taskRef.id, + error: normalizeError(cleanupError) + }); + } + + const currentWebPageSnapshot = await webPageRef.get(); + if (currentWebPageSnapshot.exists) { + await webPageRef.update({ + deletingAt: admin.firestore.FieldValue.delete() + }); + } + + logger.error("웹페이지 삭제 요청 실패", { + userId, + urlString, + error: normalizeError(error) + }); + throw new HttpsError("internal", "웹페이지 삭제 요청에 실패했습니다."); + } + + return { success: true }; + } +); + +export const undoWebPageDeletion = onCall({ + cors: true, + maxInstances: 10, + region: LOCATION, + }, + async (request) => { + const userId = request.auth?.uid; + const urlString = typeof request.data?.urlString === "string" ? + request.data.urlString.trim() : + ""; + + if (!userId) { + throw new HttpsError("unauthenticated", "인증된 사용자가 아닙니다."); + } + + if (!urlString) { + throw new HttpsError("invalid-argument", "urlString이 필요합니다."); + } + + const taskSnapshot = await admin.firestore() + .collection("webPageDeletionTasks") + .where("userId", "==", userId) + .where("urlString", "==", urlString) + .get(); + const webPageSnapshot = await admin.firestore() + .collection(`users/${userId}/webPages`) + .where("url", "==", urlString) + .limit(1) + .get(); + if (webPageSnapshot.empty) { + return { success: true }; + } + + const webPageRef = webPageSnapshot.docs[0].ref; + + try { + const webPageSnapshot = await webPageRef.get(); + if (webPageSnapshot.exists) { + await webPageRef.update({ + deletingAt: admin.firestore.FieldValue.delete() + }); + } + + if (!taskSnapshot.empty) { + const batch = admin.firestore().batch(); + taskSnapshot.docs.forEach((document) => { + batch.delete(document.ref); + }); + await batch.commit(); + } + } catch (error) { + logger.error("웹페이지 삭제 취소 실패", { + userId, + urlString, + error: normalizeError(error) + }); + throw new HttpsError("internal", "웹페이지 삭제 취소에 실패했습니다."); + } + + return { success: true }; + } +); + +export const completeWebPageDeletion = onTaskDispatched({ + region: LOCATION, + retryConfig: { maxAttempts: 3, minBackoffSeconds: 5 }, + rateLimits: { maxDispatchesPerSecond: 200 }, + }, + async (request) => { + const taskId = typeof request.data?.taskId === "string" ? request.data.taskId.trim() : ""; + if (!taskId) { + logger.warn("유효하지 않은 웹페이지 삭제 payload", request.data); + return; + } + + const taskRef = admin.firestore().collection("webPageDeletionTasks").doc(taskId); + const taskSnapshot = await taskRef.get(); + if (!taskSnapshot.exists) { return; } + + const taskData = taskSnapshot.data() as WebPageDeletionTaskData | undefined; + const userId = typeof taskData?.userId === "string" ? taskData.userId : ""; + const urlString = typeof taskData?.urlString === "string" ? taskData.urlString : ""; + const documentPath = typeof taskData?.documentPath === "string" ? taskData.documentPath : ""; + if (!userId || !urlString || !documentPath) { + logger.warn("webPageDeletionTasks 문서 형식이 올바르지 않습니다.", { taskId }); + return; + } + + const webPageRef = admin.firestore().doc(documentPath); + + try { + const webPageSnapshot = await webPageRef.get(); + const deletingAt = webPageSnapshot.data()?.deletingAt; + + if (!webPageSnapshot.exists || !deletingAt) { + await taskRef.delete(); + return; + } + + await webPageRef.delete(); + await taskRef.delete(); + } catch (error) { + logger.error("웹페이지 최종 삭제 실패", { + userId, + urlString, + taskId, + error: normalizeError(error) + }); + throw error; + } + } +);