Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
a9a78d1
[BOOK-365] feat: add notification-related APIs
clxxrlove Oct 29, 2025
762d6ff
[BOOK-365] feat: implement FCM token persistence and retrieval
clxxrlove Oct 29, 2025
78252f2
[BOOK-365] feat: implement FCM token repository
clxxrlove Oct 29, 2025
3f3acc8
[BOOK-365] feat: implement FCM token usecase
clxxrlove Oct 29, 2025
3888682
[BOOK-365] chore: add FirebaseMessaging dependency and configure APNs
clxxrlove Oct 29, 2025
da2a6fa
[BOOK-365] feat: configure FCM/APNs in AppDelegate
clxxrlove Oct 29, 2025
1c06a97
[BOOK-365] feat: sync FCM token with server in AppCoordinator
clxxrlove Oct 29, 2025
bf81a81
[BOOK-365] refactor: rename WebPresenting → URLPresenting and support…
clxxrlove Oct 29, 2025
5412946
[BOOK-365] feat: add NotificationSettings
clxxrlove Oct 29, 2025
a8aa8fd
[BOOK-365] feat: add Notification Settings entry
clxxrlove Oct 29, 2025
d74a82b
[BOOK-365] fix: resolve ui timing issue
clxxrlove Oct 31, 2025
787e500
[BOOK-365] feat: update view to react to system notification settings
clxxrlove Oct 31, 2025
42cd1ba
[BOOK-365] feat: add convenience method to convert AuthError into Dom…
clxxrlove Oct 31, 2025
d8da93b
[BOOK-365] chore: remove duplicated keys
clxxrlove Oct 31, 2025
f87bae2
[BOOK-365] fix: adjust timing of system notification permission request
clxxrlove Oct 31, 2025
00e3ac9
[BOOK-365] chore: remove duplicated keys
clxxrlove Oct 31, 2025
151af29
[BOOK-365] fix: resolve DI violation
clxxrlove Oct 31, 2025
d132a58
[BOOK-365] fix: resolve build error
clxxrlove Oct 31, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 12 additions & 2 deletions src/Projects/BKData/Sources/API/UserAPI.swift
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@ import Foundation
enum UserAPI {
case me
case termsAgreement(termsAgreed: Bool)
case upsertFCMToken(fcmToken: String)
case upsertNotificationSettings(notificationEnabled: Bool)
}

extension UserAPI: RequestTarget {
Expand All @@ -18,21 +20,25 @@ extension UserAPI: RequestTarget {
return ""
case .termsAgreement:
return "/terms-agreement"
case .upsertFCMToken:
return "/fcm-token"
case .upsertNotificationSettings:
return "/notification-settings"
}
}

var method: HTTPMethod {
switch self {
case .me:
return .get
case .termsAgreement:
case .termsAgreement, .upsertFCMToken, .upsertNotificationSettings:
return .put
}
}

var headers: [String: String] {
switch self {
case .termsAgreement:
case .termsAgreement, .upsertFCMToken, .upsertNotificationSettings:
return [
"Content-Type": "application/json"
]
Expand All @@ -45,6 +51,10 @@ extension UserAPI: RequestTarget {
switch self {
case .termsAgreement(let termsAgreed):
return TermsAgreementRequestDTO(termsAgreed: termsAgreed)
case .upsertFCMToken(let fcmToken):
return UpsertFCMTokenRequestDTO(fcmToken: fcmToken)
case .upsertNotificationSettings(let notificationEnabled):
return NotificationStatusRequestDTO(notificationEnabled: notificationEnabled)
case .me:
return nil
}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
// Copyright © 2025 Booket. All rights reserved

import Foundation

struct NotificationStatusRequestDTO: Encodable {
let notificationEnabled: Bool
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
// Copyright © 2025 Booket. All rights reserved

import Foundation

struct UpsertFCMTokenRequestDTO: Encodable {
let fcmToken: String
}
Original file line number Diff line number Diff line change
Expand Up @@ -9,14 +9,16 @@ public struct UserProfileResponseDTO: Decodable {
public let nickname: String
public let provider: String
public let termsAgreed: Bool
public let notificationEnabled: Bool

public func toUserProfile() -> UserProfile {
return UserProfile(
id: self.id,
email: self.email,
nickname: self.nickname,
provider: self.provider,
termsAgreed: self.termsAgreed
termsAgreed: self.termsAgreed,
notificationEnabled: self.notificationEnabled
)
}
}
18 changes: 18 additions & 0 deletions src/Projects/BKData/Sources/DataAssembly.swift
Original file line number Diff line number Diff line change
Expand Up @@ -129,5 +129,23 @@ public struct DataAssembly: Assembly {
@Autowired var networkProvider: NetworkProvider
return DefaultAppStoreRepository(networkProvider: networkProvider)
}

container.register(
type: NotificationRepository.self
) { _ in
@Autowired(name: "OAuth") var networkProvider: NetworkProvider
return DefaultNotificationRepository(networkProvider: networkProvider)
}

container.register(
type: PushTokenRepository.self
) { _ in
@Autowired var pushTokenProvider: PushTokenProvider
@Autowired var pushTokenStore: PushTokenStore
return DefaultPushTokenRepository(
pushTokenProvider: pushTokenProvider,
pushTokenStore: pushTokenStore
)
}
}
}
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

👍

Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
// Copyright © 2025 Booket. All rights reserved

public protocol PushTokenProvider {
var fcmToken: String? { get }
var isSyncNeeded: Bool? { get }
func clearCache()
}
14 changes: 14 additions & 0 deletions src/Projects/BKData/Sources/Interface/Storage/PushTokenStore.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
// Copyright © 2025 Booket. All rights reserved

import BKDomain
import Combine

public protocol PushTokenStore {
func save(
fcmToken: String
) -> AnyPublisher<Void, TokenError>

func resetSyncNeeded() -> AnyPublisher<Void, TokenError>

func clear() -> AnyPublisher<Void, TokenError>
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
// Copyright © 2025 Booket. All rights reserved

import BKCore
import BKDomain
import Combine

public struct DefaultNotificationRepository: NotificationRepository {
private let networkProvider: NetworkProvider

public init(networkProvider: NetworkProvider) {
self.networkProvider = networkProvider
}

public func upsertFCMToken(
fcmToken: String
) -> AnyPublisher<Void, DomainError> {
networkProvider.request(
target: UserAPI.upsertFCMToken(fcmToken: fcmToken),
type: UserProfileResponseDTO.self
)
.debugError(logger: AppLogger.network)
.mapError { $0.toDomainError() }
.map { _ in }
.eraseToAnyPublisher()
}

public func upsertNotificationSettings(
notificationSettings: Bool
) -> AnyPublisher<Bool, DomainError> {
networkProvider.request(
target: UserAPI.upsertNotificationSettings(
notificationEnabled: notificationSettings
),
type: UserProfileResponseDTO.self
)
.debugError(logger: AppLogger.network)
.mapError { $0.toDomainError() }
.map { $0.notificationEnabled }
.eraseToAnyPublisher()
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
// Copyright © 2025 Booket. All rights reserved

import BKCore
import BKDomain
import Combine

public struct DefaultPushTokenRepository: PushTokenRepository {
private let pushTokenProvider: PushTokenProvider
private let pushTokenStore: PushTokenStore

public init(
pushTokenProvider: PushTokenProvider,
pushTokenStore: PushTokenStore
) {
self.pushTokenProvider = pushTokenProvider
self.pushTokenStore = pushTokenStore
}

public func getFCMToken() -> String? {
return pushTokenProvider.fcmToken
}

public func isSyncNeeded() -> Bool {
return pushTokenProvider.isSyncNeeded ?? false
}

public func resetSyncNeeded() -> AnyPublisher<Void, DomainError> {
return pushTokenStore
.resetSyncNeeded()
.map { _ in
self.pushTokenProvider.clearCache()
}
.mapError { error in
Log.error("Failed to reset isSyncNeeded flag: \(error)", logger: AppLogger.storage)
return .clientError
}
.eraseToAnyPublisher()
}

public func clearCache() {
pushTokenProvider.clearCache()
}
}
20 changes: 20 additions & 0 deletions src/Projects/BKDomain/Sources/DomainAssembly.swift
Original file line number Diff line number Diff line change
Expand Up @@ -228,5 +228,25 @@ public struct DomainAssembly: Assembly {
@Autowired var repository: BookRepository
return DefaultDeleteBookUseCase(repository: repository)
}

container.register(
type: SyncFCMTokenUseCase.self
) { _ in
@Autowired var pushTokenRepository: PushTokenRepository
@Autowired var notificationRepository: NotificationRepository
return DefaultSyncFCMTokenUseCase(
pushTokenRepository: pushTokenRepository,
notificationRepository: notificationRepository
)
}

container.register(
type: UpdateNotificationSettingsUseCase.self
) { _ in
@Autowired var notificationRepository: NotificationRepository
return DefaultUpdateNotificationSettingsUseCase(
notificationRepository: notificationRepository
)
}
}
}
5 changes: 4 additions & 1 deletion src/Projects/BKDomain/Sources/Entity/UserProfile.swift
Original file line number Diff line number Diff line change
Expand Up @@ -8,18 +8,21 @@ public struct UserProfile {
public let nickname: String
public let provider: String
public let termsAgreed: Bool
public let notificationEnabled: Bool

public init(
id: String,
email: String,
nickname: String,
provider: String,
termsAgreed: Bool
termsAgreed: Bool,
notificationEnabled: Bool
) {
self.id = id
self.email = email
self.nickname = nickname
self.provider = provider
self.termsAgreed = termsAgreed
self.notificationEnabled = notificationEnabled
}
}
17 changes: 17 additions & 0 deletions src/Projects/BKDomain/Sources/Error/AuthError.swift
Original file line number Diff line number Diff line change
Expand Up @@ -10,3 +10,20 @@ public enum AuthError: Error {
case termsNotAccepted
case unknown
}

public extension AuthError {
func toDomainError() -> DomainError {
switch self {
case .sdkError(message: let message):
return .clientError
case .serverError(message: let message):
return .internalServerError
case .missingToken:
return .unauthorized
case .termsNotAccepted:
return .unauthorized
case .unknown:
return .unknown
}
}
}
1 change: 1 addition & 0 deletions src/Projects/BKDomain/Sources/Error/DomainError.swift
Original file line number Diff line number Diff line change
Expand Up @@ -7,4 +7,5 @@ public enum DomainError: Error, Equatable {
case clientError
case internalServerError
case timeout
case unknown
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
// Copyright © 2025 Booket. All rights reserved

import Combine

public protocol NotificationRepository {
func upsertFCMToken(
fcmToken: String
) -> AnyPublisher<Void, DomainError>

func upsertNotificationSettings(
notificationSettings: Bool
) -> AnyPublisher<Bool, DomainError>
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
// Copyright © 2025 Booket. All rights reserved

import Combine

public protocol PushTokenRepository {
func getFCMToken() -> String?
func isSyncNeeded() -> Bool
func resetSyncNeeded() -> AnyPublisher<Void, DomainError>
func clearCache()
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
// Copyright © 2025 Booket. All rights reserved

import Combine
import Foundation

/// FCM 토큰이 동기화가 필요한 경우 서버에 업데이트합니다.
/// isSyncNeeded 플래그를 확인하고, 필요시 서버에 전송하며, 성공 시 플래그를 리셋합니다.
public protocol SyncFCMTokenUseCase {
func execute() -> AnyPublisher<Void, DomainError>
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
// Copyright © 2025 Booket. All rights reserved

import Combine
import Foundation

/// 알림 설정을 서버에 업데이트합니다.
public protocol UpdateNotificationSettingsUseCase {
func execute(isEnabled: Bool) -> AnyPublisher<Bool, DomainError>
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
// Copyright © 2025 Booket. All rights reserved

import BKCore
import Combine
import Foundation

public struct DefaultSyncFCMTokenUseCase: SyncFCMTokenUseCase {
private let pushTokenRepository: PushTokenRepository
private let notificationRepository: NotificationRepository

public init(
pushTokenRepository: PushTokenRepository,
notificationRepository: NotificationRepository
) {
self.pushTokenRepository = pushTokenRepository
self.notificationRepository = notificationRepository
}

public func execute() -> AnyPublisher<Void, DomainError> {
guard pushTokenRepository.isSyncNeeded() else {
return Just(())
.setFailureType(to: DomainError.self)
.eraseToAnyPublisher()
}

guard let fcmToken = pushTokenRepository.getFCMToken() else {
return Just(())
.setFailureType(to: DomainError.self)
.eraseToAnyPublisher()
}

return notificationRepository
.upsertFCMToken(fcmToken: fcmToken)
.debugError(logger: AppLogger.network)
.flatMap { _ -> AnyPublisher<Void, DomainError> in
return self.pushTokenRepository.resetSyncNeeded()
}
.eraseToAnyPublisher()
}
}
Loading