diff --git a/DevLog.xcodeproj/project.pbxproj b/DevLog.xcodeproj/project.pbxproj index 20be7f54..266721bf 100644 --- a/DevLog.xcodeproj/project.pbxproj +++ b/DevLog.xcodeproj/project.pbxproj @@ -15,29 +15,13 @@ DFABA3B02E23526500FEFBDB /* FirebaseFirestore in Frameworks */ = {isa = PBXBuildFile; productRef = DFABA3AF2E23526500FEFBDB /* FirebaseFirestore */; }; DFABA3B22E23526500FEFBDB /* FirebaseFunctions in Frameworks */ = {isa = PBXBuildFile; productRef = DFABA3B12E23526500FEFBDB /* FirebaseFunctions */; }; DFABA3B42E23526500FEFBDB /* FirebaseMessaging in Frameworks */ = {isa = PBXBuildFile; productRef = DFABA3B32E23526500FEFBDB /* FirebaseMessaging */; }; - DFD643A72EC787AB0073E133 /* firebase.json in Resources */ = {isa = PBXBuildFile; fileRef = DFD643A42EC787AB0073E133 /* firebase.json */; }; - DFD643A92EC787AB0073E133 /* package.json in Resources */ = {isa = PBXBuildFile; fileRef = DFD643A02EC787AB0073E133 /* package.json */; }; - DFD643AC2EC787AB0073E133 /* tsconfig.json in Resources */ = {isa = PBXBuildFile; fileRef = DFD643A22EC787AB0073E133 /* tsconfig.json */; }; - DFD643AE2EC787AB0073E133 /* package-lock.json in Resources */ = {isa = PBXBuildFile; fileRef = DFD643A12EC787AB0073E133 /* package-lock.json */; }; DFD645402EC827A10073E133 /* .gitignore in Resources */ = {isa = PBXBuildFile; fileRef = DFD6453F2EC827A10073E133 /* .gitignore */; }; DFD74E2F2E423EA700613803 /* README.md in Resources */ = {isa = PBXBuildFile; fileRef = DFD74E2E2E423EA700613803 /* README.md */; }; DFF2DACE2EDC02AD00778738 /* OrderedCollections in Frameworks */ = {isa = PBXBuildFile; productRef = DFF2DACD2EDC02AD00778738 /* OrderedCollections */; }; /* End PBXBuildFile section */ /* Begin PBXFileReference section */ - DF3416452E45F67C00F9312B /* DevLog_Unit.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = DevLog_Unit.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; DFD48B002DC4D6E2005905C5 /* DevLog.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = DevLog.app; sourceTree = BUILT_PRODUCTS_DIR; }; - DFD643952EC787AB0073E133 /* apple.ts */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.typescript; path = apple.ts; sourceTree = ""; }; - DFD643962EC787AB0073E133 /* github.ts */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.typescript; path = github.ts; sourceTree = ""; }; - DFD643972EC787AB0073E133 /* google.ts */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.typescript; path = google.ts; sourceTree = ""; }; - DFD643992EC787AB0073E133 /* notification.ts */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.typescript; path = notification.ts; sourceTree = ""; }; - DFD6439A2EC787AB0073E133 /* schedule.ts */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.typescript; path = schedule.ts; sourceTree = ""; }; - DFD6439C2EC787AB0073E133 /* delete.ts */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.typescript; path = delete.ts; sourceTree = ""; }; - DFD6439E2EC787AB0073E133 /* index.ts */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.typescript; path = index.ts; sourceTree = ""; }; - DFD643A02EC787AB0073E133 /* package.json */ = {isa = PBXFileReference; lastKnownFileType = text.json; path = package.json; sourceTree = ""; }; - DFD643A12EC787AB0073E133 /* package-lock.json */ = {isa = PBXFileReference; lastKnownFileType = text.json; path = "package-lock.json"; sourceTree = ""; }; - DFD643A22EC787AB0073E133 /* tsconfig.json */ = {isa = PBXFileReference; lastKnownFileType = text.json; path = tsconfig.json; sourceTree = ""; }; - DFD643A42EC787AB0073E133 /* firebase.json */ = {isa = PBXFileReference; lastKnownFileType = text.json; path = firebase.json; sourceTree = ""; }; DFD6453F2EC827A10073E133 /* .gitignore */ = {isa = PBXFileReference; lastKnownFileType = text; path = .gitignore; sourceTree = ""; }; DFD74E2E2E423EA700613803 /* README.md */ = {isa = PBXFileReference; lastKnownFileType = net.daringfireball.markdown; path = README.md; sourceTree = ""; }; /* End PBXFileReference section */ @@ -54,11 +38,6 @@ /* End PBXFileSystemSynchronizedBuildFileExceptionSet section */ /* Begin PBXFileSystemSynchronizedRootGroup section */ - DF3416462E45F67C00F9312B /* DevLog_Unit */ = { - isa = PBXFileSystemSynchronizedRootGroup; - path = DevLog_Unit; - sourceTree = ""; - }; DF8AB7982E938B0B00E50BBF /* DevLog */ = { isa = PBXFileSystemSynchronizedRootGroup; exceptions = ( @@ -70,13 +49,6 @@ /* End PBXFileSystemSynchronizedRootGroup section */ /* Begin PBXFrameworksBuildPhase section */ - DF3416422E45F67C00F9312B /* Frameworks */ = { - isa = PBXFrameworksBuildPhase; - buildActionMask = 2147483647; - files = ( - ); - runOnlyForDeploymentPostprocessing = 0; - }; DFD48AFD2DC4D6E2005905C5 /* Frameworks */ = { isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; @@ -102,10 +74,8 @@ DFD6453F2EC827A10073E133 /* .gitignore */, DF8AB7982E938B0B00E50BBF /* DevLog */, DFD74E2E2E423EA700613803 /* README.md */, - DF3416462E45F67C00F9312B /* DevLog_Unit */, DFE28EB62DCCF26300B28FE5 /* Frameworks */, DFD48B012DC4D6E2005905C5 /* Products */, - DFD643A52EC787AB0073E133 /* Firebase */, ); sourceTree = ""; }; @@ -113,69 +83,10 @@ isa = PBXGroup; children = ( DFD48B002DC4D6E2005905C5 /* DevLog.app */, - DF3416452E45F67C00F9312B /* DevLog_Unit.xctest */, ); name = Products; sourceTree = ""; }; - DFD643982EC787AB0073E133 /* auth */ = { - isa = PBXGroup; - children = ( - DFD643952EC787AB0073E133 /* apple.ts */, - DFD643962EC787AB0073E133 /* github.ts */, - DFD643972EC787AB0073E133 /* google.ts */, - ); - path = auth; - sourceTree = ""; - }; - DFD6439B2EC787AB0073E133 /* fcm */ = { - isa = PBXGroup; - children = ( - DFD643992EC787AB0073E133 /* notification.ts */, - DFD6439A2EC787AB0073E133 /* schedule.ts */, - ); - path = fcm; - sourceTree = ""; - }; - DFD6439D2EC787AB0073E133 /* user */ = { - isa = PBXGroup; - children = ( - DFD6439C2EC787AB0073E133 /* delete.ts */, - ); - path = user; - sourceTree = ""; - }; - DFD6439F2EC787AB0073E133 /* src */ = { - isa = PBXGroup; - children = ( - DFD643982EC787AB0073E133 /* auth */, - DFD6439B2EC787AB0073E133 /* fcm */, - DFD6439D2EC787AB0073E133 /* user */, - DFD6439E2EC787AB0073E133 /* index.ts */, - ); - path = src; - sourceTree = ""; - }; - DFD643A32EC787AB0073E133 /* functions */ = { - isa = PBXGroup; - children = ( - DFD6439F2EC787AB0073E133 /* src */, - DFD643A02EC787AB0073E133 /* package.json */, - DFD643A12EC787AB0073E133 /* package-lock.json */, - DFD643A22EC787AB0073E133 /* tsconfig.json */, - ); - path = functions; - sourceTree = ""; - }; - DFD643A52EC787AB0073E133 /* Firebase */ = { - isa = PBXGroup; - children = ( - DFD643A32EC787AB0073E133 /* functions */, - DFD643A42EC787AB0073E133 /* firebase.json */, - ); - path = Firebase; - sourceTree = ""; - }; DFE28EB62DCCF26300B28FE5 /* Frameworks */ = { isa = PBXGroup; children = ( @@ -186,28 +97,6 @@ /* End PBXGroup section */ /* Begin PBXNativeTarget section */ - DF3416442E45F67C00F9312B /* DevLog_Unit */ = { - isa = PBXNativeTarget; - buildConfigurationList = DF34164B2E45F67C00F9312B /* Build configuration list for PBXNativeTarget "DevLog_Unit" */; - buildPhases = ( - DF3416412E45F67C00F9312B /* Sources */, - DF3416422E45F67C00F9312B /* Frameworks */, - DF3416432E45F67C00F9312B /* Resources */, - ); - buildRules = ( - ); - dependencies = ( - ); - fileSystemSynchronizedGroups = ( - DF3416462E45F67C00F9312B /* DevLog_Unit */, - ); - name = DevLog_Unit; - packageProductDependencies = ( - ); - productName = DevLog_Unit; - productReference = DF3416452E45F67C00F9312B /* DevLog_Unit.xctest */; - productType = "com.apple.product-type.bundle.unit-test"; - }; DFD48AFF2DC4D6E2005905C5 /* DevLog */ = { isa = PBXNativeTarget; buildConfigurationList = DFD48B112DC4D6E4005905C5 /* Build configuration list for PBXNativeTarget "DevLog" */; @@ -250,9 +139,6 @@ LastSwiftUpdateCheck = 1640; LastUpgradeCheck = 2600; TargetAttributes = { - DF3416442E45F67C00F9312B = { - CreatedOnToolsVersion = 16.4; - }; DFD48AFF2DC4D6E2005905C5 = { CreatedOnToolsVersion = 16.3; }; @@ -279,42 +165,23 @@ projectRoot = ""; targets = ( DFD48AFF2DC4D6E2005905C5 /* DevLog */, - DF3416442E45F67C00F9312B /* DevLog_Unit */, ); }; /* End PBXProject section */ /* Begin PBXResourcesBuildPhase section */ - DF3416432E45F67C00F9312B /* Resources */ = { - isa = PBXResourcesBuildPhase; - buildActionMask = 2147483647; - files = ( - ); - runOnlyForDeploymentPostprocessing = 0; - }; DFD48AFE2DC4D6E2005905C5 /* Resources */ = { isa = PBXResourcesBuildPhase; buildActionMask = 2147483647; files = ( DFD645402EC827A10073E133 /* .gitignore in Resources */, DFD74E2F2E423EA700613803 /* README.md in Resources */, - DFD643A72EC787AB0073E133 /* firebase.json in Resources */, - DFD643A92EC787AB0073E133 /* package.json in Resources */, - DFD643AC2EC787AB0073E133 /* tsconfig.json in Resources */, - DFD643AE2EC787AB0073E133 /* package-lock.json in Resources */, ); runOnlyForDeploymentPostprocessing = 0; }; /* End PBXResourcesBuildPhase section */ /* Begin PBXSourcesBuildPhase section */ - DF3416412E45F67C00F9312B /* Sources */ = { - isa = PBXSourcesBuildPhase; - buildActionMask = 2147483647; - files = ( - ); - runOnlyForDeploymentPostprocessing = 0; - }; DFD48AFC2DC4D6E2005905C5 /* Sources */ = { isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; @@ -332,40 +199,6 @@ /* End PBXTargetDependency section */ /* Begin XCBuildConfiguration section */ - DF3416492E45F67C00F9312B /* Debug */ = { - isa = XCBuildConfiguration; - buildSettings = { - CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 1; - DEAD_CODE_STRIPPING = YES; - GENERATE_INFOPLIST_FILE = YES; - MACOSX_DEPLOYMENT_TARGET = 15.5; - MARKETING_VERSION = 1.0; - PRODUCT_BUNDLE_IDENTIFIER = "opfic.DevLog-Unit"; - PRODUCT_NAME = "$(TARGET_NAME)"; - SDKROOT = macosx; - SWIFT_EMIT_LOC_STRINGS = NO; - SWIFT_VERSION = 5.0; - }; - name = Debug; - }; - DF34164A2E45F67C00F9312B /* Release */ = { - isa = XCBuildConfiguration; - buildSettings = { - CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 1; - DEAD_CODE_STRIPPING = YES; - GENERATE_INFOPLIST_FILE = YES; - MACOSX_DEPLOYMENT_TARGET = 15.5; - MARKETING_VERSION = 1.0; - PRODUCT_BUNDLE_IDENTIFIER = "opfic.DevLog-Unit"; - PRODUCT_NAME = "$(TARGET_NAME)"; - SDKROOT = macosx; - SWIFT_EMIT_LOC_STRINGS = NO; - SWIFT_VERSION = 5.0; - }; - name = Release; - }; DFD48B122DC4D6E4005905C5 /* Debug */ = { isa = XCBuildConfiguration; baseConfigurationReferenceAnchor = DF8AB7982E938B0B00E50BBF /* DevLog */; @@ -592,15 +425,6 @@ /* End XCBuildConfiguration section */ /* Begin XCConfigurationList section */ - DF34164B2E45F67C00F9312B /* Build configuration list for PBXNativeTarget "DevLog_Unit" */ = { - isa = XCConfigurationList; - buildConfigurations = ( - DF3416492E45F67C00F9312B /* Debug */, - DF34164A2E45F67C00F9312B /* Release */, - ); - defaultConfigurationIsVisible = 0; - defaultConfigurationName = Release; - }; DFD48AFB2DC4D6E2005905C5 /* Build configuration list for PBXProject "DevLog" */ = { isa = XCConfigurationList; buildConfigurations = ( diff --git a/DevLog/App/Assembler/Assembler.swift b/DevLog/App/Assembler/Assembler.swift index 02a9f4a6..5c65b405 100644 --- a/DevLog/App/Assembler/Assembler.swift +++ b/DevLog/App/Assembler/Assembler.swift @@ -11,6 +11,7 @@ protocol Assembler { final class AppAssembler: Assembler { private let assemblers: [Assembler] = [ + PersistenceAssembler(), InfraAssembler(), DataAssembler(), DomainAssembler() diff --git a/DevLog/App/Assembler/DomainAssembler.swift b/DevLog/App/Assembler/DomainAssembler.swift index 9122edf0..a2e5a4e8 100644 --- a/DevLog/App/Assembler/DomainAssembler.swift +++ b/DevLog/App/Assembler/DomainAssembler.swift @@ -66,6 +66,10 @@ private extension DomainAssembler { container.register(DeleteTodoUseCase.self) { DeleteTodoUseCaseImpl(container.resolve(TodoRepository.self)) } + + container.register(UndoDeleteTodoUseCase.self) { + UndoDeleteTodoUseCaseImpl(container.resolve(TodoRepository.self)) + } } func registerUserDataUseCases(_ container: DIContainer) { diff --git a/DevLog/App/Assembler/InfraAssembler.swift b/DevLog/App/Assembler/InfraAssembler.swift index d898d9ef..acf1e61c 100644 --- a/DevLog/App/Assembler/InfraAssembler.swift +++ b/DevLog/App/Assembler/InfraAssembler.swift @@ -52,12 +52,5 @@ final class InfraAssembler: Assembler { WebPageMetadataService() } - container.register(UserDefaultsStore.self) { - UserDefaultsStore() - } - - container.register(ThemeStore.self) { - ThemeStore() - } } } diff --git a/DevLog/App/Assembler/PersistenceAssembler.swift b/DevLog/App/Assembler/PersistenceAssembler.swift new file mode 100644 index 00000000..c904a1ec --- /dev/null +++ b/DevLog/App/Assembler/PersistenceAssembler.swift @@ -0,0 +1,18 @@ +// +// PersistenceAssembler.swift +// DevLog +// +// Created by opfic on 3/15/26. +// + +final class PersistenceAssembler: Assembler { + func assemble(_ container: any DIContainer) { + container.register(UserDefaultsStore.self) { + UserDefaultsStore() + } + + container.register(ThemeStore.self) { + ThemeStore() + } + } +} diff --git a/DevLog/Data/Repository/TodoRepositoryImpl.swift b/DevLog/Data/Repository/TodoRepositoryImpl.swift index db026f7d..2433c52d 100644 --- a/DevLog/Data/Repository/TodoRepositoryImpl.swift +++ b/DevLog/Data/Repository/TodoRepositoryImpl.swift @@ -33,4 +33,8 @@ final class TodoRepositoryImpl: TodoRepository { func deleteTodo(_ todoId: String) async throws { try await todoService.deleteTodo(todoId: todoId) } + + func undoDeleteTodo(_ todoId: String) async throws { + try await todoService.undoDeleteTodo(todoId: todoId) + } } diff --git a/DevLog/Domain/Protocol/TodoRepository.swift b/DevLog/Domain/Protocol/TodoRepository.swift index ef3402d2..3105591d 100644 --- a/DevLog/Domain/Protocol/TodoRepository.swift +++ b/DevLog/Domain/Protocol/TodoRepository.swift @@ -12,4 +12,5 @@ protocol TodoRepository { func fetchTodo(_ todoId: String) async throws -> Todo func upsertTodo(_ todo: Todo) async throws func deleteTodo(_ todoId: String) async throws + func undoDeleteTodo(_ todoId: String) async throws } diff --git a/DevLog/Domain/UseCase/Todo/Delete/UndoDeleteTodoUseCase.swift b/DevLog/Domain/UseCase/Todo/Delete/UndoDeleteTodoUseCase.swift new file mode 100644 index 00000000..0764e93a --- /dev/null +++ b/DevLog/Domain/UseCase/Todo/Delete/UndoDeleteTodoUseCase.swift @@ -0,0 +1,10 @@ +// +// UndoDeleteTodoUseCase.swift +// DevLog +// +// Created by opfic on 3/15/26. +// + +protocol UndoDeleteTodoUseCase { + func execute(_ todoId: String) async throws +} diff --git a/DevLog/Domain/UseCase/Todo/Delete/UndoDeleteTodoUseCaseImpl.swift b/DevLog/Domain/UseCase/Todo/Delete/UndoDeleteTodoUseCaseImpl.swift new file mode 100644 index 00000000..499fcaad --- /dev/null +++ b/DevLog/Domain/UseCase/Todo/Delete/UndoDeleteTodoUseCaseImpl.swift @@ -0,0 +1,18 @@ +// +// UndoDeleteTodoUseCaseImpl.swift +// DevLog +// +// Created by opfic on 3/15/26. +// + +final class UndoDeleteTodoUseCaseImpl: UndoDeleteTodoUseCase { + private let repository: TodoRepository + + init(_ repository: TodoRepository) { + self.repository = repository + } + + func execute(_ todoId: String) async throws { + try await repository.undoDeleteTodo(todoId) + } +} diff --git a/DevLog/Infra/Extension/FirebaseAuthUser.swift b/DevLog/Infra/Extension/FirebaseAuthUser+.swift similarity index 95% rename from DevLog/Infra/Extension/FirebaseAuthUser.swift rename to DevLog/Infra/Extension/FirebaseAuthUser+.swift index 7aea4baa..11b8dccd 100644 --- a/DevLog/Infra/Extension/FirebaseAuthUser.swift +++ b/DevLog/Infra/Extension/FirebaseAuthUser+.swift @@ -1,5 +1,5 @@ // -// FirebaseAuthUser.swift +// FirebaseAuthUser+.swift // DevLog // // Created by 최윤진 on 11/3/25. diff --git a/DevLog/Infra/Extension/FirebaseFunctions+.swift b/DevLog/Infra/Extension/FirebaseFunctions+.swift new file mode 100644 index 00000000..e255bcec --- /dev/null +++ b/DevLog/Infra/Extension/FirebaseFunctions+.swift @@ -0,0 +1,14 @@ +// +// FirebaseFunctions+.swift +// DevLog +// +// Created by opfic on 3/16/26. +// + +import FirebaseFunctions + +extension Functions { + func httpsCallable(_ name: some RawRepresentable) -> HTTPSCallable { + httpsCallable(name.rawValue) + } +} diff --git a/DevLog/Infra/Service/PushNotificationService.swift b/DevLog/Infra/Service/PushNotificationService.swift index 00a0cb96..59e3de77 100644 --- a/DevLog/Infra/Service/PushNotificationService.swift +++ b/DevLog/Infra/Service/PushNotificationService.swift @@ -256,6 +256,9 @@ private extension PushNotificationService { func makeResponse(from snapshot: QueryDocumentSnapshot) -> PushNotificationResponse? { let data = snapshot.data() + if data[Key.deletingAt.rawValue] is Timestamp { + return nil + } guard let title = data[Key.title.rawValue] as? String, let body = data[Key.body.rawValue] as? String, @@ -284,5 +287,6 @@ private extension PushNotificationService { case isRead case todoId case todoKind + case deletingAt // 삭제 요청은 되었지만, 5초 유예 후 최종 삭제되기 전 상태 } } diff --git a/DevLog/Infra/Service/SocialLogin/AppleAuthenticationService.swift b/DevLog/Infra/Service/SocialLogin/AppleAuthenticationService.swift index d190c161..347ecf75 100644 --- a/DevLog/Infra/Service/SocialLogin/AppleAuthenticationService.swift +++ b/DevLog/Infra/Service/SocialLogin/AppleAuthenticationService.swift @@ -14,6 +14,13 @@ import FirebaseMessaging import Foundation final class AppleAuthenticationService: AuthenticationService { + private enum FunctionName: String { + case requestAppleCustomToken + case refreshAppleAccessToken + case requestAppleRefreshToken + case revokeAppleAccessToken + } + private var appleSignInDelegate: AppleSignInDelegate? private let store = Firestore.firestore() private let functions = Functions.functions(region: "asia-northeast3") @@ -222,7 +229,7 @@ final class AppleAuthenticationService: AuthenticationService { throw URLError(.badServerResponse) } - let requestTokenFunction = functions.httpsCallable("requestAppleCustomToken") + let requestTokenFunction = functions.httpsCallable(FunctionName.requestAppleCustomToken) let result = try await requestTokenFunction.call([ "idToken": idToken, "authorizationCode": authorizationCode @@ -236,7 +243,7 @@ final class AppleAuthenticationService: AuthenticationService { // Apple AceessToken 재발급 메서드 private func refreshAppleAccessToken() async throws -> String { - let refreshFunction = functions.httpsCallable("refreshAppleAccessToken") + let refreshFunction = functions.httpsCallable(FunctionName.refreshAppleAccessToken) let result = try await refreshFunction.call() guard let data = result.data as? [String: Any], @@ -253,7 +260,7 @@ final class AppleAuthenticationService: AuthenticationService { throw URLError(.userAuthenticationRequired) } - let requestFuction = functions.httpsCallable("requestAppleRefreshToken") + let requestFuction = functions.httpsCallable(FunctionName.requestAppleRefreshToken) let params: [String: Any] = [ "authorizationCode": authorizationCode, @@ -270,7 +277,7 @@ final class AppleAuthenticationService: AuthenticationService { // Apple AccessToken 취소 메서드 func revokeAppleAccessToken(token: String) async throws { - let revokeFunction = functions.httpsCallable("revokeAppleAccessToken") + let revokeFunction = functions.httpsCallable(FunctionName.revokeAppleAccessToken) _ = try await revokeFunction.call(["token": token]) } diff --git a/DevLog/Infra/Service/SocialLogin/GithubAuthenticationService.swift b/DevLog/Infra/Service/SocialLogin/GithubAuthenticationService.swift index 92c5ae4d..935762c2 100644 --- a/DevLog/Infra/Service/SocialLogin/GithubAuthenticationService.swift +++ b/DevLog/Infra/Service/SocialLogin/GithubAuthenticationService.swift @@ -13,6 +13,11 @@ import FirebaseFunctions import FirebaseMessaging final class GithubAuthenticationService: NSObject, AuthenticationService { + private enum FunctionName: String { + case requestGithubTokens + case revokeGithubAccessToken + } + private let store = Firestore.firestore() private let functions = Functions.functions(region: "asia-northeast3") private let messaging = Messaging.messaging() @@ -208,7 +213,7 @@ final class GithubAuthenticationService: NSObject, AuthenticationService { // Firebase Function 호출: Custom Token 발급 func requestTokens(authorizationCode: String) async throws -> (String, String) { - let requestTokenFunction = functions.httpsCallable("requestGithubTokens") + let requestTokenFunction = functions.httpsCallable(FunctionName.requestGithubTokens) let result = try await requestTokenFunction.call(["code": authorizationCode]) if let data = result.data as? [String: Any], @@ -226,7 +231,7 @@ final class GithubAuthenticationService: NSObject, AuthenticationService { param["accessToken"] = accessToken } - let revokeFunction = functions.httpsCallable("revokeGithubAccessToken") + let revokeFunction = functions.httpsCallable(FunctionName.revokeGithubAccessToken) _ = try await revokeFunction.call(param) } diff --git a/DevLog/Infra/Service/SocialLogin/GoogleAuthenticationService.swift b/DevLog/Infra/Service/SocialLogin/GoogleAuthenticationService.swift index cf2cd7ae..0ccb4c15 100644 --- a/DevLog/Infra/Service/SocialLogin/GoogleAuthenticationService.swift +++ b/DevLog/Infra/Service/SocialLogin/GoogleAuthenticationService.swift @@ -7,14 +7,12 @@ import FirebaseAuth import FirebaseFirestore -import FirebaseFunctions import FirebaseMessaging import Foundation import GoogleSignIn final class GoogleAuthenticationService: AuthenticationService { private let store = Firestore.firestore() - private let functions = Functions.functions(region: "asia-northeast3") private let messaging = Messaging.messaging() private var user: User? { Auth.auth().currentUser } private let provider = TopViewControllerProvider() diff --git a/DevLog/Infra/Service/TodoService.swift b/DevLog/Infra/Service/TodoService.swift index 4809bb01..fd11efde 100644 --- a/DevLog/Infra/Service/TodoService.swift +++ b/DevLog/Infra/Service/TodoService.swift @@ -7,9 +7,16 @@ import FirebaseAuth import FirebaseFirestore +import FirebaseFunctions final class TodoService { + private enum FunctionName: String { + case requestTodoDeletion + case undoTodoDeletion + } + private let store = Firestore.firestore() + private let functions = Functions.functions(region: "asia-northeast3") private let encoder = Firestore.Encoder() private let logger = Logger(category: "TodoService") @@ -171,18 +178,33 @@ final class TodoService { } func deleteTodo(todoId: String) async throws { - guard let uid = Auth.auth().currentUser?.uid else { throw AuthError.notAuthenticated } + guard Auth.auth().currentUser?.uid != nil else { throw AuthError.notAuthenticated } - logger.info("Deleting todo: \(todoId)") + logger.info("Requesting todo deletion: \(todoId)") do { - let collection = store.collection("users/\(uid)/todoLists/") - let docRef = collection.document(todoId) - try await docRef.delete() + let function = functions.httpsCallable(FunctionName.requestTodoDeletion) + _ = try await function.call(["todoId": todoId]) - logger.info("Successfully deleted todo") + logger.info("Successfully requested todo deletion") } catch { - logger.error("Failed to delete todo", error: error) + logger.error("Failed to request todo deletion", error: error) + throw error + } + } + + func undoDeleteTodo(todoId: String) async throws { + guard Auth.auth().currentUser?.uid != nil else { throw AuthError.notAuthenticated } + + logger.info("Undoing todo deletion: \(todoId)") + + do { + let function = functions.httpsCallable(FunctionName.undoTodoDeletion) + _ = try await function.call(["todoId": todoId]) + + logger.info("Successfully undone todo deletion") + } catch { + logger.error("Failed to undo todo deletion", error: error) throw error } } @@ -282,7 +304,10 @@ private extension TodoService { } func makeResponse(from snapshot: QueryDocumentSnapshot) -> TodoResponse? { - makeResponse(documentID: snapshot.documentID, data: snapshot.data()) + if snapshot.data()[TodoFieldKey.deletingAt.rawValue] is Timestamp { + return nil + } + return makeResponse(documentID: snapshot.documentID, data: snapshot.data()) } func makeResponse(from snapshot: DocumentSnapshot) -> TodoResponse? { @@ -337,5 +362,6 @@ private extension TodoService { case dueDate case tags case kind + case deletingAt // 삭제 요청은 되었지만, 5초 유예 후 최종 삭제되기 전 상태 } } diff --git a/DevLog/Infra/Service/UserService.swift b/DevLog/Infra/Service/UserService.swift index f9b21f2d..d62134b2 100644 --- a/DevLog/Infra/Service/UserService.swift +++ b/DevLog/Infra/Service/UserService.swift @@ -7,11 +7,9 @@ import FirebaseAuth import FirebaseFirestore -import FirebaseFunctions final class UserService { private let store = Firestore.firestore() - private let functions = Functions.functions(region: "asia-northeast3") private let logger = Logger(category: "UserService") // 유저를 Firestore에 저장 및 업데이트 diff --git a/DevLog/Presentation/ViewModel/TodoListViewModel.swift b/DevLog/Presentation/ViewModel/TodoListViewModel.swift index 5ce85363..5bfc4f70 100644 --- a/DevLog/Presentation/ViewModel/TodoListViewModel.swift +++ b/DevLog/Presentation/ViewModel/TodoListViewModel.swift @@ -45,7 +45,6 @@ final class TodoListViewModel: Store { case undoDelete // View - case confirmDelete case onAppear case loadNextPage case setSearchText(String) @@ -57,6 +56,7 @@ final class TodoListViewModel: Store { case fetchSearchResults([TodoListItem]) case didToggleCompleted(TodoListItem) case didTogglePinned(TodoListItem) + case restoreTodo(TodoListItem, Int) case setLoading(Bool) case appendTodos([TodoListItem], nextCursor: TodoCursor?) case resetPagination @@ -68,7 +68,8 @@ final class TodoListViewModel: Store { case loadNextPage case search(String) case upsert(Todo) - case delete(String) + case delete(TodoListItem, Int) + case undoDelete(String) case toggleCompleted(TodoListItem) case togglePinned(TodoListItem) } @@ -80,7 +81,8 @@ final class TodoListViewModel: Store { private let fetchTodoByIdUseCase: FetchTodoByIdUseCase private let upsertTodoUseCase: UpsertTodoUseCase private let deleteTodoUseCase: DeleteTodoUseCase - private var pendingTask: (TodoListItem, Int)? + private let undoDeleteTodoUseCase: UndoDeleteTodoUseCase + private var undoDeleteTodoId: String? private var nextCursor: TodoCursor? init( @@ -88,12 +90,14 @@ final class TodoListViewModel: Store { fetchTodoByIdUseCase: FetchTodoByIdUseCase, upsertTodoUseCase: UpsertTodoUseCase, deleteTodoUseCase: DeleteTodoUseCase, + undoDeleteTodoUseCase: UndoDeleteTodoUseCase, kind: TodoKind ) { self.fetchTodosUseCase = fetchTodosUseCase self.fetchTodoByIdUseCase = fetchTodoByIdUseCase self.upsertTodoUseCase = upsertTodoUseCase self.deleteTodoUseCase = deleteTodoUseCase + self.undoDeleteTodoUseCase = undoDeleteTodoUseCase self.state = State( kind: kind, query: TodoQuery(kind: kind) @@ -121,11 +125,11 @@ final class TodoListViewModel: Store { .setShowAllSearchResults, .tapToggleCompleted, .tapTogglePinned, .undoDelete: effects = reduceByUser(action, state: &state) - case .confirmDelete, .onAppear, .loadNextPage, .setSearchText, .setToast, .upsertTodo: + case .onAppear, .loadNextPage, .setSearchText, .setToast, .upsertTodo: effects = reduceByView(action, state: &state) - case .setSearchQuery, .fetchSearchResults, - .didToggleCompleted, .didTogglePinned, .setLoading, .appendTodos, .resetPagination, .setHasMore: + case .setSearchQuery, .fetchSearchResults, .didToggleCompleted, .didTogglePinned, + .restoreTodo, .setLoading, .appendTodos, .resetPagination, .setHasMore: effects = reduceByRun(action, state: &state) } @@ -215,14 +219,24 @@ final class TodoListViewModel: Store { send(.setAlert(true)) } } - case .delete(let todoId): + case .delete(let item, let index): Task { do { - try await deleteTodoUseCase.execute(todoId) + try await deleteTodoUseCase.execute(item.id) } catch { + send(.restoreTodo(item, index)) send(.setAlert(true)) } } + case .undoDelete(let todoId): + Task { + do { + try await undoDeleteTodoUseCase.execute(todoId) + send(.refresh) + } catch { + send(.setAlert(true)); send(.refresh) + } + } } } } @@ -238,18 +252,12 @@ private extension TodoListViewModel { case .setShowEditor(let value): state.showEditor = value case .swipeTodo(let todo): - var effects: [SideEffect] = [] - if let (pendingItem, _) = pendingTask { - effects = [.delete(pendingItem.id)] - } - if let index = state.todos.firstIndex(where: { $0.id == todo.id }) { - pendingTask = (todo, index) + undoDeleteTodoId = todo.id state.todos.remove(at: index) setToast(&state, isPresented: true) + return [.delete(todo, index)] } - - return effects case .setSortTarget(let target): state.query.sortTarget = target self.nextCursor = nil @@ -285,11 +293,9 @@ private extension TodoListViewModel { case .tapTogglePinned(let todo): return [.togglePinned(todo)] case .undoDelete: - guard let (todo, index) = pendingTask else { return [] } - if index <= state.todos.count { - state.todos.insert(todo, at: index) - } - pendingTask = nil + guard let undoDeleteTodoId else { return [] } + self.undoDeleteTodoId = nil + return [.undoDelete(undoDeleteTodoId)] default: break } @@ -298,16 +304,10 @@ private extension TodoListViewModel { func reduceByView(_ action: Action, state: inout State) -> [SideEffect] { switch action { - case .confirmDelete: - guard let (item, _) = pendingTask else { - return [] - } - pendingTask = nil - return [.delete(item.id)] case .onAppear: return [.fetch] case .loadNextPage: - guard state.hasMore, !state.isLoading, pendingTask == nil else { return [] } + guard state.hasMore, !state.isLoading else { return [] } return [.loadNextPage] case .setSearchText(let text): guard state.searchText != text else { return [] } @@ -324,6 +324,7 @@ private extension TodoListViewModel { } case .setToast(let isPresented): setToast(&state, isPresented: isPresented) + if !isPresented { undoDeleteTodoId = nil } case .upsertTodo(let todo): return [.upsert(todo)] default: @@ -351,16 +352,22 @@ private extension TodoListViewModel { if let index = state.todos.firstIndex(where: { $0.id == todo.id }) { state.todos[index] = todo } + case .restoreTodo(let todo, let index): + if state.todos.contains(where: { $0.id == todo.id }) { break } + + if index <= state.todos.count { + state.todos.insert(todo, at: index) + } else { + state.todos.append(todo) + } + + if undoDeleteTodoId == todo.id { + undoDeleteTodoId = nil + } case .setLoading(let value): state.isLoading = value case .appendTodos(let todos, let nextCursor): - let filteredTodos: [TodoListItem] - if let (pendingItem, _) = pendingTask { - filteredTodos = todos.filter { $0.id != pendingItem.id } - } else { - filteredTodos = todos - } - state.todos.append(contentsOf: filteredTodos) + state.todos.append(contentsOf: todos) self.nextCursor = nextCursor case .resetPagination: state.todos = [] diff --git a/DevLog/UI/Home/HomeView.swift b/DevLog/UI/Home/HomeView.swift index b157165b..bae1d16f 100644 --- a/DevLog/UI/Home/HomeView.swift +++ b/DevLog/UI/Home/HomeView.swift @@ -30,6 +30,7 @@ struct HomeView: View { fetchTodoByIdUseCase: container.resolve(FetchTodoByIdUseCase.self), upsertTodoUseCase: container.resolve(UpsertTodoUseCase.self), deleteTodoUseCase: container.resolve(DeleteTodoUseCase.self), + undoDeleteTodoUseCase: container.resolve(UndoDeleteTodoUseCase.self), kind: todoKind )) .environment(router) diff --git a/DevLog/UI/Home/TodoListView.swift b/DevLog/UI/Home/TodoListView.swift index 94a7c0a6..34e807e3 100644 --- a/DevLog/UI/Home/TodoListView.swift +++ b/DevLog/UI/Home/TodoListView.swift @@ -71,8 +71,7 @@ struct TodoListView: View { 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") } diff --git a/DevLog_Unit/DevLog_Unit.swift b/DevLog_Unit/DevLog_Unit.swift deleted file mode 100644 index 89c61dec..00000000 --- a/DevLog_Unit/DevLog_Unit.swift +++ /dev/null @@ -1,35 +0,0 @@ -// -// DevLog_Unit.swift -// DevLog_Unit -// -// Created by opfic on 8/8/25. -// - -import XCTest - -final class DevLog_Unit: XCTestCase { - - override func setUpWithError() throws { - // Put setup code here. This method is called before the invocation of each test method in the class. - } - - override func tearDownWithError() throws { - // Put teardown code here. This method is called after the invocation of each test method in the class. - } - - func testExample() throws { - // This is an example of a functional test case. - // Use XCTAssert and related functions to verify your tests produce the correct results. - // Any test you write for XCTest can be annotated as throws and async. - // Mark your test throws to produce an unexpected failure when your test encounters an uncaught error. - // Mark your test async to allow awaiting for asynchronous code to complete. Check the results with assertions afterwards. - } - - func testPerformanceExample() throws { - // This is an example of a performance test case. - measure { - // Put the code you want to measure the time of here. - } - } - -} diff --git a/Firebase/functions/src/fcm/notification.ts b/Firebase/functions/src/fcm/notification.ts index 16d3c9ac..af8e68fd 100644 --- a/Firebase/functions/src/fcm/notification.ts +++ b/Firebase/functions/src/fcm/notification.ts @@ -103,6 +103,7 @@ export const sendPushNotification = onTaskDispatched({ .where("isRead", "==", false) .count() .get(); + // 2. 사용자 FCM 토큰 가져오기 const tokenDocPromise = admin.firestore().doc(`users/${userId}/userData/tokens`).get(); const [tokenDoc, unreadCountSnapshot] = await Promise.all([ tokenDocPromise, diff --git a/Firebase/functions/src/index.ts b/Firebase/functions/src/index.ts index c4f884e8..6ab429f8 100644 --- a/Firebase/functions/src/index.ts +++ b/Firebase/functions/src/index.ts @@ -38,6 +38,12 @@ import { removeStaleTodoReceipts } from "./todo/remove"; +import { + requestTodoDeletion, + undoTodoDeletion, + completeTodoDeletion +} from "./todo/deletion"; + // .env 파일 로드 dotenv.config({ @@ -77,5 +83,8 @@ export { export { removeTodoNotificationDocuments, removeCompletedTodoReceipts, - removeStaleTodoReceipts + removeStaleTodoReceipts, + requestTodoDeletion, + undoTodoDeletion, + completeTodoDeletion }; diff --git a/Firebase/functions/src/todo/deletion.ts b/Firebase/functions/src/todo/deletion.ts new file mode 100644 index 00000000..0b5c75eb --- /dev/null +++ b/Firebase/functions/src/todo/deletion.ts @@ -0,0 +1,246 @@ +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"; + +const LOCATION = "asia-northeast3"; +const DELETE_DELAY_SECONDS = 5; +const QUERY_BATCH_SIZE = 200; + +type TodoDeletionTaskData = { + userId: string; + todoId: string; + createdAt?: FirebaseFirestore.Timestamp | Date | null; +}; + +export const requestTodoDeletion = onCall({ + cors: true, + maxInstances: 10, + region: LOCATION, + }, + async (request) => { + const userId = request.auth?.uid; + const todoId = typeof request.data?.todoId === "string" ? request.data.todoId.trim() : ""; + + if (!userId) { + throw new HttpsError("unauthenticated", "인증된 사용자가 아닙니다."); + } + + if (!todoId) { + throw new HttpsError("invalid-argument", "todoId가 필요합니다."); + } + + const todoRef = admin.firestore().doc(`users/${userId}/todoLists/${todoId}`); + const todoSnapshot = await todoRef.get(); + + if (!todoSnapshot.exists) { + throw new HttpsError("not-found", "Todo를 찾을 수 없습니다."); + } + + const taskRef = admin.firestore().collection("todoDeletionTasks").doc(); + const taskData = { + userId, + todoId, + createdAt: admin.firestore.FieldValue.serverTimestamp() + }; + + try { + await taskRef.set(taskData); + await todoRef.set({ + // deletingAt: 삭제 요청은 되었지만, 5초 유예 후 최종 삭제되기 전 상태를 의미한다. + deletingAt: admin.firestore.FieldValue.serverTimestamp() + }, {merge: true}); + + await updateNotificationsDeletingAt( + userId, + todoId, + admin.firestore.FieldValue.serverTimestamp() + ); + + const queue = getFunctions().taskQueue( + `locations/${LOCATION}/functions/completeTodoDeletion` + ); + await queue.enqueue( + {taskId: taskRef.id}, + {scheduleDelaySeconds: DELETE_DELAY_SECONDS} + ); + } catch (error) { + try { + await taskRef.delete(); + } catch (cleanupError) { + logger.warn("todoDeletionTasks 정리 실패", { + userId, + todoId, + taskId: taskRef.id, + error: normalizeError(cleanupError) + }); + } + + const todoSnapshot = await todoRef.get(); + + if (todoSnapshot.exists) { + await todoRef.update({ + deletingAt: admin.firestore.FieldValue.delete() + }); + } + + await updateNotificationsDeletingAt( + userId, + todoId, + admin.firestore.FieldValue.delete() + ); + logger.error("todo 삭제 요청 실패", { + userId, + todoId, + error: normalizeError(error) + }); + throw new HttpsError("internal", "Todo 삭제 요청에 실패했습니다."); + } + + return {success: true}; + } +); + +export const undoTodoDeletion = onCall({ + cors: true, + maxInstances: 10, + region: LOCATION, + }, + async (request) => { + const userId = request.auth?.uid; + const todoId = typeof request.data?.todoId === "string" ? request.data.todoId.trim() : ""; + + if (!userId) { + throw new HttpsError("unauthenticated", "인증된 사용자가 아닙니다."); + } + + if (!todoId) { + throw new HttpsError("invalid-argument", "todoId가 필요합니다."); + } + + const taskSnapshot = await admin.firestore() + .collection("todoDeletionTasks") + .where("userId", "==", userId) + .where("todoId", "==", todoId) + .get(); + + try { + const todoRef = admin.firestore().doc(`users/${userId}/todoLists/${todoId}`); + const todoSnapshot = await todoRef.get(); + + if (todoSnapshot.exists) { + await todoRef.update({ + deletingAt: admin.firestore.FieldValue.delete() + }); + } + + await updateNotificationsDeletingAt( + userId, + todoId, + 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("todo 삭제 취소 실패", { + userId, + todoId, + error: normalizeError(error) + }); + throw new HttpsError("internal", "Todo 삭제 취소에 실패했습니다."); + } + + return {success: true}; + } +); + +export const completeTodoDeletion = 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("유효하지 않은 todo 삭제 payload", request.data); + return; + } + + const taskRef = admin.firestore().collection("todoDeletionTasks").doc(taskId); + const taskSnapshot = await taskRef.get(); + if (!taskSnapshot.exists) { return; } + + const taskData = taskSnapshot.data() as TodoDeletionTaskData | undefined; + const userId = typeof taskData?.userId === "string" ? taskData.userId : ""; + const todoId = typeof taskData?.todoId === "string" ? taskData.todoId : ""; + if (!userId || !todoId) { + logger.warn("todoDeletionTasks 문서 형식이 올바르지 않습니다.", {taskId}); + return; + } + + const todoRef = admin.firestore().doc(`users/${userId}/todoLists/${todoId}`); + + try { + const todoSnapshot = await todoRef.get(); + const deletingAt = todoSnapshot.data()?.deletingAt; + + if (!todoSnapshot.exists || !deletingAt) { + await taskRef.delete(); + return; + } + + await todoRef.delete(); + await taskRef.delete(); + } catch (error) { + logger.error("todo 최종 삭제 실패", { + userId, + todoId, + taskId, + error: normalizeError(error) + }); + throw error; + } + } +); + +async function updateNotificationsDeletingAt( + userId: string, + todoId: string, + fieldValue: FirebaseFirestore.FieldValue +): Promise { + while (true) { + const snapshot = await admin.firestore() + .collection(`users/${userId}/notifications`) + .where("todoId", "==", todoId) + .limit(QUERY_BATCH_SIZE) + .get(); + + if (snapshot.empty) { return; } + + const batch = admin.firestore().batch(); + snapshot.docs.forEach((document) => { + batch.update(document.ref, { + deletingAt: fieldValue + }); + }); + await batch.commit(); + + 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 + }; +}