diff --git a/.hermes/skills/devlog-architecture-harness/references/devlog-workflow-rules.md b/.hermes/skills/devlog-architecture-harness/references/devlog-workflow-rules.md index 095d22e6..071d1501 100644 --- a/.hermes/skills/devlog-architecture-harness/references/devlog-workflow-rules.md +++ b/.hermes/skills/devlog-architecture-harness/references/devlog-workflow-rules.md @@ -45,6 +45,7 @@ This reference holds DevLog-specific working rules that should live with the pro - If the user says they will commit or asks only for a commit message, provide commit-message guidance instead of committing. - Before proposing a commit message, inspect the actual diff and recent `git log`. +- When recent history contains GitHub merge commits, do not infer commit-message style from merge subjects such as `[#123] ... (#456)`. Open the merge commit with `git show --no-patch --format=full ` and use the individual commit messages in the body, or inspect nearby non-merge commits. - Match the repository's current Korean style and prefix pattern. - If the user explicitly specifies a prefix or noun-phrase ending, follow it exactly. - For broad architecture refactors, split commits by layer when the user asks for staged commits. diff --git a/Application/DevLogInfra/Sources/Service/PushNotificationServiceImpl.swift b/Application/DevLogInfra/Sources/Service/PushNotificationServiceImpl.swift index ece2ad88..7b37318b 100644 --- a/Application/DevLogInfra/Sources/Service/PushNotificationServiceImpl.swift +++ b/Application/DevLogInfra/Sources/Service/PushNotificationServiceImpl.swift @@ -274,12 +274,7 @@ final class PushNotificationServiceImpl: PushNotificationService { throw FirestoreError.dataNotFound("notification") } - guard let currentValue = document.data()["isRead"] as? Bool else { - logger.error("isRead not found for notification: \(document.documentID)") - throw FirestoreError.dataNotFound("isRead") - } - - try await document.reference.updateData(["isRead": !currentValue]) + try await toggleReadValue(for: document.reference) logger.info("Successfully toggled notification read") } catch { logger.error("Failed to toggle notification read", error: error) @@ -302,6 +297,24 @@ private extension PushNotificationServiceImpl { Self.record(error, code: code) } + func toggleReadValue(for notificationRef: DocumentReference) async throws { + _ = try await store.runTransaction { transaction, errorPointer in + do { + let snapshot = try transaction.getDocument(notificationRef) + guard let currentValue = snapshot.data()?["isRead"] as? Bool else { + throw FirestoreError.dataNotFound("isRead") + } + + transaction.updateData(["isRead": !currentValue], forDocument: notificationRef) + } catch let error as NSError { + errorPointer?.pointee = error + return nil + } + + return nil + } + } + func makeQuery( uid: String, query: PushNotificationQuery diff --git a/Application/DevLogInfra/Sources/Service/SocialLogin/AppleAuthenticationServiceImpl.swift b/Application/DevLogInfra/Sources/Service/SocialLogin/AppleAuthenticationServiceImpl.swift index 40e394b6..2ed89436 100644 --- a/Application/DevLogInfra/Sources/Service/SocialLogin/AppleAuthenticationServiceImpl.swift +++ b/Application/DevLogInfra/Sources/Service/SocialLogin/AppleAuthenticationServiceImpl.swift @@ -197,15 +197,8 @@ final class AppleAuthenticationServiceImpl: AuthenticationService { let tokensRef = store.document(FirestorePath.userData(uid, document: .tokens)) - logger.info("Starting Apple token document fetch for unlink. uid: \(uid)") - let doc = try await tokensRef.getDocument() - - if doc.exists { - logger.info("Starting Apple refresh token deletion from Firestore for unlink. uid: \(uid)") - try await tokensRef.updateData([ - "appleRefreshToken": FieldValue.delete() - ]) - } + logger.info("Starting Apple refresh token deletion from Firestore for unlink. uid: \(uid)") + try await deleteAppleRefreshToken(from: tokensRef) logger.info("Starting Firebase Apple provider unlink. uid: \(uid)") _ = try await user?.unlink(fromProvider: providerID.rawValue) @@ -337,6 +330,28 @@ final class AppleAuthenticationServiceImpl: AuthenticationService { } private extension AppleAuthenticationServiceImpl { + func deleteAppleRefreshToken(from tokensRef: DocumentReference) async throws { + _ = try await store.runTransaction { transaction, errorPointer in + let snapshot: DocumentSnapshot + + do { + snapshot = try transaction.getDocument(tokensRef) + } catch let error as NSError { + errorPointer?.pointee = error + return nil + } + + if snapshot.exists { + transaction.updateData( + ["appleRefreshToken": FieldValue.delete()], + forDocument: tokensRef + ) + } + + return nil + } + } + private static func record(_ error: Error, code: CrashlyticsError.Code) { FirebaseCrashlyticsHelper.record( error, diff --git a/Application/DevLogInfra/Sources/Service/TodoServiceImpl.swift b/Application/DevLogInfra/Sources/Service/TodoServiceImpl.swift index 12aae818..9fea958d 100644 --- a/Application/DevLogInfra/Sources/Service/TodoServiceImpl.swift +++ b/Application/DevLogInfra/Sources/Service/TodoServiceImpl.swift @@ -348,66 +348,57 @@ private extension TodoServiceImpl { for todoRef: DocumentReference, counterRef: DocumentReference ) async throws { - try await withCheckedThrowingContinuation { (continuation: CheckedContinuation) in - store.runTransaction({ transaction, errorPointer in - let todoSnapshot: DocumentSnapshot + _ = try await store.runTransaction { transaction, errorPointer in + let todoSnapshot: DocumentSnapshot + + do { + todoSnapshot = try transaction.getDocument(todoRef) + } catch let error as NSError { + errorPointer?.pointee = error + return nil + } + + var todoData = data + + if !todoSnapshot.exists { + let counterSnapshot: DocumentSnapshot do { - todoSnapshot = try transaction.getDocument(todoRef) + counterSnapshot = try transaction.getDocument(counterRef) } catch let error as NSError { errorPointer?.pointee = error return nil } - var todoData = data - - if !todoSnapshot.exists { - let counterSnapshot: DocumentSnapshot - - do { - counterSnapshot = try transaction.getDocument(counterRef) - } catch let error as NSError { - errorPointer?.pointee = error - return nil - } - - let nextNumberValue = counterSnapshot.data()?[CounterFieldKey.nextNumber.rawValue] - let nextNumber: Int - - if let storedNextNumber = nextNumberValue as? Int { - nextNumber = storedNextNumber - } else if counterSnapshot.exists { - errorPointer?.pointee = NSError( - domain: "TodoServiceImpl", - code: 1, - userInfo: [NSLocalizedDescriptionKey: "Todo counter is invalid."] - ) - return nil - } else { - nextNumber = 1 - } + let nextNumberValue = counterSnapshot.data()?[CounterFieldKey.nextNumber.rawValue] + let nextNumber: Int - todoData[TodoFieldKey.number.rawValue] = nextNumber - transaction.setData( - [ - CounterFieldKey.nextNumber.rawValue: nextNumber + 1, - CounterFieldKey.updatedAt.rawValue: FieldValue.serverTimestamp() - ], - forDocument: counterRef, - merge: true + if let storedNextNumber = nextNumberValue as? Int { + nextNumber = storedNextNumber + } else if counterSnapshot.exists { + errorPointer?.pointee = NSError( + domain: "TodoServiceImpl", + code: 1, + userInfo: [NSLocalizedDescriptionKey: "Todo counter is invalid."] ) + return nil + } else { + nextNumber = 1 } - transaction.setData(todoData, forDocument: todoRef, merge: true) - return nil - }) { _, error in - if let error { - continuation.resume(throwing: error) - return - } - - continuation.resume(returning: ()) + todoData[TodoFieldKey.number.rawValue] = nextNumber + transaction.setData( + [ + CounterFieldKey.nextNumber.rawValue: nextNumber + 1, + CounterFieldKey.updatedAt.rawValue: FieldValue.serverTimestamp() + ], + forDocument: counterRef, + merge: true + ) } + + transaction.setData(todoData, forDocument: todoRef, merge: true) + return nil } } diff --git a/Application/DevLogInfra/Sources/Service/UserServiceImpl.swift b/Application/DevLogInfra/Sources/Service/UserServiceImpl.swift index 8e1c827a..0cf2df5c 100644 --- a/Application/DevLogInfra/Sources/Service/UserServiceImpl.swift +++ b/Application/DevLogInfra/Sources/Service/UserServiceImpl.swift @@ -36,12 +36,6 @@ final class UserServiceImpl: UserService { } do { - let userRef = store.document(FirestorePath.user(user.uid)) - let infoRef = store.document(FirestorePath.userData(user.uid, document: .info)) - let tokensRef = store.document(FirestorePath.userData(user.uid, document: .tokens)) - let settingsRef = store.document(FirestorePath.userData(user.uid, document: .settings)) - let todoCounterRef = store.document(FirestorePath.counter(user.uid, document: .todo)) - // 사용자 기본 정보 var userField: [String: Any] = [ "currentProvider": response.providerID @@ -62,64 +56,22 @@ final class UserServiceImpl: UserService { userField["appleName"] = user.displayName } - let userDocument = try await userRef.getDocument() - if !userDocument.exists { - userField["statusMsg"] = "" - userField["createdAt"] = FieldValue.serverTimestamp() - } - - var settingField: [String: Any] = [:] + var tokenField: [String: Any] = [:] if let fcmToken = response.fcmToken { - settingField["fcmToken"] = fcmToken + tokenField["fcmToken"] = fcmToken } // 깃헙 로그인 시 추가 정보 저장 if response.providerID == "github.com", let accessToken = response.accessToken { - settingField["githubAccessToken"] = accessToken + tokenField["githubAccessToken"] = accessToken } - // Reference to capture ~ in concurrently-executing code; Swift 6 lang mode의 경고 해결 - let userFieldSnapshot = userField - let settingFieldSnapshot = settingField - // ----------------------------------------------------- - - async let userUpdate: Void = userRef.setData( - ["updatedAt": FieldValue.serverTimestamp()], - merge: true + try await upsertUserDocuments( + uid: user.uid, + userField: userField, + tokenField: tokenField ) - async let infoUpdate: Void = infoRef.setData(userFieldSnapshot, merge: true) - async let tokensUpdate: Void = { - guard !settingFieldSnapshot.isEmpty else { return } - try await tokensRef.setData(settingFieldSnapshot, merge: true) - }() - - let settingsDocument = try await settingsRef.getDocument() - var settingsField: [String: Any] = [ - "timeZone": TimeZone.autoupdatingCurrent.identifier - ] - if !settingsDocument.exists { - settingsField["allowPushNotification"] = true - settingsField["pushNotificationHour"] = 9 - settingsField["pushNotificationMinute"] = 0 - } - - let settingsFieldSnapshot = settingsField - async let settingsUpdate: Void = settingsRef.setData(settingsFieldSnapshot, merge: true) - async let todoCounterUpdate: Void? = { // 옵셔널이 포함된 이유: 신규 사용자일 때만 할 작업 - guard !userDocument.exists else { return nil } - - try await todoCounterRef.setData( - [ - "nextNumber": 1, - "updatedAt": FieldValue.serverTimestamp() - ], - merge: true - ) - return nil - }() - - _ = try await (userUpdate, infoUpdate, tokensUpdate, settingsUpdate, todoCounterUpdate) logger.info("Successfully upserted user: \(user.uid)") } catch { @@ -241,4 +193,70 @@ private extension UserServiceImpl { private func record(_ error: Error, code: CrashlyticsError.Code) { Self.record(error, code: code) } + + func upsertUserDocuments( + uid: String, + userField: [String: Any], + tokenField: [String: Any] + ) async throws { + let userRef = store.document(FirestorePath.user(uid)) + let infoRef = store.document(FirestorePath.userData(uid, document: .info)) + let tokensRef = store.document(FirestorePath.userData(uid, document: .tokens)) + let settingsRef = store.document(FirestorePath.userData(uid, document: .settings)) + let todoCounterRef = store.document(FirestorePath.counter(uid, document: .todo)) + + _ = try await store.runTransaction { transaction, errorPointer in + let userDocument: DocumentSnapshot + let settingsDocument: DocumentSnapshot + + do { + userDocument = try transaction.getDocument(userRef) + settingsDocument = try transaction.getDocument(settingsRef) + } catch let error as NSError { + errorPointer?.pointee = error + return nil + } + + var infoField = userField + if !userDocument.exists { + infoField["statusMsg"] = "" + infoField["createdAt"] = FieldValue.serverTimestamp() + } + + var settingsField: [String: Any] = [ + "timeZone": TimeZone.autoupdatingCurrent.identifier + ] + if !settingsDocument.exists { + settingsField["allowPushNotification"] = true + settingsField["pushNotificationHour"] = 9 + settingsField["pushNotificationMinute"] = 0 + } + + transaction.setData( + ["updatedAt": FieldValue.serverTimestamp()], + forDocument: userRef, + merge: true + ) + transaction.setData(infoField, forDocument: infoRef, merge: true) + + if !tokenField.isEmpty { + transaction.setData(tokenField, forDocument: tokensRef, merge: true) + } + + transaction.setData(settingsField, forDocument: settingsRef, merge: true) + + if !userDocument.exists { + transaction.setData( + [ + "nextNumber": 1, + "updatedAt": FieldValue.serverTimestamp() + ], + forDocument: todoCounterRef, + merge: true + ) + } + + return nil + } + } }