From 14eeee784ed5bc3011a7ad67cb0f68285d0d5ce4 Mon Sep 17 00:00:00 2001 From: Roy Date: Tue, 30 Dec 2025 11:04:16 +0900 Subject: [PATCH 01/26] =?UTF-8?q?=E2=9C=A8[feat]:=20=EC=98=A8=EB=B3=B4?= =?UTF-8?q?=EB=94=A9=20API=20=EB=B0=8F=20=EC=B4=88=EB=8C=80=EC=BD=94?= =?UTF-8?q?=EB=93=9C=20=EA=B2=80=EC=A6=9D=20=EC=8B=9C=EC=8A=A4=ED=85=9C=20?= =?UTF-8?q?=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../API/OnBoarding/OnBoardingAPI.swift | 31 ++++++++++++ .../OnBoarding/DTO/VerifyCodeDTO.swift | 18 +++++++ .../OnBoarding/Mapper/VerifyCodeDTO+.swift | 19 +++++++ .../Auth/Repository/AuthRepositoryImpl.swift | 2 - .../OnBoarding/OnBoardingRepositoryImpl.swift | 31 ++++++++++++ .../Sources/Common/Extension+Encodable.swift | 7 +++ .../OnBoarding/OnBoardingService.swift | 50 +++++++++++++++++++ 7 files changed, 156 insertions(+), 2 deletions(-) create mode 100644 Projects/Data/API/Sources/API/OnBoarding/OnBoardingAPI.swift create mode 100644 Projects/Data/Model/Sources/OnBoarding/DTO/VerifyCodeDTO.swift create mode 100644 Projects/Data/Model/Sources/OnBoarding/Mapper/VerifyCodeDTO+.swift create mode 100644 Projects/Data/Repository/Sources/OnBoarding/OnBoardingRepositoryImpl.swift create mode 100644 Projects/Data/Service/Sources/OnBoarding/OnBoardingService.swift diff --git a/Projects/Data/API/Sources/API/OnBoarding/OnBoardingAPI.swift b/Projects/Data/API/Sources/API/OnBoarding/OnBoardingAPI.swift new file mode 100644 index 00000000..4a32fb25 --- /dev/null +++ b/Projects/Data/API/Sources/API/OnBoarding/OnBoardingAPI.swift @@ -0,0 +1,31 @@ +// +// OnBoardingAPI.swift +// API +// +// Created by Wonji Suh on 12/30/25. +// + +import Foundation + +public enum OnBoardingAPI: String, CaseIterable { + case verifyCode + case teams + case jobs + case mangerRole + + public var description: String { + switch self { + case .verifyCode: + return "verify-code" + + case .teams: + return "teams" + + case .jobs: + return "jobs" + + case .mangerRole: + return "manager-roles" + } + } +} diff --git a/Projects/Data/Model/Sources/OnBoarding/DTO/VerifyCodeDTO.swift b/Projects/Data/Model/Sources/OnBoarding/DTO/VerifyCodeDTO.swift new file mode 100644 index 00000000..b4344cf2 --- /dev/null +++ b/Projects/Data/Model/Sources/OnBoarding/DTO/VerifyCodeDTO.swift @@ -0,0 +1,18 @@ +// +// VerifyCodeDTO.swift +// Model +// +// Created by Wonji Suh on 12/30/25. +// + +import Foundation + +public struct VerifyCodeDTO: Decodable { + let generationID: Int + let generationName, type, description: String + + enum CodingKeys: String, CodingKey { + case generationID = "generationId" + case generationName, type, description + } +} diff --git a/Projects/Data/Model/Sources/OnBoarding/Mapper/VerifyCodeDTO+.swift b/Projects/Data/Model/Sources/OnBoarding/Mapper/VerifyCodeDTO+.swift new file mode 100644 index 00000000..8cd5d883 --- /dev/null +++ b/Projects/Data/Model/Sources/OnBoarding/Mapper/VerifyCodeDTO+.swift @@ -0,0 +1,19 @@ +// +// VerifyCodeDTO+.swift +// Model +// +// Created by Wonji Suh on 12/30/25. +// + +import Foundation + +import Entity + +public extension VerifyCodeDTO { + func toDomain() -> VerifyCodeEntity { + return VerifyCodeEntity( + generationID: self.generationID, + type: Staff(rawValue: self.type) ?? .member + ) + } +} diff --git a/Projects/Data/Repository/Sources/Auth/Repository/AuthRepositoryImpl.swift b/Projects/Data/Repository/Sources/Auth/Repository/AuthRepositoryImpl.swift index 02dd7055..a7781ab9 100644 --- a/Projects/Data/Repository/Sources/Auth/Repository/AuthRepositoryImpl.swift +++ b/Projects/Data/Repository/Sources/Auth/Repository/AuthRepositoryImpl.swift @@ -17,8 +17,6 @@ import FirebaseFirestore @Observable final public class AuthRepositoryImpl: AuthInterface, Sendable { - - private let provider: MoyaProvider public init( diff --git a/Projects/Data/Repository/Sources/OnBoarding/OnBoardingRepositoryImpl.swift b/Projects/Data/Repository/Sources/OnBoarding/OnBoardingRepositoryImpl.swift new file mode 100644 index 00000000..3c961db7 --- /dev/null +++ b/Projects/Data/Repository/Sources/OnBoarding/OnBoardingRepositoryImpl.swift @@ -0,0 +1,31 @@ +// +// OnBoardingRepositoryImpl.swift +// Repository +// +// Created by Wonji Suh on 12/30/25. +// + +import Foundation + +import DomainInterface +import Service +import Entity + +@preconcurrency import AsyncMoya + +final public class OnBoardingRepositoryImpl:OnBoardingInterface { + private let provider: MoyaProvider + + public init( + provider: MoyaProvider = MoyaProvider.default + ) { + self.provider = provider + } + + public func verifyCode( + code: String + ) async throws -> VerifyCodeEntity { + let dto: VerifyCodeDTO = try await provider.request(.verifyCode(code: code)) + return dto.toDomain() + } +} diff --git a/Projects/Data/Service/Sources/Common/Extension+Encodable.swift b/Projects/Data/Service/Sources/Common/Extension+Encodable.swift index be929cd6..7e817b6e 100644 --- a/Projects/Data/Service/Sources/Common/Extension+Encodable.swift +++ b/Projects/Data/Service/Sources/Common/Extension+Encodable.swift @@ -14,3 +14,10 @@ extension Encodable { } } +extension String { + /// 문자열을 지정된 키로 API 파라미터용 Dictionary로 변환 + func toDictionary(key: String) -> [String: Any] { + return [key: self] + } +} + diff --git a/Projects/Data/Service/Sources/OnBoarding/OnBoardingService.swift b/Projects/Data/Service/Sources/OnBoarding/OnBoardingService.swift new file mode 100644 index 00000000..c9c047f7 --- /dev/null +++ b/Projects/Data/Service/Sources/OnBoarding/OnBoardingService.swift @@ -0,0 +1,50 @@ +// +// OnBoardingService.swift +// Service +// +// Created by Wonji Suh on 12/30/25. +// + +import Foundation + +import API + +import AsyncMoya + +public enum OnBoardingService { + case verifyCode(code : String) +} + + +extension OnBoardingService: BaseTargetType { + public typealias Domain = AttendanceDomain + + public var domain: AttendanceDomain { + return .onboarding + } + + public var urlPath: String { + switch self { + case .verifyCode: + return OnBoardingAPI.verifyCode.description + } + } + + public var error: [Int : AsyncMoya.NetworkError]? { + return nil + } + + public var parameters: [String : Any]? { + switch self { + case .verifyCode(let code): + return code.toDictionary(key: "code") + } + } + + public var method: Moya.Method { + switch self { + case .verifyCode: + return .get + } + } +} From 74311ad54d0651b045f7508b7477c9b17229ed88 Mon Sep 17 00:00:00 2001 From: Roy Date: Tue, 30 Dec 2025 11:04:34 +0900 Subject: [PATCH 02/26] =?UTF-8?q?=F0=9F=94=A7[refactor]:=20=ED=9A=8C?= =?UTF-8?q?=EC=9B=90=EA=B0=80=EC=9E=85=20=EB=B0=8F=20=EC=98=A8=EB=B3=B4?= =?UTF-8?q?=EB=94=A9=20=EB=A0=88=ED=8F=AC=EC=A7=80=ED=86=A0=EB=A6=AC=20Moc?= =?UTF-8?q?k=20=EA=B5=AC=ED=98=84=20=EA=B0=9C=EC=84=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../DefaultOnBoardingRepositoryImpl.swift | 221 +++++++++++++ .../OnBoarding/MockOnBoardingRepository.swift | 168 ++++++++++ .../OnBoarding/OnBoardingInterface.swift | 38 +++ .../SignUp/DefaultSignUpRepositoryImpl.swift | 295 ++++++++++++++++- .../Sources/SignUp/MockSignUpRepository.swift | 301 ++++++++++++++++++ .../Sources/SignUp/SignUpInterface.swift | 6 +- .../Entity/Sources/Error/SignUpError.swift | 216 +++++++++++++ .../Entity/Sources/OnBoarding/Staff.swift | 22 ++ .../Sources/OnBoarding/VerifyCodeEntity.swift | 21 ++ .../Sources/Auth/AuthUseCaseImpl.swift | 9 +- .../OnBoarding/OnBoardingUseCaseImpl.swift | 38 +++ 11 files changed, 1322 insertions(+), 13 deletions(-) create mode 100644 Projects/Domain/DomainInterface/Sources/OnBoarding/DefaultOnBoardingRepositoryImpl.swift create mode 100644 Projects/Domain/DomainInterface/Sources/OnBoarding/MockOnBoardingRepository.swift create mode 100644 Projects/Domain/DomainInterface/Sources/OnBoarding/OnBoardingInterface.swift create mode 100644 Projects/Domain/DomainInterface/Sources/SignUp/MockSignUpRepository.swift create mode 100644 Projects/Domain/Entity/Sources/Error/SignUpError.swift create mode 100644 Projects/Domain/Entity/Sources/OnBoarding/Staff.swift create mode 100644 Projects/Domain/Entity/Sources/OnBoarding/VerifyCodeEntity.swift create mode 100644 Projects/Domain/UseCase/Sources/OnBoarding/OnBoardingUseCaseImpl.swift diff --git a/Projects/Domain/DomainInterface/Sources/OnBoarding/DefaultOnBoardingRepositoryImpl.swift b/Projects/Domain/DomainInterface/Sources/OnBoarding/DefaultOnBoardingRepositoryImpl.swift new file mode 100644 index 00000000..c4996f8e --- /dev/null +++ b/Projects/Domain/DomainInterface/Sources/OnBoarding/DefaultOnBoardingRepositoryImpl.swift @@ -0,0 +1,221 @@ +// +// DefaultOnBoardingRepositoryImpl.swift +// DomainInterface +// +// Created by Wonji Suh on 12/30/25. +// + +import Foundation +import Entity + +// MARK: - OnBoarding Errors + +public enum OnBoardingError: Error, LocalizedError { + case invalidCode + case verifyFailed + case networkError + case unknownError + + public var errorDescription: String? { + switch self { + case .invalidCode: + return "Invalid verification code" + case .verifyFailed: + return "Verification failed" + case .networkError: + return "Network connection error" + case .unknownError: + return "Unknown error occurred" + } + } +} + +public final class DefaultOnBoardingRepositoryImpl: OnBoardingInterface, @unchecked Sendable { + + // MARK: - Configuration + public enum Configuration { + case success + case failure + case invalidCode + case networkError + case memberRole + case managerRole + case customDelay(TimeInterval) + + var shouldSucceed: Bool { + switch self { + case .success, .memberRole, .managerRole, .customDelay: + return true + case .failure, .invalidCode, .networkError: + return false + } + } + + var delay: TimeInterval { + switch self { + case .customDelay(let delay): + return delay + default: + return 0.5 // 실제 네트워크 지연 시뮬레이션 + } + } + + var staffType: Staff { + switch self { + case .managerRole: + return .manger + default: + return .member + } + } + + var mockGenerationID: Int { + switch self { + case .managerRole: + return 2024 + default: + return 2025 + } + } + + var error: OnBoardingError? { + switch self { + case .success, .memberRole, .managerRole, .customDelay: + return nil + case .failure: + return .verifyFailed + case .invalidCode: + return .invalidCode + case .networkError: + return .networkError + } + } + } + + // MARK: - Properties + private var configuration: Configuration = .success + private var verifyCallCount = 0 + private var lastVerifyCall: Date? + + // MARK: - Initialization + + public init(configuration: Configuration = .success) { + self.configuration = configuration + } + + // MARK: - Configuration Methods + + public func setConfiguration(_ configuration: Configuration) { + self.configuration = configuration + verifyCallCount = 0 + lastVerifyCall = nil + } + + public func getVerifyCallCount() -> Int { + return verifyCallCount + } + + public func getLastVerifyCall() -> Date? { + return lastVerifyCall + } + + public func reset() { + configuration = .success + verifyCallCount = 0 + lastVerifyCall = nil + } + + // MARK: - OnBoardingInterface Implementation + + public func verifyCode(code: String) async throws -> VerifyCodeEntity { + // Track call + verifyCallCount += 1 + lastVerifyCall = Date() + + // Apply delay + if configuration.delay > 0 { + try await Task.sleep(for: .seconds(configuration.delay)) + } + + // 코드 유효성 검사 (기본적인 검사) + guard !code.isEmpty, code.count >= 4 else { + throw OnBoardingError.invalidCode + } + + // Configuration 기반 응답 처리 + if !configuration.shouldSucceed, let error = configuration.error { + throw error + } + + // 특정 코드에 따른 응답 분기 처리 + switch code { + case "1234", "test", "member": + return VerifyCodeEntity( + generationID: 2025, + type: .member + ) + + case "5678", "admin", "manager": + return VerifyCodeEntity( + generationID: 2024, + type: .manger + ) + + case "error", "fail": + throw OnBoardingError.verifyFailed + + case "network": + throw OnBoardingError.networkError + + case "invalid", "wrong": + throw OnBoardingError.invalidCode + + default: + // Configuration에 따른 기본 응답 + return VerifyCodeEntity( + generationID: configuration.mockGenerationID, + type: configuration.staffType + ) + } + } +} + +// MARK: - Convenience Static Methods + +public extension DefaultOnBoardingRepositoryImpl { + + /// Creates a pre-configured instance for success scenario with member role + static func success() -> DefaultOnBoardingRepositoryImpl { + return DefaultOnBoardingRepositoryImpl(configuration: .success) + } + + /// Creates a pre-configured instance for failure scenario + static func failure() -> DefaultOnBoardingRepositoryImpl { + return DefaultOnBoardingRepositoryImpl(configuration: .failure) + } + + /// Creates a pre-configured instance for invalid code scenario + static func invalidCode() -> DefaultOnBoardingRepositoryImpl { + return DefaultOnBoardingRepositoryImpl(configuration: .invalidCode) + } + + /// Creates a pre-configured instance for member role scenario + static func memberRole() -> DefaultOnBoardingRepositoryImpl { + return DefaultOnBoardingRepositoryImpl(configuration: .memberRole) + } + + /// Creates a pre-configured instance for manager role scenario + static func managerRole() -> DefaultOnBoardingRepositoryImpl { + return DefaultOnBoardingRepositoryImpl(configuration: .managerRole) + } + + /// Creates a pre-configured instance for network error scenario + static func networkError() -> DefaultOnBoardingRepositoryImpl { + return DefaultOnBoardingRepositoryImpl(configuration: .networkError) + } + + /// Creates a pre-configured instance with custom delay + static func withDelay(_ delay: TimeInterval) -> DefaultOnBoardingRepositoryImpl { + return DefaultOnBoardingRepositoryImpl(configuration: .customDelay(delay)) + } +} diff --git a/Projects/Domain/DomainInterface/Sources/OnBoarding/MockOnBoardingRepository.swift b/Projects/Domain/DomainInterface/Sources/OnBoarding/MockOnBoardingRepository.swift new file mode 100644 index 00000000..7f826df9 --- /dev/null +++ b/Projects/Domain/DomainInterface/Sources/OnBoarding/MockOnBoardingRepository.swift @@ -0,0 +1,168 @@ +// +// MockOnBoardingRepository.swift +// DomainInterface +// +// Created by Wonji Suh on 12/30/25. +// + +import Foundation +import Entity + +public actor MockOnBoardingRepository: OnBoardingInterface { + + // MARK: - Configuration + public enum Configuration { + case success + case failure + case invalidCode + case networkError + case memberRole + case managerRole + case customDelay(TimeInterval) + + var shouldSucceed: Bool { + switch self { + case .success, .memberRole, .managerRole, .customDelay: + return true + case .failure, .invalidCode, .networkError: + return false + } + } + + var delay: TimeInterval { + switch self { + case .customDelay(let delay): + return delay + default: + return 0.1 + } + } + + var staffType: Staff { + switch self { + case .managerRole: + return .manger + default: + return .member + } + } + + var mockGenerationID: Int { + switch self { + case .managerRole: + return 2024 + default: + return 2025 + } + } + + var error: OnBoardingError? { + switch self { + case .success, .memberRole, .managerRole, .customDelay: + return nil + case .failure: + return .verifyFailed + case .invalidCode: + return .invalidCode + case .networkError: + return .networkError + } + } + } + + // MARK: - State + private var configuration: Configuration = .success + private var verifyCallCount = 0 + private var lastVerifyCall: Date? + + // MARK: - Public Configuration Methods + + public init(configuration: Configuration = .success) { + self.configuration = configuration + } + + public func setConfiguration(_ configuration: Configuration) { + self.configuration = configuration + verifyCallCount = 0 + lastVerifyCall = nil + } + + public func getVerifyCallCount() -> Int { + return verifyCallCount + } + + public func getLastVerifyCall() -> Date? { + return lastVerifyCall + } + + public func reset() { + configuration = .success + verifyCallCount = 0 + lastVerifyCall = nil + } + + // MARK: - OnBoardingInterface Implementation + + public func verifyCode(code: String) async throws -> VerifyCodeEntity { + // Track call + verifyCallCount += 1 + lastVerifyCall = Date() + + // Apply delay + if configuration.delay > 0 { + try await Task.sleep(for: .seconds(configuration.delay)) + } + + // Handle failure scenarios + if !configuration.shouldSucceed, let error = configuration.error { + throw error + } + + // Return success payload + return VerifyCodeEntity( + generationID: configuration.mockGenerationID, + type: configuration.staffType + ) + } +} + +// MARK: - Convenience Static Methods + +public extension MockOnBoardingRepository { + + /// Creates a pre-configured actor for success scenario with member role + static func success() -> MockOnBoardingRepository { + return MockOnBoardingRepository(configuration: .success) + } + + /// Creates a pre-configured actor for failure scenario + static func failure() -> MockOnBoardingRepository { + return MockOnBoardingRepository(configuration: .failure) + } + + /// Creates a pre-configured actor for invalid code scenario + static func invalidCode() -> MockOnBoardingRepository { + return MockOnBoardingRepository(configuration: .invalidCode) + } + + /// Creates a pre-configured actor for member role scenario + static func memberRole() -> MockOnBoardingRepository { + return MockOnBoardingRepository(configuration: .memberRole) + } + + /// Creates a pre-configured actor for manager role scenario + static func managerRole() -> MockOnBoardingRepository { + return MockOnBoardingRepository(configuration: .managerRole) + } + + /// Creates a pre-configured actor for network error scenario + static func networkError() -> MockOnBoardingRepository { + return MockOnBoardingRepository(configuration: .networkError) + } + + /// Creates a pre-configured actor with custom delay + static func withDelay(_ delay: TimeInterval) -> MockOnBoardingRepository { + return MockOnBoardingRepository(configuration: .customDelay(delay)) + } +} + diff --git a/Projects/Domain/DomainInterface/Sources/OnBoarding/OnBoardingInterface.swift b/Projects/Domain/DomainInterface/Sources/OnBoarding/OnBoardingInterface.swift new file mode 100644 index 00000000..42a4a353 --- /dev/null +++ b/Projects/Domain/DomainInterface/Sources/OnBoarding/OnBoardingInterface.swift @@ -0,0 +1,38 @@ +// +// OnBoardingInterface.swift +// DomainInterface +// +// Created by Wonji Suh on 12/30/25. +// + +import Foundation + +import WeaveDI +import Entity + + +public protocol OnBoardingInterface: Sendable { + func verifyCode(code: String) async throws -> VerifyCodeEntity +} + +public struct OnBoardingRepositoryDependency: DependencyKey { + public static var liveValue: OnBoardingInterface { + UnifiedDI.resolve(OnBoardingInterface.self) ?? DefaultOnBoardingRepositoryImpl() + } + + public static var testValue: OnBoardingInterface { + UnifiedDI.resolve(OnBoardingInterface.self) ?? DefaultOnBoardingRepositoryImpl() + } + + public static var previewValue: OnBoardingInterface { + MockOnBoardingRepository.success() + } +} + +public extension DependencyValues { + var onBoardingRepository: OnBoardingInterface { + get { self[OnBoardingRepositoryDependency.self] } + set { self[OnBoardingRepositoryDependency.self] = newValue } + } +} + diff --git a/Projects/Domain/DomainInterface/Sources/SignUp/DefaultSignUpRepositoryImpl.swift b/Projects/Domain/DomainInterface/Sources/SignUp/DefaultSignUpRepositoryImpl.swift index b72300b5..cc76cd9d 100644 --- a/Projects/Domain/DomainInterface/Sources/SignUp/DefaultSignUpRepositoryImpl.swift +++ b/Projects/Domain/DomainInterface/Sources/SignUp/DefaultSignUpRepositoryImpl.swift @@ -8,25 +8,308 @@ import Foundation import Model +import Entity /// SignUp Repository의 기본 구현체 (테스트/프리뷰용) -final public class DefaultSignUpRepositoryImpl: SignUpInterface { - public init() {} +final public class DefaultSignUpRepositoryImpl: SignUpInterface, @unchecked Sendable { + + // MARK: - Configuration + public enum Configuration { + case success + case failure + case invalidEmail + case duplicateEmail + case weakPassword + case invalidInviteCode + case expiredInviteCode + case networkError + case serverError + case customDelay(TimeInterval) + + var shouldSucceed: Bool { + switch self { + case .success, .customDelay: + return true + case .failure, .invalidEmail, .duplicateEmail, .weakPassword, + .invalidInviteCode, .expiredInviteCode, .networkError, .serverError: + return false + } + } + + var delay: TimeInterval { + switch self { + case .customDelay(let delay): + return delay + default: + return 1.0 // 실제 네트워크 지연 시뮬레이션 + } + } + + var signUpError: SignUpError? { + switch self { + case .success, .customDelay: + return nil + case .failure: + return .accountCreationFailed + case .invalidEmail: + return .invalidEmail + case .duplicateEmail: + return .duplicateEmail + case .weakPassword: + return .weakPassword + case .invalidInviteCode: + return .invalidInviteCode + case .expiredInviteCode: + return .expiredInviteCode + case .networkError: + return .networkError + case .serverError: + return .serverError("서버 내부 오류") + } + } + } + + // MARK: - Properties + private var configuration: Configuration = .success + private var registerCallCount = 0 + private var validateCallCount = 0 + private var checkEmailCallCount = 0 + private var lastCall: Date? + + // MARK: - Initialization + + public init(configuration: Configuration = .success) { + self.configuration = configuration + } + + // MARK: - Configuration Methods + + public func setConfiguration(_ configuration: Configuration) { + self.configuration = configuration + registerCallCount = 0 + validateCallCount = 0 + checkEmailCallCount = 0 + lastCall = nil + } + + public func getRegisterCallCount() -> Int { registerCallCount } + public func getValidateCallCount() -> Int { validateCallCount } + public func getCheckEmailCallCount() -> Int { checkEmailCallCount } + public func getLastCall() -> Date? { lastCall } + + public func reset() { + configuration = .success + registerCallCount = 0 + validateCallCount = 0 + checkEmailCallCount = 0 + lastCall = nil + } + + // MARK: - SignUpInterface Implementation public func registerAccount( email: String, password: String ) async throws -> SignUpModel? { - return nil + // Track call + registerCallCount += 1 + lastCall = Date() + + // Apply delay + if configuration.delay > 0 { + try await Task.sleep(for: .seconds(configuration.delay)) + } + + // 입력 값 검증 + guard !email.isEmpty else { + throw SignUpError.missingRequiredField("이메일") + } + + guard !password.isEmpty else { + throw SignUpError.missingRequiredField("비밀번호") + } + + // 특정 이메일 패턴 검사 + if email == "invalid@" || !email.contains("@") { + throw SignUpError.invalidEmail + } + + if email == "duplicate@example.com" { + throw SignUpError.duplicateEmail + } + + // 비밀번호 검증 + if password.count < 8 { + throw SignUpError.passwordTooShort + } + + if password == "weak" || password == "123456" { + throw SignUpError.weakPassword + } + + // Configuration 기반 응답 처리 + if !configuration.shouldSucceed, let error = configuration.signUpError { + throw error + } + + // Success case + let mockUser = SignUPUser( + username: email.components(separatedBy: "@").first ?? "User", + email: email + ) + + let mockResponse = SignUpResponseModel( + accessToken: "mock_access_token_\(UUID().uuidString)", + refreshToken: "mock_refresh_token_\(UUID().uuidString)", + user: mockUser + ) + + return BaseResponseDTO( + code: 200, + message: "회원가입이 성공적으로 완료되었습니다", + data: mockResponse + ) } public func validateInviteCode( inviteCode: String ) async throws -> InviteCodeModel? { - return nil + // Track call + validateCallCount += 1 + lastCall = Date() + + // Apply delay + if configuration.delay > 0 { + try await Task.sleep(for: .seconds(configuration.delay)) + } + + // 입력 값 검증 + guard !inviteCode.isEmpty else { + throw SignUpError.missingRequiredField("초대 코드") + } + + // 특정 코드별 처리 + switch inviteCode.lowercased() { + case "invalid", "wrong", "used", "format", "unauthorized", "forbidden", "team", "revoked": + throw SignUpError.invalidInviteCode + case "expired": + throw SignUpError.expiredInviteCode + case "error": + throw SignUpError.networkError + default: + break + } + + // Configuration 기반 응답 처리 + if !configuration.shouldSucceed, let error = configuration.signUpError { + throw error + } + + // Success case + let mockResponse = InviteCodeResponseModel( + valid: true, + inviteCodeID: inviteCode, + inviteType: "TEAM_INVITE", + oneTimeUse: true, + errorMessage: nil + ) + + return BaseResponseDTO( + code: 200, + message: "유효한 초대 코드입니다", + data: mockResponse + ) + } + + public func checkEmail( + email: String + ) async throws -> CheckEmailModel? { + // Track call + checkEmailCallCount += 1 + lastCall = Date() + + // Apply delay + if configuration.delay > 0 { + try await Task.sleep(for: .seconds(configuration.delay)) + } + + // 입력 값 검증 + guard !email.isEmpty else { + throw SignUpError.missingRequiredField("이메일") + } + + // 이메일 형식 검증 + if !email.contains("@") || !email.contains(".") { + throw SignUpError.invalidEmail + } + + // 특정 이메일별 처리 + let isUsed = email == "duplicate@example.com" || + email == "used@example.com" || + email == "admin@example.com" + + // Configuration 기반 응답 처리 + if !configuration.shouldSucceed, let error = configuration.signUpError { + throw error + } + + // Success case + let mockResponse = CheckEmailResponseModel(emailUsed: isUsed) + + return BaseResponseDTO( + code: 200, + message: isUsed ? "이미 사용 중인 이메일입니다" : "사용 가능한 이메일입니다", + data: mockResponse + ) + } +} + +// MARK: - Convenience Static Methods + +public extension DefaultSignUpRepositoryImpl { + + /// Creates a pre-configured instance for success scenario + static func success() -> DefaultSignUpRepositoryImpl { + return DefaultSignUpRepositoryImpl(configuration: .success) + } + + /// Creates a pre-configured instance for failure scenario + static func failure() -> DefaultSignUpRepositoryImpl { + return DefaultSignUpRepositoryImpl(configuration: .failure) + } + + /// Creates a pre-configured instance for invalid email scenario + static func invalidEmail() -> DefaultSignUpRepositoryImpl { + return DefaultSignUpRepositoryImpl(configuration: .invalidEmail) + } + + /// Creates a pre-configured instance for duplicate email scenario + static func duplicateEmail() -> DefaultSignUpRepositoryImpl { + return DefaultSignUpRepositoryImpl(configuration: .duplicateEmail) + } + + /// Creates a pre-configured instance for weak password scenario + static func weakPassword() -> DefaultSignUpRepositoryImpl { + return DefaultSignUpRepositoryImpl(configuration: .weakPassword) + } + + /// Creates a pre-configured instance for invalid invite code scenario + static func invalidInviteCode() -> DefaultSignUpRepositoryImpl { + return DefaultSignUpRepositoryImpl(configuration: .invalidInviteCode) + } + + /// Creates a pre-configured instance for expired invite code scenario + static func expiredInviteCode() -> DefaultSignUpRepositoryImpl { + return DefaultSignUpRepositoryImpl(configuration: .expiredInviteCode) + } + + /// Creates a pre-configured instance for network error scenario + static func networkError() -> DefaultSignUpRepositoryImpl { + return DefaultSignUpRepositoryImpl(configuration: .networkError) } - public func checkEmail(email: String) async throws -> CheckEmailModel? { - return nil + /// Creates a pre-configured instance with custom delay + static func withDelay(_ delay: TimeInterval) -> DefaultSignUpRepositoryImpl { + return DefaultSignUpRepositoryImpl(configuration: .customDelay(delay)) } } \ No newline at end of file diff --git a/Projects/Domain/DomainInterface/Sources/SignUp/MockSignUpRepository.swift b/Projects/Domain/DomainInterface/Sources/SignUp/MockSignUpRepository.swift new file mode 100644 index 00000000..e0ac0af6 --- /dev/null +++ b/Projects/Domain/DomainInterface/Sources/SignUp/MockSignUpRepository.swift @@ -0,0 +1,301 @@ +// +// MockSignUpRepository.swift +// DomainInterface +// +// Created by Wonji Suh on 12/30/25. +// + +import Foundation +import Model +import Entity + +public actor MockSignUpRepository: SignUpInterface { + + // MARK: - Configuration + public enum Configuration { + case success + case failure + case invalidEmail + case duplicateEmail + case weakPassword + case invalidInviteCode + case expiredInviteCode + case networkError + case serverError + case emailCheckedValid + case emailCheckedUsed + case customDelay(TimeInterval) + + var shouldSucceed: Bool { + switch self { + case .success, .emailCheckedValid, .emailCheckedUsed, .customDelay: + return true + case .failure, .invalidEmail, .duplicateEmail, .weakPassword, + .invalidInviteCode, .expiredInviteCode, + .networkError, .serverError: + return false + } + } + + var delay: TimeInterval { + switch self { + case .customDelay(let delay): + return delay + default: + return 0.1 // Fast for testing + } + } + + var isEmailUsed: Bool { + switch self { + case .emailCheckedUsed, .duplicateEmail: + return true + default: + return false + } + } + + var signUpError: SignUpError? { + switch self { + case .success, .emailCheckedValid, .emailCheckedUsed, .customDelay: + return nil + case .failure: + return .accountCreationFailed + case .invalidEmail: + return .invalidEmail + case .duplicateEmail: + return .duplicateEmail + case .weakPassword: + return .weakPassword + case .invalidInviteCode: + return .invalidInviteCode + case .expiredInviteCode: + return .expiredInviteCode + case .networkError: + return .networkError + case .serverError: + return .serverError("Mock 서버 오류") + } + } + } + + // MARK: - State + private var configuration: Configuration = .success + private var registerCallCount = 0 + private var validateCallCount = 0 + private var checkEmailCallCount = 0 + private var lastCall: Date? + + // MARK: - Public Configuration Methods + + public init(configuration: Configuration = .success) { + self.configuration = configuration + } + + public func setConfiguration(_ configuration: Configuration) { + self.configuration = configuration + registerCallCount = 0 + validateCallCount = 0 + checkEmailCallCount = 0 + lastCall = nil + } + + public func getRegisterCallCount() -> Int { registerCallCount } + public func getValidateCallCount() -> Int { validateCallCount } + public func getCheckEmailCallCount() -> Int { checkEmailCallCount } + public func getLastCall() -> Date? { lastCall } + + public func reset() { + configuration = .success + registerCallCount = 0 + validateCallCount = 0 + checkEmailCallCount = 0 + lastCall = nil + } + + // MARK: - SignUpInterface Implementation + + public func registerAccount( + email: String, + password: String + ) async throws -> SignUpModel? { + // Track call + registerCallCount += 1 + lastCall = Date() + + // Apply delay + if configuration.delay > 0 { + try await Task.sleep(for: .seconds(configuration.delay)) + } + + // Handle failure scenarios + if !configuration.shouldSucceed, let error = configuration.signUpError { + throw error + } + + // Success case + let mockUser = SignUPUser( + username: createMockUsername(from: email), + email: email + ) + + let mockResponse = SignUpResponseModel( + accessToken: createMockAccessToken(), + refreshToken: createMockRefreshToken(), + user: mockUser + ) + + return BaseResponseDTO( + code: 200, + message: "Mock 회원가입 성공", + data: mockResponse + ) + } + + public func validateInviteCode( + inviteCode: String + ) async throws -> InviteCodeModel? { + // Track call + validateCallCount += 1 + lastCall = Date() + + // Apply delay + if configuration.delay > 0 { + try await Task.sleep(for: .seconds(configuration.delay)) + } + + // Handle failure scenarios + if !configuration.shouldSucceed, let error = configuration.signUpError { + throw error + } + + // Success case + let mockResponse = InviteCodeResponseModel( + valid: true, + inviteCodeID: inviteCode, + inviteType: createMockInviteType(), + oneTimeUse: true, + errorMessage: nil + ) + + return BaseResponseDTO( + code: 200, + message: "Mock 초대 코드 검증 성공", + data: mockResponse + ) + } + + public func checkEmail( + email: String + ) async throws -> CheckEmailModel? { + // Track call + checkEmailCallCount += 1 + lastCall = Date() + + // Apply delay + if configuration.delay > 0 { + try await Task.sleep(for: .seconds(configuration.delay)) + } + + // Handle failure scenarios + if !configuration.shouldSucceed, let error = configuration.signUpError { + throw error + } + + // Success case + let mockResponse = CheckEmailResponseModel( + emailUsed: configuration.isEmailUsed + ) + + return BaseResponseDTO( + code: 200, + message: configuration.isEmailUsed ? "Mock 이메일 이미 사용됨" : "Mock 이메일 사용 가능", + data: mockResponse + ) + } + + // MARK: - Private Helper Methods + + private func createMockUsername(from email: String) -> String { + let prefix = email.components(separatedBy: "@").first ?? "MockUser" + return "\(prefix)_\(UUID().uuidString.prefix(4))" + } + + private func createMockAccessToken() -> String { + return "mock_access_\(UUID().uuidString.prefix(16))" + } + + private func createMockRefreshToken() -> String { + return "mock_refresh_\(UUID().uuidString.prefix(16))" + } + + private func createMockInviteType() -> String { + let types = ["TEAM_INVITE", "PROJECT_INVITE", "ORGANIZATION_INVITE"] + return types.randomElement() ?? "TEAM_INVITE" + } +} + +// MARK: - Convenience Static Methods + +public extension MockSignUpRepository { + + /// Creates a pre-configured actor for success scenario + static func success() -> MockSignUpRepository { + return MockSignUpRepository(configuration: .success) + } + + /// Creates a pre-configured actor for failure scenario + static func failure() -> MockSignUpRepository { + return MockSignUpRepository(configuration: .failure) + } + + /// Creates a pre-configured actor for invalid email scenario + static func invalidEmail() -> MockSignUpRepository { + return MockSignUpRepository(configuration: .invalidEmail) + } + + /// Creates a pre-configured actor for duplicate email scenario + static func duplicateEmail() -> MockSignUpRepository { + return MockSignUpRepository(configuration: .duplicateEmail) + } + + /// Creates a pre-configured actor for weak password scenario + static func weakPassword() -> MockSignUpRepository { + return MockSignUpRepository(configuration: .weakPassword) + } + + /// Creates a pre-configured actor for invalid invite code scenario + static func invalidInviteCode() -> MockSignUpRepository { + return MockSignUpRepository(configuration: .invalidInviteCode) + } + + /// Creates a pre-configured actor for expired invite code scenario + static func expiredInviteCode() -> MockSignUpRepository { + return MockSignUpRepository(configuration: .expiredInviteCode) + } + + /// Creates a pre-configured actor for email check with valid result + static func emailValid() -> MockSignUpRepository { + return MockSignUpRepository(configuration: .emailCheckedValid) + } + + /// Creates a pre-configured actor for email check with used result + static func emailUsed() -> MockSignUpRepository { + return MockSignUpRepository(configuration: .emailCheckedUsed) + } + + /// Creates a pre-configured actor for network error scenario + static func networkError() -> MockSignUpRepository { + return MockSignUpRepository(configuration: .networkError) + } + + /// Creates a pre-configured actor for server error scenario + static func serverError() -> MockSignUpRepository { + return MockSignUpRepository(configuration: .serverError) + } + + /// Creates a pre-configured actor with custom delay + static func withDelay(_ delay: TimeInterval) -> MockSignUpRepository { + return MockSignUpRepository(configuration: .customDelay(delay)) + } +} \ No newline at end of file diff --git a/Projects/Domain/DomainInterface/Sources/SignUp/SignUpInterface.swift b/Projects/Domain/DomainInterface/Sources/SignUp/SignUpInterface.swift index 131935c5..58ad2950 100644 --- a/Projects/Domain/DomainInterface/Sources/SignUp/SignUpInterface.swift +++ b/Projects/Domain/DomainInterface/Sources/SignUp/SignUpInterface.swift @@ -25,10 +25,12 @@ public struct SignUpRepositoryDependency: DependencyKey { } public static var testValue: SignUpInterface { - UnifiedDI.resolve(SignUpInterface.self) ?? DefaultSignUpRepositoryImpl() + DefaultSignUpRepositoryImpl.success() } - public static var previewValue: SignUpInterface = liveValue + public static var previewValue: SignUpInterface { + DefaultSignUpRepositoryImpl.success() + } } /// DependencyValues extension으로 간편한 접근 제공 diff --git a/Projects/Domain/Entity/Sources/Error/SignUpError.swift b/Projects/Domain/Entity/Sources/Error/SignUpError.swift new file mode 100644 index 00000000..01725709 --- /dev/null +++ b/Projects/Domain/Entity/Sources/Error/SignUpError.swift @@ -0,0 +1,216 @@ +// +// SignUpError.swift +// Entity +// +// Created by Wonji Suh on 12/30/25. +// + +import Foundation + +public enum SignUpError: Error, LocalizedError, Equatable { + // MARK: - Email Related Errors + case invalidEmail + case duplicateEmail + case emailNotVerified + case emailBlocked + + // MARK: - Password Related Errors + case weakPassword + case passwordMismatch + case passwordTooShort + case passwordTooLong + case passwordMissingRequirements + + // MARK: - Invite Code Related Errors + case invalidInviteCode + case expiredInviteCode + + // MARK: - Account Related Errors + case accountAlreadyExists + case accountCreationFailed + case accountSuspended + case accountNotActivated + + // MARK: - Validation Errors + case invalidName + case nameTooShort + case nameTooLong + case invalidPhoneNumber + case phoneNumberAlreadyExists + + // MARK: - Network & Server Errors + case networkError + case serverError(String) + case timeout + case serviceUnavailable + + // MARK: - General Errors + case unknownError(String) + case userCancelled + case missingRequiredField(String) + + public var errorDescription: String? { + switch self { + // Email Related Errors + case .invalidEmail: + return "유효하지 않은 이메일 형식입니다" + case .duplicateEmail: + return "이미 사용 중인 이메일입니다" + case .emailNotVerified: + return "이메일 인증이 필요합니다" + case .emailBlocked: + return "차단된 이메일입니다" + + // Password Related Errors + case .weakPassword: + return "더 강력한 비밀번호를 설정해주세요" + case .passwordMismatch: + return "비밀번호가 일치하지 않습니다" + case .passwordTooShort: + return "비밀번호는 최소 8자 이상이어야 합니다" + case .passwordTooLong: + return "비밀번호가 너무 깁니다" + case .passwordMissingRequirements: + return "비밀번호는 영문, 숫자, 특수문자를 포함해야 합니다" + + // Invite Code Related Errors + case .invalidInviteCode: + return "초대 코드가 잘못 되었습니다" + case .expiredInviteCode: + return "만료된 초대 코드입니다" + + // Account Related Errors + case .accountAlreadyExists: + return "이미 존재하는 계정입니다" + case .accountCreationFailed: + return "계정 생성에 실패했습니다" + case .accountSuspended: + return "정지된 계정입니다" + case .accountNotActivated: + return "활성화되지 않은 계정입니다" + + // Validation Errors + case .invalidName: + return "유효하지 않은 이름입니다" + case .nameTooShort: + return "이름이 너무 짧습니다" + case .nameTooLong: + return "이름이 너무 깁니다" + case .invalidPhoneNumber: + return "유효하지 않은 전화번호입니다" + case .phoneNumberAlreadyExists: + return "이미 등록된 전화번호입니다" + + // Network & Server Errors + case .networkError: + return "네트워크 연결을 확인해주세요" + case .serverError(let message): + return "서버 오류: \(message)" + case .timeout: + return "요청 시간이 초과되었습니다" + case .serviceUnavailable: + return "서비스를 일시적으로 이용할 수 없습니다" + + // General Errors + case .unknownError(let message): + return "알 수 없는 오류가 발생했습니다: \(message)" + case .userCancelled: + return "사용자가 취소했습니다" + case .missingRequiredField(let field): + return "\(field)은(는) 필수 입력 항목입니다" + } + } + + public var failureReason: String? { + switch self { + case .invalidEmail: + return "이메일 형식 검증 실패" + case .duplicateEmail: + return "이메일 중복 검사 실패" + case .weakPassword: + return "비밀번호 강도 검증 실패" + case .invalidInviteCode: + return "초대 코드 검증 실패" + case .networkError: + return "네트워크 연결 실패" + case .serverError: + return "서버 처리 실패" + default: + return nil + } + } + + public var recoverySuggestion: String? { + switch self { + case .invalidEmail: + return "올바른 이메일 주소를 입력해주세요 (예: user@example.com)" + case .duplicateEmail: + return "다른 이메일 주소를 사용하거나 로그인을 시도해보세요" + case .weakPassword: + return "영문, 숫자, 특수문자를 조합하여 8자 이상 입력해주세요" + case .invalidInviteCode: + return "초대 코드를 다시 확인하거나 관리자에게 문의해주세요" + case .networkError: + return "인터넷 연결을 확인하고 다시 시도해주세요" + case .timeout: + return "잠시 후 다시 시도해주세요" + default: + return "문제가 지속되면 고객센터에 문의해주세요" + } + } +} + +// MARK: - Convenience Methods + +public extension SignUpError { + + /// 이메일 관련 에러인지 확인 + var isEmailError: Bool { + switch self { + case .invalidEmail, .duplicateEmail, .emailNotVerified, .emailBlocked: + return true + default: + return false + } + } + + /// 비밀번호 관련 에러인지 확인 + var isPasswordError: Bool { + switch self { + case .weakPassword, .passwordMismatch, .passwordTooShort, .passwordTooLong, .passwordMissingRequirements: + return true + default: + return false + } + } + + /// 초대 코드 관련 에러인지 확인 + var isInviteCodeError: Bool { + switch self { + case .invalidInviteCode, .expiredInviteCode: + return true + default: + return false + } + } + + /// 네트워크 관련 에러인지 확인 + var isNetworkError: Bool { + switch self { + case .networkError, .timeout, .serviceUnavailable: + return true + default: + return false + } + } + + /// 재시도 가능한 에러인지 확인 + var isRetryable: Bool { + switch self { + case .networkError, .timeout, .serviceUnavailable, .serverError: + return true + default: + return false + } + } +} \ No newline at end of file diff --git a/Projects/Domain/Entity/Sources/OnBoarding/Staff.swift b/Projects/Domain/Entity/Sources/OnBoarding/Staff.swift new file mode 100644 index 00000000..62429a2c --- /dev/null +++ b/Projects/Domain/Entity/Sources/OnBoarding/Staff.swift @@ -0,0 +1,22 @@ +// +// Staff.swift +// Entity +// +// Created by Wonji Suh on 12/30/25. +// + +import Foundation + +public enum Staff: String, CaseIterable , Equatable{ + case member + case manger + + public var description: String { + switch self { + case .member: + return "MEMBER" + case .manger: + return "MANAGER" + } + } +} diff --git a/Projects/Domain/Entity/Sources/OnBoarding/VerifyCodeEntity.swift b/Projects/Domain/Entity/Sources/OnBoarding/VerifyCodeEntity.swift new file mode 100644 index 00000000..12a20215 --- /dev/null +++ b/Projects/Domain/Entity/Sources/OnBoarding/VerifyCodeEntity.swift @@ -0,0 +1,21 @@ +// +// VerifyCodeEntity.swift +// Entity +// +// Created by Wonji Suh on 12/30/25. +// + +import Foundation + +public struct VerifyCodeEntity: Equatable { + public let generationID: Int + public let type: Staff + + public init( + generationID: Int, + type: Staff + ) { + self.generationID = generationID + self.type = type + } +} diff --git a/Projects/Domain/UseCase/Sources/Auth/AuthUseCaseImpl.swift b/Projects/Domain/UseCase/Sources/Auth/AuthUseCaseImpl.swift index e7284045..166ee855 100644 --- a/Projects/Domain/UseCase/Sources/Auth/AuthUseCaseImpl.swift +++ b/Projects/Domain/UseCase/Sources/Auth/AuthUseCaseImpl.swift @@ -6,7 +6,6 @@ // import DomainInterface -import Model import Entity import WeaveDI @@ -26,13 +25,13 @@ public struct AuthUseCaseImpl: AuthInterface { } extension AuthUseCaseImpl: DependencyKey { - static public var liveValue: AuthInterface = AuthUseCaseImpl() - static public var testValue: AuthInterface = AuthUseCaseImpl() - static public var previewValue: AuthInterface = liveValue + static public var liveValue = AuthUseCaseImpl() + static public var testValue = AuthUseCaseImpl() + static public var previewValue = AuthUseCaseImpl() } public extension DependencyValues { - var authUseCase: AuthInterface { + var authUseCase: AuthUseCaseImpl { get { self[AuthUseCaseImpl.self] } set { self[AuthUseCaseImpl.self] = newValue } } diff --git a/Projects/Domain/UseCase/Sources/OnBoarding/OnBoardingUseCaseImpl.swift b/Projects/Domain/UseCase/Sources/OnBoarding/OnBoardingUseCaseImpl.swift new file mode 100644 index 00000000..ccf0eb67 --- /dev/null +++ b/Projects/Domain/UseCase/Sources/OnBoarding/OnBoardingUseCaseImpl.swift @@ -0,0 +1,38 @@ +// +// OnBoardingUseCaseImpl.swift +// UseCase +// +// Created by Wonji Suh on 12/30/25. +// + +import DomainInterface +import Entity + +import WeaveDI + +public struct OnBoardingUseCaseImpl: OnBoardingInterface { + @Dependency(\.onBoardingRepository) var repository + + public init() {} + + + public func verifyCode( + code: String + ) async throws -> Entity.VerifyCodeEntity { + return try await repository.verifyCode(code: code) + } +} + + +extension OnBoardingUseCaseImpl : DependencyKey { + static public var liveValue = OnBoardingUseCaseImpl() + static public var testValue = OnBoardingUseCaseImpl() + static public var previewValue = OnBoardingUseCaseImpl() +} + +public extension DependencyValues { + var onBoardingUseCase: OnBoardingUseCaseImpl { + get { self[OnBoardingUseCaseImpl.self] } + set { self[OnBoardingUseCaseImpl.self] = newValue } + } +} From 6dd445251a5dd958b49c1a1c5709829cad6b918f Mon Sep 17 00:00:00 2001 From: Roy Date: Tue, 30 Dec 2025 11:04:48 +0900 Subject: [PATCH 03/26] =?UTF-8?q?=F0=9F=94=A7[refactor]:=20=EC=B4=88?= =?UTF-8?q?=EB=8C=80=EC=BD=94=EB=93=9C=20=EA=B2=80=EC=A6=9D=20=EB=A1=9C?= =?UTF-8?q?=EC=A7=81=20=EA=B0=9C=EC=84=A0=20=EB=B0=8F=20=EC=98=A4=ED=83=80?= =?UTF-8?q?=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Projects/App/Sources/Di/DiRegister.swift | 3 +- .../Reducer/SignUpInviteCode.swift | 109 +++++++++++------- .../View/SignUpInviteCodeView.swift | 21 ++-- 3 files changed, 83 insertions(+), 50 deletions(-) diff --git a/Projects/App/Sources/Di/DiRegister.swift b/Projects/App/Sources/Di/DiRegister.swift index b1b56eac..e7a3b073 100644 --- a/Projects/App/Sources/Di/DiRegister.swift +++ b/Projects/App/Sources/Di/DiRegister.swift @@ -32,7 +32,8 @@ public class AppDIManager: @unchecked Sendable { .register { AppleOAuthRepositoryImpl() as AppleOAuthInterface } .register { AppleOAuthProvider() as AppleOAuthProviderInterface } .register { GoogleOAuthProvider() as GoogleOAuthProviderInterface } - + // MARK: - 온보딩 관련 + .register { OnBoardingRepositoryImpl() as OnBoardingInterface } .register { SignUpRepositoryImpl() as SignUpInterface } .register { AttendanceRepositoryImpl() as AttendanceInterface } .register { ProfileRepositoryImpl() as ProfileInterface } diff --git a/Projects/Presentation/Auth/Sources/SignUpInviteCode/Reducer/SignUpInviteCode.swift b/Projects/Presentation/Auth/Sources/SignUpInviteCode/Reducer/SignUpInviteCode.swift index 152afd33..04cad40d 100644 --- a/Projects/Presentation/Auth/Sources/SignUpInviteCode/Reducer/SignUpInviteCode.swift +++ b/Projects/Presentation/Auth/Sources/SignUpInviteCode/Reducer/SignUpInviteCode.swift @@ -9,6 +9,8 @@ import Foundation import Core import Utill +import Entity +import Model import AsyncMoya import ComposableArchitecture @@ -24,20 +26,22 @@ public struct SignUpInviteCode { var secondInviteCode: String = "" var thirdInviteCode: String = "" var lastInviteCode: String = "" - var validateInviteCodeDTOModel: InviteCodeModel? + var verifyInviteCodeModel: VerifyCodeEntity? + @Shared(.inMemory("UserEntity")) var userEntity: UserEntity = .shared - + @Presents public var alert: AlertState? + var totalInviteCode: String { return firstInviteCode + secondInviteCode + thirdInviteCode + lastInviteCode } var enableButton: Bool { - return !isNotAvaliableCode && + return !isNotAvailableCode && !firstInviteCode.isEmpty && !secondInviteCode.isEmpty && !thirdInviteCode.isEmpty && !lastInviteCode.isEmpty } - var isNotAvaliableCode: Bool = false + var isNotAvailableCode: Bool = false @Shared var userSignUp: Member @@ -47,12 +51,14 @@ public struct SignUpInviteCode { self._userSignUp = Shared(wrappedValue: userSignUp, .inMemory("Member")) } } - + + @CasePathable public enum Action: ViewAction, BindableAction, FeatureAction { case binding(BindingAction) case view(View) case async(AsyncAction) case inner(InnerAction) + case scope(ScopeAction) case navigation(NavigationAction) } @@ -64,26 +70,41 @@ public struct SignUpInviteCode { } // MARK: - AsyncAction 비동기 처리 액션 - + @CasePathable public enum AsyncAction: Equatable { - case validataInviteCode(code: String) + case verifyInviteCode(code: String) } // MARK: - 앱내에서 사용하는 액션 - + @CasePathable public enum InnerAction: Equatable { - case validataInviteCodeResponse(Result) + case verifyInviteCodeResponse(Result) } // MARK: - NavigationAction - + @CasePathable public enum NavigationAction: Equatable { case presentSignUpName } + + @CasePathable + public enum ScopeAction { + case alert(PresentationAction) + } + + @CasePathable + public enum AlertAction { + case confirmTapped + } + + nonisolated enum CancelID: Hashable { + case verifyCode + } + + - struct SignUpInviteCodeCancel: Hashable {} - - @Dependency(\.signUpUseCase) var signUpUseCase + + @Dependency(\.onBoardingUseCase) var onBoardingUseCase @Dependency(\.continuousClock) var clock @Dependency(\.mainQueue) var mainQueue @@ -102,11 +123,15 @@ public struct SignUpInviteCode { case .inner(let innerAction): return handleInnerAction(state: &state, action: innerAction) - + + case .scope: + return .none + case .navigation(let navigationAction): return handleNavigationAction(state: &state, action: navigationAction) } } + .ifLet(\.$alert, action: \.scope.alert) } private func handleViewAction( @@ -128,27 +153,22 @@ public struct SignUpInviteCode { action: AsyncAction ) -> Effect { switch action { - case .validataInviteCode(let code): + case .verifyInviteCode(let code): return .run { send in - let validataCodeResult = await Result { - try await signUpUseCase.validateInviteCode(inviteCode: code) + let verifyCodeResult = await Result { + try await onBoardingUseCase.verifyCode(code: code) } - - switch validataCodeResult { - case .success(let validataCodeData): - if let validataCodeData = validataCodeData { - await send(.inner(.validataInviteCodeResponse(.success(validataCodeData)))) - - if validataCodeData.data.valid == true { - await send(.navigation(.presentSignUpName)) - } + .mapError { error -> SignUpError in + if let authError = error as? SignUpError { + return authError + } else { + return .unknownError(error.localizedDescription) + } } - case .failure(let error): - await send(.inner(.validataInviteCodeResponse(.failure(CustomError.firestoreError(error.localizedDescription))))) - } - } - .debounce(id: SignUpInviteCodeCancel(), for: 0.3, scheduler: mainQueue) + return await send(.inner(.verifyInviteCodeResponse(verifyCodeResult))) + } + .cancellable(id: CancelID.verifyCode, cancelInFlight: true) } } @@ -167,17 +187,28 @@ public struct SignUpInviteCode { action: InnerAction ) -> Effect { switch action { - case .validataInviteCodeResponse(let result): + case .verifyInviteCodeResponse(let result): switch result { - case .success(let validateCodeData): - state.validateInviteCodeDTOModel = validateCodeData - state.$userEntity.withLock{ $0.userRole = UserRole(rawValue: state.validateInviteCodeDTOModel?.data.inviteType ?? "") - $0.inviteCodeId = state.validateInviteCodeDTOModel?.data.inviteCodeID ?? "" - } + case .success(let data): + state.verifyInviteCodeModel = data + //TODO: 차후에 수정 예정 +// state.$userEntity.withLock{ $0.userRole = UserRole(rawValue: state.validateInviteCodeDTOModel?.data.inviteType ?? "") +// $0.inviteCodeId = state.validateInviteCodeDTOModel?.data.inviteCodeID ?? "" +// } + return .send(.navigation(.presentSignUpName)) case .failure(let error): - #logError("코드에러", error.localizedDescription) - state.isNotAvaliableCode.toggle() + #logError("코드에러", error) + state.isNotAvailableCode.toggle() + state.alert = AlertState { + TextState("오류") + } actions: { + ButtonState(action: .confirmTapped) { + TextState("확인") + } + } message: { + TextState("잘못된 초대 코드입니다. 다시 입력해 주세요.\n\(SignUpError.invalidInviteCode.errorDescription ?? "")") + } } return .none } diff --git a/Projects/Presentation/Auth/Sources/SignUpInviteCode/View/SignUpInviteCodeView.swift b/Projects/Presentation/Auth/Sources/SignUpInviteCode/View/SignUpInviteCodeView.swift index ea0997bb..f109f396 100644 --- a/Projects/Presentation/Auth/Sources/SignUpInviteCode/View/SignUpInviteCodeView.swift +++ b/Projects/Presentation/Auth/Sources/SignUpInviteCode/View/SignUpInviteCodeView.swift @@ -64,6 +64,7 @@ public struct SignUpInviteCodeView : View { .onAppear { store.send(.view(.initInviteCode)) } + .alert($store.scope(state: \.alert, action: \.scope.alert)) } } @@ -90,7 +91,7 @@ extension SignUpInviteCodeView { CustomButton( action: { - store.send(.async(.validataInviteCode(code: store.totalInviteCode))) + store.send(.async(.verifyInviteCode(code: store.totalInviteCode))) }, title: "다음", config: CustomButtonConfig.create(), @@ -112,11 +113,11 @@ extension SignUpInviteCodeView { inputCodeText( text: $store.firstInviteCode, - isErrorCode: store.isNotAvaliableCode, + isErrorCode: store.isNotAvailableCode, isFocs: $firstInviteCodeFocus) { moveBack in if moveBack { firstInviteCodeFocus = true - store.isNotAvaliableCode = false + store.isNotAvailableCode = false } else { secodInviteCodeFocus = true } @@ -124,11 +125,11 @@ extension SignUpInviteCodeView { inputCodeText( text: $store.secondInviteCode, - isErrorCode: store.isNotAvaliableCode, + isErrorCode: store.isNotAvailableCode, isFocs: $secodInviteCodeFocus) { moveBack in if moveBack { firstInviteCodeFocus = true - store.isNotAvaliableCode = false + store.isNotAvailableCode = false } else { thirdlnviteCodeFocus = true } @@ -136,11 +137,11 @@ extension SignUpInviteCodeView { inputCodeText( text: $store.thirdInviteCode, - isErrorCode: store.isNotAvaliableCode, + isErrorCode: store.isNotAvailableCode, isFocs: $thirdlnviteCodeFocus) { moveBack in if moveBack { secodInviteCodeFocus = true - store.isNotAvaliableCode = false + store.isNotAvailableCode = false } else { lastlnviteCodeFocus = true } @@ -148,11 +149,11 @@ extension SignUpInviteCodeView { inputCodeText( text: $store.lastInviteCode, - isErrorCode: store.isNotAvaliableCode, + isErrorCode: store.isNotAvailableCode, isFocs: $lastlnviteCodeFocus) { moveBack in if moveBack { thirdlnviteCodeFocus = true - store.isNotAvaliableCode = false + store.isNotAvailableCode = false } } @@ -209,7 +210,7 @@ extension SignUpInviteCodeView { @ViewBuilder private func isNotValidateCodeErrorText() -> some View { - if store.isNotAvaliableCode { + if store.isNotAvailableCode { VStack { Spacer() .frame(height: 16) From d00c745ac0ac7d78bc178f4ae651cc709e5af9a4 Mon Sep 17 00:00:00 2001 From: Roy Date: Tue, 30 Dec 2025 14:37:53 +0900 Subject: [PATCH 04/26] =?UTF-8?q?=E2=9C=A8[feat]:=20=EC=A7=81=EC=97=85=20?= =?UTF-8?q?=EC=84=A0=ED=83=9D=20API=20=EC=97=B0=EB=8F=99=20=EB=B0=8F=20DTO?= =?UTF-8?q?=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../OnBoarding/DTO/SelectJobsDTO.swift | 17 +++++++++++ .../OnBoarding/Mapper/SelectJobsDTO+.swift | 30 +++++++++++++++++++ .../OnBoarding/OnBoardingRepositoryImpl.swift | 7 +++++ .../OnBoarding/OnBoardingService.swift | 9 +++++- 4 files changed, 62 insertions(+), 1 deletion(-) create mode 100644 Projects/Data/Model/Sources/OnBoarding/DTO/SelectJobsDTO.swift create mode 100644 Projects/Data/Model/Sources/OnBoarding/Mapper/SelectJobsDTO+.swift diff --git a/Projects/Data/Model/Sources/OnBoarding/DTO/SelectJobsDTO.swift b/Projects/Data/Model/Sources/OnBoarding/DTO/SelectJobsDTO.swift new file mode 100644 index 00000000..61793a07 --- /dev/null +++ b/Projects/Data/Model/Sources/OnBoarding/DTO/SelectJobsDTO.swift @@ -0,0 +1,17 @@ +// +// SelectJobsDTO.swift +// Model +// +// Created by Wonji Suh on 12/30/25. +// + +//public typealias SelectJobsDTO = [SelectJobsDTOResponse] + +public struct SelectJobsDTO: Decodable { + public let data: [SelectJobsDTOResponse] +} + + +public struct SelectJobsDTOResponse: Decodable { + let key, description: String +} diff --git a/Projects/Data/Model/Sources/OnBoarding/Mapper/SelectJobsDTO+.swift b/Projects/Data/Model/Sources/OnBoarding/Mapper/SelectJobsDTO+.swift new file mode 100644 index 00000000..8679bc8b --- /dev/null +++ b/Projects/Data/Model/Sources/OnBoarding/Mapper/SelectJobsDTO+.swift @@ -0,0 +1,30 @@ +// +// SelectJobsDTO+.swift +// Model +// +// Created by Wonji Suh on 12/30/25. +// + +import Foundation +import Entity + +public extension SelectJobsDTOResponse { + func toDomain() -> SelectJob { + return SelectJob( + jobKeys: self.key, + job: SelectParts.from(apiKey: self.key) ?? .all + ) + } +} + +public extension Array where Element == SelectJobsDTOResponse { + func toDomain() -> [SelectJob] { + return self.map { $0.toDomain() } + } +} + +public extension SelectJobsDTO { + func toDomain() -> [SelectJob] { + return self.data.toDomain() + } +} diff --git a/Projects/Data/Repository/Sources/OnBoarding/OnBoardingRepositoryImpl.swift b/Projects/Data/Repository/Sources/OnBoarding/OnBoardingRepositoryImpl.swift index 3c961db7..8e8a04e3 100644 --- a/Projects/Data/Repository/Sources/OnBoarding/OnBoardingRepositoryImpl.swift +++ b/Projects/Data/Repository/Sources/OnBoarding/OnBoardingRepositoryImpl.swift @@ -14,6 +14,7 @@ import Entity @preconcurrency import AsyncMoya final public class OnBoardingRepositoryImpl:OnBoardingInterface { + private let provider: MoyaProvider public init( @@ -28,4 +29,10 @@ final public class OnBoardingRepositoryImpl:OnBoardingInterface { let dto: VerifyCodeDTO = try await provider.request(.verifyCode(code: code)) return dto.toDomain() } + + public func fetchJobs() async throws -> [Entity.SelectJob] { + let dtoArray: [SelectJobsDTOResponse] = try await provider.request(.jobs) + return dtoArray.toDomain() + } + } diff --git a/Projects/Data/Service/Sources/OnBoarding/OnBoardingService.swift b/Projects/Data/Service/Sources/OnBoarding/OnBoardingService.swift index c9c047f7..b54a6082 100644 --- a/Projects/Data/Service/Sources/OnBoarding/OnBoardingService.swift +++ b/Projects/Data/Service/Sources/OnBoarding/OnBoardingService.swift @@ -13,6 +13,7 @@ import AsyncMoya public enum OnBoardingService { case verifyCode(code : String) + case jobs } @@ -27,6 +28,9 @@ extension OnBoardingService: BaseTargetType { switch self { case .verifyCode: return OnBoardingAPI.verifyCode.description + + case .jobs: + return OnBoardingAPI.jobs.description } } @@ -38,12 +42,15 @@ extension OnBoardingService: BaseTargetType { switch self { case .verifyCode(let code): return code.toDictionary(key: "code") + + case .jobs: + return nil } } public var method: Moya.Method { switch self { - case .verifyCode: + case .verifyCode, .jobs: return .get } } From 2ae68639caf36debb32f47acd4d359217a5324ed Mon Sep 17 00:00:00 2001 From: Roy Date: Tue, 30 Dec 2025 14:43:00 +0900 Subject: [PATCH 05/26] =?UTF-8?q?=F0=9F=94=A7[refactor]:=20=EC=98=A8?= =?UTF-8?q?=EB=B3=B4=EB=94=A9=20=EB=B0=8F=20=ED=9A=8C=EC=9B=90=EA=B0=80?= =?UTF-8?q?=EC=9E=85=20Repository=20Mock=20=EC=8B=9C=EC=8A=A4=ED=85=9C=20?= =?UTF-8?q?=ED=86=B5=ED=95=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../DefaultOnBoardingRepositoryImpl.swift | 72 ++++++++ .../OnBoarding/MockOnBoardingRepository.swift | 168 ------------------ .../OnBoarding/OnBoardingInterface.swift | 5 +- .../SignUp/DefaultSignUpRepositoryImpl.swift | 39 +--- .../Sources/SignUp/MockSignUpRepository.swift | 31 +--- .../Entity/Sources/Error/SignUpError.swift | 119 ++++--------- .../Entity/Sources/OnBoarding/SelectJob.swift | 21 +++ .../Sources/OnBoarding/SelectPart.swift | 61 +++++++ .../OnBoarding/OnBoardingUseCaseImpl.swift | 5 + 9 files changed, 202 insertions(+), 319 deletions(-) delete mode 100644 Projects/Domain/DomainInterface/Sources/OnBoarding/MockOnBoardingRepository.swift create mode 100644 Projects/Domain/Entity/Sources/OnBoarding/SelectJob.swift create mode 100644 Projects/Domain/Entity/Sources/OnBoarding/SelectPart.swift diff --git a/Projects/Domain/DomainInterface/Sources/OnBoarding/DefaultOnBoardingRepositoryImpl.swift b/Projects/Domain/DomainInterface/Sources/OnBoarding/DefaultOnBoardingRepositoryImpl.swift index c4996f8e..4020cebf 100644 --- a/Projects/Domain/DomainInterface/Sources/OnBoarding/DefaultOnBoardingRepositoryImpl.swift +++ b/Projects/Domain/DomainInterface/Sources/OnBoarding/DefaultOnBoardingRepositoryImpl.swift @@ -31,6 +31,7 @@ public enum OnBoardingError: Error, LocalizedError { } public final class DefaultOnBoardingRepositoryImpl: OnBoardingInterface, @unchecked Sendable { + // MARK: - Configuration public enum Configuration { @@ -96,6 +97,8 @@ public final class DefaultOnBoardingRepositoryImpl: OnBoardingInterface, @unchec private var configuration: Configuration = .success private var verifyCallCount = 0 private var lastVerifyCall: Date? + private var fetchJobsCallCount = 0 + private var lastFetchJobsCall: Date? // MARK: - Initialization @@ -109,6 +112,8 @@ public final class DefaultOnBoardingRepositoryImpl: OnBoardingInterface, @unchec self.configuration = configuration verifyCallCount = 0 lastVerifyCall = nil + fetchJobsCallCount = 0 + lastFetchJobsCall = nil } public func getVerifyCallCount() -> Int { @@ -119,10 +124,20 @@ public final class DefaultOnBoardingRepositoryImpl: OnBoardingInterface, @unchec return lastVerifyCall } + public func getFetchJobsCallCount() -> Int { + return fetchJobsCallCount + } + + public func getLastFetchJobsCall() -> Date? { + return lastFetchJobsCall + } + public func reset() { configuration = .success verifyCallCount = 0 lastVerifyCall = nil + fetchJobsCallCount = 0 + lastFetchJobsCall = nil } // MARK: - OnBoardingInterface Implementation @@ -178,6 +193,63 @@ public final class DefaultOnBoardingRepositoryImpl: OnBoardingInterface, @unchec ) } } + + public func fetchJobs() async throws -> [Entity.SelectJob] { + // Track call + fetchJobsCallCount += 1 + lastFetchJobsCall = Date() + + // Apply delay + if configuration.delay > 0 { + try await Task.sleep(for: .seconds(configuration.delay)) + } + + // Configuration 기반 응답 처리 + if !configuration.shouldSucceed, let error = configuration.error { + throw error + } + + // Mock jobs data - 다양한 직무를 순환하면서 반환 + let mockJobs: [Entity.SelectJob] = [ + Entity.SelectJob( + jobKeys: "BACKEND", + job: .backend + ), + Entity.SelectJob( + jobKeys: "FRONTEND", + job: .frontend + ), + Entity.SelectJob( + jobKeys: "DESIGNER", + job: .designer + ), + Entity.SelectJob( + jobKeys: "PM", + job: .pm + ), + Entity.SelectJob( + jobKeys: "ANDROID", + job: .android + ), + Entity.SelectJob( + jobKeys: "IOS", + job: .ios + ) + ] + + // Configuration에 따른 응답 + switch configuration { + case .memberRole: + // 일반 멤버는 개발 직무들만 + return mockJobs.filter { $0.job != .pm } + case .managerRole: + // 매니저는 PM 직무만 + return [Entity.SelectJob(jobKeys: "PM", job: .pm)] + default: + // 기본적으로 모든 직무 반환 + return mockJobs + } + } } // MARK: - Convenience Static Methods diff --git a/Projects/Domain/DomainInterface/Sources/OnBoarding/MockOnBoardingRepository.swift b/Projects/Domain/DomainInterface/Sources/OnBoarding/MockOnBoardingRepository.swift deleted file mode 100644 index 7f826df9..00000000 --- a/Projects/Domain/DomainInterface/Sources/OnBoarding/MockOnBoardingRepository.swift +++ /dev/null @@ -1,168 +0,0 @@ -// -// MockOnBoardingRepository.swift -// DomainInterface -// -// Created by Wonji Suh on 12/30/25. -// - -import Foundation -import Entity - -public actor MockOnBoardingRepository: OnBoardingInterface { - - // MARK: - Configuration - public enum Configuration { - case success - case failure - case invalidCode - case networkError - case memberRole - case managerRole - case customDelay(TimeInterval) - - var shouldSucceed: Bool { - switch self { - case .success, .memberRole, .managerRole, .customDelay: - return true - case .failure, .invalidCode, .networkError: - return false - } - } - - var delay: TimeInterval { - switch self { - case .customDelay(let delay): - return delay - default: - return 0.1 - } - } - - var staffType: Staff { - switch self { - case .managerRole: - return .manger - default: - return .member - } - } - - var mockGenerationID: Int { - switch self { - case .managerRole: - return 2024 - default: - return 2025 - } - } - - var error: OnBoardingError? { - switch self { - case .success, .memberRole, .managerRole, .customDelay: - return nil - case .failure: - return .verifyFailed - case .invalidCode: - return .invalidCode - case .networkError: - return .networkError - } - } - } - - // MARK: - State - private var configuration: Configuration = .success - private var verifyCallCount = 0 - private var lastVerifyCall: Date? - - // MARK: - Public Configuration Methods - - public init(configuration: Configuration = .success) { - self.configuration = configuration - } - - public func setConfiguration(_ configuration: Configuration) { - self.configuration = configuration - verifyCallCount = 0 - lastVerifyCall = nil - } - - public func getVerifyCallCount() -> Int { - return verifyCallCount - } - - public func getLastVerifyCall() -> Date? { - return lastVerifyCall - } - - public func reset() { - configuration = .success - verifyCallCount = 0 - lastVerifyCall = nil - } - - // MARK: - OnBoardingInterface Implementation - - public func verifyCode(code: String) async throws -> VerifyCodeEntity { - // Track call - verifyCallCount += 1 - lastVerifyCall = Date() - - // Apply delay - if configuration.delay > 0 { - try await Task.sleep(for: .seconds(configuration.delay)) - } - - // Handle failure scenarios - if !configuration.shouldSucceed, let error = configuration.error { - throw error - } - - // Return success payload - return VerifyCodeEntity( - generationID: configuration.mockGenerationID, - type: configuration.staffType - ) - } -} - -// MARK: - Convenience Static Methods - -public extension MockOnBoardingRepository { - - /// Creates a pre-configured actor for success scenario with member role - static func success() -> MockOnBoardingRepository { - return MockOnBoardingRepository(configuration: .success) - } - - /// Creates a pre-configured actor for failure scenario - static func failure() -> MockOnBoardingRepository { - return MockOnBoardingRepository(configuration: .failure) - } - - /// Creates a pre-configured actor for invalid code scenario - static func invalidCode() -> MockOnBoardingRepository { - return MockOnBoardingRepository(configuration: .invalidCode) - } - - /// Creates a pre-configured actor for member role scenario - static func memberRole() -> MockOnBoardingRepository { - return MockOnBoardingRepository(configuration: .memberRole) - } - - /// Creates a pre-configured actor for manager role scenario - static func managerRole() -> MockOnBoardingRepository { - return MockOnBoardingRepository(configuration: .managerRole) - } - - /// Creates a pre-configured actor for network error scenario - static func networkError() -> MockOnBoardingRepository { - return MockOnBoardingRepository(configuration: .networkError) - } - - /// Creates a pre-configured actor with custom delay - static func withDelay(_ delay: TimeInterval) -> MockOnBoardingRepository { - return MockOnBoardingRepository(configuration: .customDelay(delay)) - } -} - diff --git a/Projects/Domain/DomainInterface/Sources/OnBoarding/OnBoardingInterface.swift b/Projects/Domain/DomainInterface/Sources/OnBoarding/OnBoardingInterface.swift index 42a4a353..2c926b0c 100644 --- a/Projects/Domain/DomainInterface/Sources/OnBoarding/OnBoardingInterface.swift +++ b/Projects/Domain/DomainInterface/Sources/OnBoarding/OnBoardingInterface.swift @@ -13,6 +13,7 @@ import Entity public protocol OnBoardingInterface: Sendable { func verifyCode(code: String) async throws -> VerifyCodeEntity + func fetchJobs() async throws -> [SelectJob] } public struct OnBoardingRepositoryDependency: DependencyKey { @@ -24,9 +25,7 @@ public struct OnBoardingRepositoryDependency: DependencyKey { UnifiedDI.resolve(OnBoardingInterface.self) ?? DefaultOnBoardingRepositoryImpl() } - public static var previewValue: OnBoardingInterface { - MockOnBoardingRepository.success() - } + public static var previewValue: OnBoardingInterface = liveValue } public extension DependencyValues { diff --git a/Projects/Domain/DomainInterface/Sources/SignUp/DefaultSignUpRepositoryImpl.swift b/Projects/Domain/DomainInterface/Sources/SignUp/DefaultSignUpRepositoryImpl.swift index cc76cd9d..6dc631f3 100644 --- a/Projects/Domain/DomainInterface/Sources/SignUp/DefaultSignUpRepositoryImpl.swift +++ b/Projects/Domain/DomainInterface/Sources/SignUp/DefaultSignUpRepositoryImpl.swift @@ -17,9 +17,6 @@ final public class DefaultSignUpRepositoryImpl: SignUpInterface, @unchecked Send public enum Configuration { case success case failure - case invalidEmail - case duplicateEmail - case weakPassword case invalidInviteCode case expiredInviteCode case networkError @@ -30,8 +27,7 @@ final public class DefaultSignUpRepositoryImpl: SignUpInterface, @unchecked Send switch self { case .success, .customDelay: return true - case .failure, .invalidEmail, .duplicateEmail, .weakPassword, - .invalidInviteCode, .expiredInviteCode, .networkError, .serverError: + case .failure, .invalidInviteCode, .expiredInviteCode, .networkError, .serverError: return false } } @@ -51,12 +47,6 @@ final public class DefaultSignUpRepositoryImpl: SignUpInterface, @unchecked Send return nil case .failure: return .accountCreationFailed - case .invalidEmail: - return .invalidEmail - case .duplicateEmail: - return .duplicateEmail - case .weakPassword: - return .weakPassword case .invalidInviteCode: return .invalidInviteCode case .expiredInviteCode: @@ -129,22 +119,22 @@ final public class DefaultSignUpRepositoryImpl: SignUpInterface, @unchecked Send throw SignUpError.missingRequiredField("비밀번호") } - // 특정 이메일 패턴 검사 + // 특정 패턴 검사 (간소화) if email == "invalid@" || !email.contains("@") { - throw SignUpError.invalidEmail + throw SignUpError.missingRequiredField("유효한 이메일") } if email == "duplicate@example.com" { - throw SignUpError.duplicateEmail + throw SignUpError.accountAlreadyExists } // 비밀번호 검증 if password.count < 8 { - throw SignUpError.passwordTooShort + throw SignUpError.missingRequiredField("8자 이상의 비밀번호") } if password == "weak" || password == "123456" { - throw SignUpError.weakPassword + throw SignUpError.missingRequiredField("강력한 비밀번호") } // Configuration 기반 응답 처리 @@ -240,7 +230,7 @@ final public class DefaultSignUpRepositoryImpl: SignUpInterface, @unchecked Send // 이메일 형식 검증 if !email.contains("@") || !email.contains(".") { - throw SignUpError.invalidEmail + throw SignUpError.missingRequiredField("유효한 이메일") } // 특정 이메일별 처리 @@ -278,21 +268,6 @@ public extension DefaultSignUpRepositoryImpl { return DefaultSignUpRepositoryImpl(configuration: .failure) } - /// Creates a pre-configured instance for invalid email scenario - static func invalidEmail() -> DefaultSignUpRepositoryImpl { - return DefaultSignUpRepositoryImpl(configuration: .invalidEmail) - } - - /// Creates a pre-configured instance for duplicate email scenario - static func duplicateEmail() -> DefaultSignUpRepositoryImpl { - return DefaultSignUpRepositoryImpl(configuration: .duplicateEmail) - } - - /// Creates a pre-configured instance for weak password scenario - static func weakPassword() -> DefaultSignUpRepositoryImpl { - return DefaultSignUpRepositoryImpl(configuration: .weakPassword) - } - /// Creates a pre-configured instance for invalid invite code scenario static func invalidInviteCode() -> DefaultSignUpRepositoryImpl { return DefaultSignUpRepositoryImpl(configuration: .invalidInviteCode) diff --git a/Projects/Domain/DomainInterface/Sources/SignUp/MockSignUpRepository.swift b/Projects/Domain/DomainInterface/Sources/SignUp/MockSignUpRepository.swift index e0ac0af6..9f1e37cd 100644 --- a/Projects/Domain/DomainInterface/Sources/SignUp/MockSignUpRepository.swift +++ b/Projects/Domain/DomainInterface/Sources/SignUp/MockSignUpRepository.swift @@ -15,9 +15,6 @@ public actor MockSignUpRepository: SignUpInterface { public enum Configuration { case success case failure - case invalidEmail - case duplicateEmail - case weakPassword case invalidInviteCode case expiredInviteCode case networkError @@ -30,8 +27,7 @@ public actor MockSignUpRepository: SignUpInterface { switch self { case .success, .emailCheckedValid, .emailCheckedUsed, .customDelay: return true - case .failure, .invalidEmail, .duplicateEmail, .weakPassword, - .invalidInviteCode, .expiredInviteCode, + case .failure, .invalidInviteCode, .expiredInviteCode, .networkError, .serverError: return false } @@ -48,7 +44,7 @@ public actor MockSignUpRepository: SignUpInterface { var isEmailUsed: Bool { switch self { - case .emailCheckedUsed, .duplicateEmail: + case .emailCheckedUsed: return true default: return false @@ -61,12 +57,6 @@ public actor MockSignUpRepository: SignUpInterface { return nil case .failure: return .accountCreationFailed - case .invalidEmail: - return .invalidEmail - case .duplicateEmail: - return .duplicateEmail - case .weakPassword: - return .weakPassword case .invalidInviteCode: return .invalidInviteCode case .expiredInviteCode: @@ -249,21 +239,6 @@ public extension MockSignUpRepository { return MockSignUpRepository(configuration: .failure) } - /// Creates a pre-configured actor for invalid email scenario - static func invalidEmail() -> MockSignUpRepository { - return MockSignUpRepository(configuration: .invalidEmail) - } - - /// Creates a pre-configured actor for duplicate email scenario - static func duplicateEmail() -> MockSignUpRepository { - return MockSignUpRepository(configuration: .duplicateEmail) - } - - /// Creates a pre-configured actor for weak password scenario - static func weakPassword() -> MockSignUpRepository { - return MockSignUpRepository(configuration: .weakPassword) - } - /// Creates a pre-configured actor for invalid invite code scenario static func invalidInviteCode() -> MockSignUpRepository { return MockSignUpRepository(configuration: .invalidInviteCode) @@ -298,4 +273,4 @@ public extension MockSignUpRepository { static func withDelay(_ delay: TimeInterval) -> MockSignUpRepository { return MockSignUpRepository(configuration: .customDelay(delay)) } -} \ No newline at end of file +} diff --git a/Projects/Domain/Entity/Sources/Error/SignUpError.swift b/Projects/Domain/Entity/Sources/Error/SignUpError.swift index 01725709..6aa2a536 100644 --- a/Projects/Domain/Entity/Sources/Error/SignUpError.swift +++ b/Projects/Domain/Entity/Sources/Error/SignUpError.swift @@ -8,41 +8,26 @@ import Foundation public enum SignUpError: Error, LocalizedError, Equatable { - // MARK: - Email Related Errors - case invalidEmail - case duplicateEmail - case emailNotVerified - case emailBlocked - - // MARK: - Password Related Errors - case weakPassword - case passwordMismatch - case passwordTooShort - case passwordTooLong - case passwordMissingRequirements - // MARK: - Invite Code Related Errors case invalidInviteCode case expiredInviteCode + // MARK: - Job Related Errors + case invalidJob + case jobNotSelected + case jobNotAvailable + // MARK: - Account Related Errors case accountAlreadyExists case accountCreationFailed - case accountSuspended - case accountNotActivated // MARK: - Validation Errors - case invalidName case nameTooShort case nameTooLong - case invalidPhoneNumber - case phoneNumberAlreadyExists // MARK: - Network & Server Errors case networkError case serverError(String) - case timeout - case serviceUnavailable // MARK: - General Errors case unknownError(String) @@ -51,65 +36,37 @@ public enum SignUpError: Error, LocalizedError, Equatable { public var errorDescription: String? { switch self { - // Email Related Errors - case .invalidEmail: - return "유효하지 않은 이메일 형식입니다" - case .duplicateEmail: - return "이미 사용 중인 이메일입니다" - case .emailNotVerified: - return "이메일 인증이 필요합니다" - case .emailBlocked: - return "차단된 이메일입니다" - - // Password Related Errors - case .weakPassword: - return "더 강력한 비밀번호를 설정해주세요" - case .passwordMismatch: - return "비밀번호가 일치하지 않습니다" - case .passwordTooShort: - return "비밀번호는 최소 8자 이상이어야 합니다" - case .passwordTooLong: - return "비밀번호가 너무 깁니다" - case .passwordMissingRequirements: - return "비밀번호는 영문, 숫자, 특수문자를 포함해야 합니다" - // Invite Code Related Errors case .invalidInviteCode: return "초대 코드가 잘못 되었습니다" case .expiredInviteCode: return "만료된 초대 코드입니다" + // Job Related Errors + case .invalidJob: + return "유효하지 않은 직무입니다" + case .jobNotSelected: + return "직무를 선택해주세요" + case .jobNotAvailable: + return "선택한 직무를 사용할 수 없습니다" + // Account Related Errors case .accountAlreadyExists: return "이미 존재하는 계정입니다" case .accountCreationFailed: return "계정 생성에 실패했습니다" - case .accountSuspended: - return "정지된 계정입니다" - case .accountNotActivated: - return "활성화되지 않은 계정입니다" // Validation Errors - case .invalidName: - return "유효하지 않은 이름입니다" case .nameTooShort: return "이름이 너무 짧습니다" case .nameTooLong: return "이름이 너무 깁니다" - case .invalidPhoneNumber: - return "유효하지 않은 전화번호입니다" - case .phoneNumberAlreadyExists: - return "이미 등록된 전화번호입니다" // Network & Server Errors case .networkError: return "네트워크 연결을 확인해주세요" case .serverError(let message): return "서버 오류: \(message)" - case .timeout: - return "요청 시간이 초과되었습니다" - case .serviceUnavailable: - return "서비스를 일시적으로 이용할 수 없습니다" // General Errors case .unknownError(let message): @@ -123,14 +80,12 @@ public enum SignUpError: Error, LocalizedError, Equatable { public var failureReason: String? { switch self { - case .invalidEmail: - return "이메일 형식 검증 실패" - case .duplicateEmail: - return "이메일 중복 검사 실패" - case .weakPassword: - return "비밀번호 강도 검증 실패" case .invalidInviteCode: return "초대 코드 검증 실패" + case .invalidJob: + return "직무 검증 실패" + case .jobNotSelected: + return "직무 선택 실패" case .networkError: return "네트워크 연결 실패" case .serverError: @@ -142,18 +97,16 @@ public enum SignUpError: Error, LocalizedError, Equatable { public var recoverySuggestion: String? { switch self { - case .invalidEmail: - return "올바른 이메일 주소를 입력해주세요 (예: user@example.com)" - case .duplicateEmail: - return "다른 이메일 주소를 사용하거나 로그인을 시도해보세요" - case .weakPassword: - return "영문, 숫자, 특수문자를 조합하여 8자 이상 입력해주세요" case .invalidInviteCode: return "초대 코드를 다시 확인하거나 관리자에게 문의해주세요" + case .invalidJob: + return "유효한 직무를 선택해주세요" + case .jobNotSelected: + return "목록에서 직무를 선택해주세요" + case .jobNotAvailable: + return "다른 직무를 선택하거나 관리자에게 문의해주세요" case .networkError: return "인터넷 연결을 확인하고 다시 시도해주세요" - case .timeout: - return "잠시 후 다시 시도해주세요" default: return "문제가 지속되면 고객센터에 문의해주세요" } @@ -164,30 +117,20 @@ public enum SignUpError: Error, LocalizedError, Equatable { public extension SignUpError { - /// 이메일 관련 에러인지 확인 - var isEmailError: Bool { - switch self { - case .invalidEmail, .duplicateEmail, .emailNotVerified, .emailBlocked: - return true - default: - return false - } - } - - /// 비밀번호 관련 에러인지 확인 - var isPasswordError: Bool { + /// 초대 코드 관련 에러인지 확인 + var isInviteCodeError: Bool { switch self { - case .weakPassword, .passwordMismatch, .passwordTooShort, .passwordTooLong, .passwordMissingRequirements: + case .invalidInviteCode, .expiredInviteCode: return true default: return false } } - /// 초대 코드 관련 에러인지 확인 - var isInviteCodeError: Bool { + /// 직무 관련 에러인지 확인 + var isJobError: Bool { switch self { - case .invalidInviteCode, .expiredInviteCode: + case .invalidJob, .jobNotSelected, .jobNotAvailable: return true default: return false @@ -197,7 +140,7 @@ public extension SignUpError { /// 네트워크 관련 에러인지 확인 var isNetworkError: Bool { switch self { - case .networkError, .timeout, .serviceUnavailable: + case .networkError: return true default: return false @@ -207,7 +150,7 @@ public extension SignUpError { /// 재시도 가능한 에러인지 확인 var isRetryable: Bool { switch self { - case .networkError, .timeout, .serviceUnavailable, .serverError: + case .networkError, .serverError: return true default: return false diff --git a/Projects/Domain/Entity/Sources/OnBoarding/SelectJob.swift b/Projects/Domain/Entity/Sources/OnBoarding/SelectJob.swift new file mode 100644 index 00000000..771e41c9 --- /dev/null +++ b/Projects/Domain/Entity/Sources/OnBoarding/SelectJob.swift @@ -0,0 +1,21 @@ +// +// SelectJob.swift +// Entity +// +// Created by Wonji Suh on 12/30/25. +// + +import Foundation + +public struct SelectJob: Equatable { + public let jobKeys: String + public let job: SelectParts + + public init( + jobKeys: String, + job: SelectParts + ) { + self.jobKeys = jobKeys + self.job = job + } +} diff --git a/Projects/Domain/Entity/Sources/OnBoarding/SelectPart.swift b/Projects/Domain/Entity/Sources/OnBoarding/SelectPart.swift new file mode 100644 index 00000000..50d9433d --- /dev/null +++ b/Projects/Domain/Entity/Sources/OnBoarding/SelectPart.swift @@ -0,0 +1,61 @@ +// +// SelectPart.swift +// Entity +// +// Created by Wonji Suh on 12/30/25. +// + +import Foundation + +public enum SelectParts: String, CaseIterable, Codable, Equatable { + case all + case pm = "PM" + case designer = "Designer" + case android = "Android" + case ios + case frontend = "FE" + case backend = "BE" + + public var desc: String { + switch self { + case .all: + return "전체" + case .pm: + return "Product Manager" + case .designer: + return "Product Designer" + case .android: + return "Android" + case .ios: + return "iOS" + case .frontend: + return "Frontend" + case .backend: + return "Backend" + } + } + + public var apiKey: String { + switch self { + case .all: return "ALL" + case .backend: return "BACKEND" + case .frontend: return "FRONTEND" + case .designer: return "DESIGNER" + case .pm: return "PM" + case .android: return "ANDROID" + case .ios: return "IOS" + } + } + + public static func from(apiKey: String) -> SelectParts? { + switch apiKey.uppercased() { + case "BACKEND": return .backend + case "FRONTEND": return .frontend + case "DESIGNER": return .designer + case "PM": return .pm + case "ANDROID": return .android + case "IOS": return .ios + default: return nil + } + } +} diff --git a/Projects/Domain/UseCase/Sources/OnBoarding/OnBoardingUseCaseImpl.swift b/Projects/Domain/UseCase/Sources/OnBoarding/OnBoardingUseCaseImpl.swift index ccf0eb67..001ffab4 100644 --- a/Projects/Domain/UseCase/Sources/OnBoarding/OnBoardingUseCaseImpl.swift +++ b/Projects/Domain/UseCase/Sources/OnBoarding/OnBoardingUseCaseImpl.swift @@ -21,6 +21,11 @@ public struct OnBoardingUseCaseImpl: OnBoardingInterface { ) async throws -> Entity.VerifyCodeEntity { return try await repository.verifyCode(code: code) } + + public func fetchJobs() async throws -> [Entity.SelectJob] { + return try await repository.fetchJobs() + } + } From db03684ea0747e4ea0fe06ab6e117ff7a4fa4bf1 Mon Sep 17 00:00:00 2001 From: Roy Date: Tue, 30 Dec 2025 14:45:54 +0900 Subject: [PATCH 06/26] =?UTF-8?q?=F0=9F=94=A7[refactor]:=20=ED=9A=8C?= =?UTF-8?q?=EC=9B=90=EA=B0=80=EC=9E=85=20=ED=99=94=EB=A9=B4=20=EC=BD=94?= =?UTF-8?q?=EB=93=9C=20=EC=A0=95=EB=A6=AC=20=EB=B0=8F=20API=20=EC=97=B0?= =?UTF-8?q?=EB=8F=99=20=EA=B0=9C=EC=84=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../SignUpName/Reducer/SignUpName.swift | 52 +++---------- .../SignUpName/View/SignUpNameView.swift | 10 +-- .../SignUpPart/Reducer/SignUpPart.swift | 78 +++++++++++++++---- .../SignUpPart/View/SignUpPartView.swift | 46 ++++++++--- 4 files changed, 112 insertions(+), 74 deletions(-) diff --git a/Projects/Presentation/Auth/Sources/SignUpName/Reducer/SignUpName.swift b/Projects/Presentation/Auth/Sources/SignUpName/Reducer/SignUpName.swift index 277b73d6..6383ea7d 100644 --- a/Projects/Presentation/Auth/Sources/SignUpName/Reducer/SignUpName.swift +++ b/Projects/Presentation/Auth/Sources/SignUpName/Reducer/SignUpName.swift @@ -22,17 +22,15 @@ public struct SignUpName { @Shared(.inMemory("UserEntity")) var userEntity: UserEntity = .shared - var isNotAvaliableName: Bool = false + var isNotAvailableName: Bool = false var enableButton: Bool { - return !userEntity.signUpName.isEmpty && !isNotAvaliableName + return !userEntity.signUpName.isEmpty && !isNotAvailableName } } - public enum Action: ViewAction, BindableAction, FeatureAction { + public enum Action: ViewAction, BindableAction { case binding(BindingAction) case view(View) - case async(AsyncAction) - case inner(InnerAction) case navigation(NavigationAction) } @@ -40,24 +38,12 @@ public struct SignUpName { @CasePathable public enum View { - case checkIsAvaliableName + case checkIsAvailableName case initSignUpName } - - // MARK: - AsyncAction 비동기 처리 액션 - - public enum AsyncAction: Equatable { - - } - - // MARK: - 앱내에서 사용하는 액션 - - public enum InnerAction: Equatable { - - } + // MARK: - NavigationAction - public enum NavigationAction: Equatable { case presentSignUpPart } @@ -71,13 +57,7 @@ public struct SignUpName { case .view(let viewAction): return handleViewAction(state: &state, action: viewAction) - - case .async(let asyncAction): - return handleAsyncAction(state: &state, action: asyncAction) - - case .inner(let innerAction): - return handleInnerAction(state: &state, action: innerAction) - + case .navigation(let navigationAction): return handleNavigationAction(state: &state, action: navigationAction) } @@ -89,11 +69,11 @@ public struct SignUpName { action: View ) -> Effect { switch action { - case .checkIsAvaliableName: + case .checkIsAvailableName: if state.userEntity.signUpName.count > 5 { - state.isNotAvaliableName = true + state.isNotAvailableName = true } else { - state.isNotAvaliableName = false + state.isNotAvailableName = false } return .run { [enableButton = state.enableButton] send in if enableButton == true { @@ -116,18 +96,4 @@ public struct SignUpName { return .none } } - - private func handleAsyncAction( - state: inout State, - action: AsyncAction - ) -> Effect { - - } - - private func handleInnerAction( - state: inout State, - action: InnerAction - ) -> Effect { - - } } diff --git a/Projects/Presentation/Auth/Sources/SignUpName/View/SignUpNameView.swift b/Projects/Presentation/Auth/Sources/SignUpName/View/SignUpNameView.swift index 0a9f14b7..47e0cbf4 100644 --- a/Projects/Presentation/Auth/Sources/SignUpName/View/SignUpNameView.swift +++ b/Projects/Presentation/Auth/Sources/SignUpName/View/SignUpNameView.swift @@ -91,7 +91,7 @@ extension SignUpNameView { RoundedRectangle(cornerRadius: 16) .inset(by: 0.5) - .stroke(store.isNotAvaliableName ? .statusError : .borderInactive, lineWidth: 1) + .stroke(store.isNotAvailableName ? .statusError : .borderInactive, lineWidth: 1) .frame(height: 56) .overlay { HStack { @@ -111,13 +111,13 @@ extension SignUpNameView { .onChange(of: store.userEntity.signUpName) { new, _ in if new.count > 5 { store.userEntity.signUpName = String(new.prefix(5)) - store.isNotAvaliableName = false + store.isNotAvailableName = false } } Spacer() - Image(asset: store.isNotAvaliableName ? .errorClose : .close) + Image(asset: store.isNotAvailableName ? .errorClose : .close) .resizable() .scaledToFit() .frame(width: 20, height: 20) @@ -136,7 +136,7 @@ extension SignUpNameView { @ViewBuilder private func errorNameText() -> some View { - if store.isNotAvaliableName { + if store.isNotAvailableName { VStack { Spacer() .frame(height: 8) @@ -167,7 +167,7 @@ extension SignUpNameView { CustomButton( action: { - store.send(.view(.checkIsAvaliableName)) + store.send(.view(.checkIsAvailableName)) }, title: "다음", config: CustomButtonConfig.create(), diff --git a/Projects/Presentation/Auth/Sources/SignUpPart/Reducer/SignUpPart.swift b/Projects/Presentation/Auth/Sources/SignUpPart/Reducer/SignUpPart.swift index c77a689b..650cbd6c 100644 --- a/Projects/Presentation/Auth/Sources/SignUpPart/Reducer/SignUpPart.swift +++ b/Projects/Presentation/Auth/Sources/SignUpPart/Reducer/SignUpPart.swift @@ -9,6 +9,7 @@ import Foundation import Core import Utill +import Entity import ComposableArchitecture @@ -21,7 +22,10 @@ public struct SignUpPart { public init() {} var activeSelectPart: Bool = false - var selectPart: SelectPart? = .all + var selectPart: SelectParts? = .all + var selectJobs: [SelectJob]? = [] + var errorMessage: String? + var loading: Bool = false @Shared(.inMemory("UserEntity")) var userEntity: UserEntity = .shared } @@ -37,19 +41,20 @@ public struct SignUpPart { @CasePathable public enum View { - case selectPartButton(selectPart: SelectPart) + case selectPartButton(selectPart: SelectJob) + case onAppear } // MARK: - AsyncAction 비동기 처리 액션 public enum AsyncAction: Equatable { - + case getJobList } // MARK: - 앱내에서 사용하는 액션 public enum InnerAction: Equatable { - + case jobListResponse(Result<[SelectJob], SignUpError>) } // MARK: - NavigationAction @@ -59,7 +64,14 @@ public struct SignUpPart { case presentSelectTeam case presentNextStep } - + + + nonisolated enum CancelID: Hashable { + case fetchJobList + } + + @Dependency(\.onBoardingUseCase) var onBoardingUseCase + public var body: some ReducerOf { BindingReducer() Reduce { state, action in @@ -88,20 +100,27 @@ public struct SignUpPart { ) -> Effect { switch action { - case .selectPartButton(let selectPart): - if state.selectPart == selectPart { + case .selectPartButton(let selectJob): + let selectedPart = selectJob.job + + if state.selectPart == selectedPart { // 동일한 파트 재선택 → 해제 state.selectPart = nil - state.$userEntity.withLock { $0.role = nil } + // TODO: - 차후에 수정 +// state.$userEntity.withLock { $0.role = nil } state.activeSelectPart = false return .none } - - state.selectPart = selectPart - state.$userEntity.withLock { $0.role = selectPart } + + state.selectPart = selectedPart + // TODO: - 차후에 수정 +// state.$userEntity.withLock { $0.role = selectedPart } state.activeSelectPart = true - #logDebug("selectPart", state.userEntity.role) +// #logDebug("selectPart", state.userEntity.role) return .none + + case .onAppear: + return .send(.async(.getJobList)) } } @@ -129,13 +148,44 @@ public struct SignUpPart { state: inout State, action: AsyncAction ) -> Effect { - + switch action { + case .getJobList: + state.loading = true + return .run { send in + let jobListResult = await Result { + try await onBoardingUseCase.fetchJobs() + } + .mapError { error -> SignUpError in + if let authError = error as? SignUpError { + return authError + } else { + return .unknownError(error.localizedDescription) + } + } + return await send(.inner(.jobListResponse(jobListResult))) + } + .cancellable(id: CancelID.fetchJobList, cancelInFlight: true) + } + } private func handleInnerAction( state: inout State, action: InnerAction ) -> Effect { - + switch action { + case .jobListResponse(let result): + switch result { + case .success(let data): + state.loading = false + state.selectJobs = data + + case .failure(let error): + state.errorMessage = error.errorDescription + #logError("네트워크 통신 실패", error.errorDescription) + } + return .none + + } } } diff --git a/Projects/Presentation/Auth/Sources/SignUpPart/View/SignUpPartView.swift b/Projects/Presentation/Auth/Sources/SignUpPart/View/SignUpPartView.swift index 6b9ef4cb..96787b31 100644 --- a/Projects/Presentation/Auth/Sources/SignUpPart/View/SignUpPartView.swift +++ b/Projects/Presentation/Auth/Sources/SignUpPart/View/SignUpPartView.swift @@ -6,10 +6,13 @@ // import SwiftUI -import ComposableArchitecture + import DesignSystem import Core +import SDWebImageSwiftUI +import ComposableArchitecture + public struct SignUpPartView: View { @Bindable var store: StoreOf var backAction: () -> Void = {} @@ -35,10 +38,26 @@ public struct SignUpPartView: View { signUpPartText() - selectPartList() - - signUpPartButton() - + if store.loading { + VStack { + Spacer() + + AnimatedImage(name: "DDDLoding.gif", isAnimating: .constant(true)) + .resizable() + .scaledToFit() + .frame(width: 200, height: 200) + + Spacer() + } + } else { + selectPartList() + + signUpPartButton() + } + + } + .task { + store.send(.view(.onAppear)) } } } @@ -63,13 +82,16 @@ extension SignUpPartView { .frame(height: 40) ScrollView { - VStack { - ForEach(SelectPart.allParts, id: \.self) { item in - SelectPartItem( - content: item.desc, - isActive: item == store.selectPart) { - store.send(.view(.selectPartButton(selectPart: item))) - } + LazyVStack { + ForEach( + (store.selectJobs ?? []).sorted { + $0.job.desc.localizedCaseInsensitiveCompare($1.job.desc) == .orderedAscending + }, + id: \.jobKeys + ) { item in + SelectPartItem(content: item.job.desc, isActive: item.job == store.selectPart) { + store.send(.view(.selectPartButton(selectPart: item))) + } } } } From b02adfe7f3d6dadf5646d474ddba88d7d690bf79 Mon Sep 17 00:00:00 2001 From: Roy Date: Wed, 31 Dec 2025 11:08:28 +0900 Subject: [PATCH 07/26] =?UTF-8?q?=E2=9C=A8[feat]:=20=ED=8C=80=20=EC=84=A0?= =?UTF-8?q?=ED=83=9D=20API=20=EC=97=B0=EB=8F=99=20=EB=B0=8F=20DTO=20?= =?UTF-8?q?=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../OnBoarding/DTO/SelectJobsDTO.swift | 18 +++++++++- .../OnBoarding/DTO/SelectTeamsDTO.swift | 33 +++++++++++++++++++ .../OnBoarding/Mapper/SelectTeamsDTO+.swift | 30 +++++++++++++++++ .../OnBoarding/OnBoardingRepositoryImpl.swift | 13 +++++--- .../Sources/Common/Extension+Encodable.swift | 5 +++ .../OnBoarding/OnBoardingService.swift | 9 ++++- 6 files changed, 102 insertions(+), 6 deletions(-) create mode 100644 Projects/Data/Model/Sources/OnBoarding/DTO/SelectTeamsDTO.swift create mode 100644 Projects/Data/Model/Sources/OnBoarding/Mapper/SelectTeamsDTO+.swift diff --git a/Projects/Data/Model/Sources/OnBoarding/DTO/SelectJobsDTO.swift b/Projects/Data/Model/Sources/OnBoarding/DTO/SelectJobsDTO.swift index 61793a07..a7a24035 100644 --- a/Projects/Data/Model/Sources/OnBoarding/DTO/SelectJobsDTO.swift +++ b/Projects/Data/Model/Sources/OnBoarding/DTO/SelectJobsDTO.swift @@ -9,9 +9,25 @@ public struct SelectJobsDTO: Decodable { public let data: [SelectJobsDTOResponse] + + public init(from decoder: Decoder) throws { + let container = try decoder.singleValueContainer() + if let array = try? container.decode([SelectJobsDTOResponse].self) { + self.data = array + return + } + + let keyed = try decoder.container(keyedBy: CodingKeys.self) + self.data = try keyed.decode([SelectJobsDTOResponse].self, forKey: .data) + } + + private enum CodingKeys: String, CodingKey { + case data + } } public struct SelectJobsDTOResponse: Decodable { - let key, description: String + public let key: String + public let description: String } diff --git a/Projects/Data/Model/Sources/OnBoarding/DTO/SelectTeamsDTO.swift b/Projects/Data/Model/Sources/OnBoarding/DTO/SelectTeamsDTO.swift new file mode 100644 index 00000000..8ecca90b --- /dev/null +++ b/Projects/Data/Model/Sources/OnBoarding/DTO/SelectTeamsDTO.swift @@ -0,0 +1,33 @@ +// +// SelectTeamsDTO.swift +// Model +// +// Created by Wonji Suh on 12/31/25. +// + +import Foundation + +public struct SelectTeamsDTO: Decodable { + public let data: [SelectTeamsDTOResponse] + + public init(from decoder: Decoder) throws { + let container = try decoder.singleValueContainer() + if let array = try? container.decode([SelectTeamsDTOResponse].self) { + self.data = array + return + } + + let keyed = try decoder.container(keyedBy: CodingKeys.self) + self.data = try keyed.decode([SelectTeamsDTOResponse].self, forKey: .data) + } + + private enum CodingKeys: String, CodingKey { + case data + } +} + + +public struct SelectTeamsDTOResponse: Decodable { + public let teamId: Int + public let name: String +} diff --git a/Projects/Data/Model/Sources/OnBoarding/Mapper/SelectTeamsDTO+.swift b/Projects/Data/Model/Sources/OnBoarding/Mapper/SelectTeamsDTO+.swift new file mode 100644 index 00000000..bfa1ecd8 --- /dev/null +++ b/Projects/Data/Model/Sources/OnBoarding/Mapper/SelectTeamsDTO+.swift @@ -0,0 +1,30 @@ +// +// SelectTeamsDTO+.swift +// Model +// +// Created by Wonji Suh on 12/31/25. +// + +import Foundation +import Entity + +public extension SelectTeamsDTOResponse { + func toDomain() -> SelectTeamEntity { + return SelectTeamEntity( + teamId: self.teamId, + teams: SelectTeams(rawValue: self.name) ?? .unknown + ) + } +} + +public extension Array where Element == SelectTeamsDTOResponse { + func toDomain() -> [SelectTeamEntity] { + return self.map { $0.toDomain() } + } +} + +public extension SelectTeamsDTO { + func toDomain() -> [SelectTeamEntity] { + return self.data.toDomain() + } +} diff --git a/Projects/Data/Repository/Sources/OnBoarding/OnBoardingRepositoryImpl.swift b/Projects/Data/Repository/Sources/OnBoarding/OnBoardingRepositoryImpl.swift index 8e8a04e3..21d76846 100644 --- a/Projects/Data/Repository/Sources/OnBoarding/OnBoardingRepositoryImpl.swift +++ b/Projects/Data/Repository/Sources/OnBoarding/OnBoardingRepositoryImpl.swift @@ -13,8 +13,8 @@ import Entity @preconcurrency import AsyncMoya -final public class OnBoardingRepositoryImpl:OnBoardingInterface { - +final public class OnBoardingRepositoryImpl: OnBoardingInterface { + private let provider: MoyaProvider public init( @@ -31,8 +31,13 @@ final public class OnBoardingRepositoryImpl:OnBoardingInterface { } public func fetchJobs() async throws -> [Entity.SelectJob] { - let dtoArray: [SelectJobsDTOResponse] = try await provider.request(.jobs) - return dtoArray.toDomain() + let dtoArray: SelectJobsDTO = try await provider.request(.jobs) + return dtoArray.data.toDomain() + } + + public func fetchTeams(generationId: Int) async throws -> [SelectTeamEntity] { + let dto : SelectTeamsDTO = try await provider.request(.teams(generationId: generationId)) + return dto.data.toDomain() } } diff --git a/Projects/Data/Service/Sources/Common/Extension+Encodable.swift b/Projects/Data/Service/Sources/Common/Extension+Encodable.swift index 7e817b6e..448524cd 100644 --- a/Projects/Data/Service/Sources/Common/Extension+Encodable.swift +++ b/Projects/Data/Service/Sources/Common/Extension+Encodable.swift @@ -21,3 +21,8 @@ extension String { } } +extension Int { + func toDictionary(key: String) -> [String: Any] { + [key: self] + } +} diff --git a/Projects/Data/Service/Sources/OnBoarding/OnBoardingService.swift b/Projects/Data/Service/Sources/OnBoarding/OnBoardingService.swift index b54a6082..d979a964 100644 --- a/Projects/Data/Service/Sources/OnBoarding/OnBoardingService.swift +++ b/Projects/Data/Service/Sources/OnBoarding/OnBoardingService.swift @@ -14,6 +14,7 @@ import AsyncMoya public enum OnBoardingService { case verifyCode(code : String) case jobs + case teams(generationId: Int) } @@ -31,6 +32,9 @@ extension OnBoardingService: BaseTargetType { case .jobs: return OnBoardingAPI.jobs.description + + case .teams: + return OnBoardingAPI.teams.description } } @@ -45,12 +49,15 @@ extension OnBoardingService: BaseTargetType { case .jobs: return nil + + case .teams(let generationId): + return generationId.toDictionary(key: "generationId") } } public var method: Moya.Method { switch self { - case .verifyCode, .jobs: + case .verifyCode, .jobs, .teams: return .get } } From e92897a657121baeac6ffaa805a79fb1888f6b2a Mon Sep 17 00:00:00 2001 From: Roy Date: Wed, 31 Dec 2025 11:08:58 +0900 Subject: [PATCH 08/26] =?UTF-8?q?=E2=9C=A8[feat]:=20=EC=98=A8=EB=B3=B4?= =?UTF-8?q?=EB=94=A9=20=EB=8F=84=EB=A9=94=EC=9D=B8=20=EA=B5=AC=EC=A1=B0=20?= =?UTF-8?q?=EA=B0=9C=EC=84=A0=20=EB=B0=8F=20=ED=8C=80=20=EC=84=A0=ED=83=9D?= =?UTF-8?q?=20=EA=B8=B0=EB=8A=A5=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../DefaultOnBoardingRepositoryImpl.swift | 40 ++++++++++++ .../OnBoarding/OnBoardingInterface.swift | 1 + .../Entity/Sources/Error/SignUpError.swift | 8 ++- .../Entity/Sources/OAuth/SocialType.swift | 2 +- .../Sources/OnBoarding/SelectTeam.swift | 23 +++++++ .../Sources/OnBoarding/SelectTeams.swift | 61 +++++++++++++++++++ .../Sources/OnBoarding/SignUpEntity.swift | 48 +++++++++++++++ .../Sources/OnBoarding/StaffManaging.swift | 34 +++++++++++ .../Sources/OAuth/UnifiedOAuthUseCase.swift | 5 +- .../OnBoarding/OnBoardingUseCaseImpl.swift | 4 ++ .../Sources/APIHeader/APIHeader.swift | 1 - 11 files changed, 223 insertions(+), 4 deletions(-) create mode 100644 Projects/Domain/Entity/Sources/OnBoarding/SelectTeam.swift create mode 100644 Projects/Domain/Entity/Sources/OnBoarding/SelectTeams.swift create mode 100644 Projects/Domain/Entity/Sources/OnBoarding/SignUpEntity.swift create mode 100644 Projects/Domain/Entity/Sources/OnBoarding/StaffManaging.swift diff --git a/Projects/Domain/DomainInterface/Sources/OnBoarding/DefaultOnBoardingRepositoryImpl.swift b/Projects/Domain/DomainInterface/Sources/OnBoarding/DefaultOnBoardingRepositoryImpl.swift index 4020cebf..482d6a18 100644 --- a/Projects/Domain/DomainInterface/Sources/OnBoarding/DefaultOnBoardingRepositoryImpl.swift +++ b/Projects/Domain/DomainInterface/Sources/OnBoarding/DefaultOnBoardingRepositoryImpl.swift @@ -99,6 +99,8 @@ public final class DefaultOnBoardingRepositoryImpl: OnBoardingInterface, @unchec private var lastVerifyCall: Date? private var fetchJobsCallCount = 0 private var lastFetchJobsCall: Date? + private var fetchTeamsCallCount = 0 + private var lastFetchTeamsCall: Date? // MARK: - Initialization @@ -114,6 +116,8 @@ public final class DefaultOnBoardingRepositoryImpl: OnBoardingInterface, @unchec lastVerifyCall = nil fetchJobsCallCount = 0 lastFetchJobsCall = nil + fetchTeamsCallCount = 0 + lastFetchTeamsCall = nil } public func getVerifyCallCount() -> Int { @@ -132,12 +136,22 @@ public final class DefaultOnBoardingRepositoryImpl: OnBoardingInterface, @unchec return lastFetchJobsCall } + public func getFetchTeamsCallCount() -> Int { + return fetchTeamsCallCount + } + + public func getLastFetchTeamsCall() -> Date? { + return lastFetchTeamsCall + } + public func reset() { configuration = .success verifyCallCount = 0 lastVerifyCall = nil fetchJobsCallCount = 0 lastFetchJobsCall = nil + fetchTeamsCallCount = 0 + lastFetchTeamsCall = nil } // MARK: - OnBoardingInterface Implementation @@ -250,6 +264,32 @@ public final class DefaultOnBoardingRepositoryImpl: OnBoardingInterface, @unchec return mockJobs } } + + public func fetchTeams(generationId: Int) async throws -> [SelectTeamEntity] { + // Track call + fetchTeamsCallCount += 1 + lastFetchTeamsCall = Date() + + // Apply delay + if configuration.delay > 0 { + try await Task.sleep(for: .seconds(configuration.delay)) + } + + // Configuration 기반 응답 처리 + if !configuration.shouldSucceed, let error = configuration.error { + throw error + } + + let availableTeams = SelectTeams.allCases.filter { $0 != .unknown } + let baseTeamId = max(generationId, 0) * 100 + + return availableTeams.enumerated().map { index, team in + SelectTeamEntity( + teamId: baseTeamId + index + 1, + teams: team + ) + } + } } // MARK: - Convenience Static Methods diff --git a/Projects/Domain/DomainInterface/Sources/OnBoarding/OnBoardingInterface.swift b/Projects/Domain/DomainInterface/Sources/OnBoarding/OnBoardingInterface.swift index 2c926b0c..adfe0a39 100644 --- a/Projects/Domain/DomainInterface/Sources/OnBoarding/OnBoardingInterface.swift +++ b/Projects/Domain/DomainInterface/Sources/OnBoarding/OnBoardingInterface.swift @@ -14,6 +14,7 @@ import Entity public protocol OnBoardingInterface: Sendable { func verifyCode(code: String) async throws -> VerifyCodeEntity func fetchJobs() async throws -> [SelectJob] + func fetchTeams(generationId: Int) async throws -> [SelectTeamEntity] } public struct OnBoardingRepositoryDependency: DependencyKey { diff --git a/Projects/Domain/Entity/Sources/Error/SignUpError.swift b/Projects/Domain/Entity/Sources/Error/SignUpError.swift index 6aa2a536..2c9e9fd3 100644 --- a/Projects/Domain/Entity/Sources/Error/SignUpError.swift +++ b/Projects/Domain/Entity/Sources/Error/SignUpError.swift @@ -116,6 +116,12 @@ public enum SignUpError: Error, LocalizedError, Equatable { // MARK: - Convenience Methods public extension SignUpError { + static func from(_ error: Error) -> SignUpError { + if let signUpError = error as? SignUpError { + return signUpError + } + return .unknownError(error.localizedDescription) + } /// 초대 코드 관련 에러인지 확인 var isInviteCodeError: Bool { @@ -156,4 +162,4 @@ public extension SignUpError { return false } } -} \ No newline at end of file +} diff --git a/Projects/Domain/Entity/Sources/OAuth/SocialType.swift b/Projects/Domain/Entity/Sources/OAuth/SocialType.swift index 2174876a..4a5c351b 100644 --- a/Projects/Domain/Entity/Sources/OAuth/SocialType.swift +++ b/Projects/Domain/Entity/Sources/OAuth/SocialType.swift @@ -7,7 +7,7 @@ import Foundation -public enum SocialType: String, CaseIterable, Identifiable, Hashable { +public enum SocialType: String, CaseIterable, Identifiable, Hashable, Equatable { case apple case google diff --git a/Projects/Domain/Entity/Sources/OnBoarding/SelectTeam.swift b/Projects/Domain/Entity/Sources/OnBoarding/SelectTeam.swift new file mode 100644 index 00000000..e1711be6 --- /dev/null +++ b/Projects/Domain/Entity/Sources/OnBoarding/SelectTeam.swift @@ -0,0 +1,23 @@ +// +// SelectEntity.swift +// Entity +// +// Created by Wonji Suh on 12/31/25. +// + +import Foundation + +// MARK: - 나중에 +//SelectTeam 으로 이름 변경 예정 +public struct SelectTeamEntity: Equatable { + public let teamId: Int + public let teams: SelectTeams + + public init( + teamId: Int, + teams: SelectTeams + ) { + self.teamId = teamId + self.teams = teams + } +} diff --git a/Projects/Domain/Entity/Sources/OnBoarding/SelectTeams.swift b/Projects/Domain/Entity/Sources/OnBoarding/SelectTeams.swift new file mode 100644 index 00000000..278c784e --- /dev/null +++ b/Projects/Domain/Entity/Sources/OnBoarding/SelectTeams.swift @@ -0,0 +1,61 @@ +// +// SelectTeams.swift +// Entity +// +// Created by Wonji Suh on 12/31/25. +// + +public enum SelectTeams: String, CaseIterable, Equatable, CustomStringConvertible { + case and1 = "Android 1팀" + case and2 = "Android 2팀" + case ios1 = "iOS 1팀" + case ios2 = "iOS 2팀" + case web1 = "WEB 1팀" + case web2 = "WEB 2팀" + case unknown + + + public var description: String { rawValue } + + public var managingTeamDesc: String { + switch self { + case .web1: return "WEB 1" + case .web2: return "WEB 2" + case .and1: return "Android 1" + case .and2: return "Android 2" + case .ios1: return "iOS 1" + case .ios2: return "iOS 2" + case .unknown: return "" + } + } + + public var selectTeamDescription: String { + switch self { + case .ios1: return "🍏 iOS 1팀" + case .ios2: return "🍏 iOS 2팀" + case .and1: return "🤖 Android 1팀" + case .and2: return "🤖 Android 2팀" + case .web1: return "🖥️ WEB 1팀" + case .web2: return "🖥️ WEB 2팀" + case .unknown: return "" + } + } + + public var attendanceListDescription: String { rawValue } + + public var attandanceCardDescription: String { + switch self { + case .web1: return "WEB1팀" + case .web2: return "WEB2팀" + case .and1: return "Android1팀" + case .and2: return "Android2팀" + case .ios1: return "iOS1팀" + case .ios2: return "iOS2팀" + default: return "" + } + } + + public static func from(name: String) -> SelectTeams { + SelectTeams(rawValue: name) ?? .unknown + } +} diff --git a/Projects/Domain/Entity/Sources/OnBoarding/SignUpEntity.swift b/Projects/Domain/Entity/Sources/OnBoarding/SignUpEntity.swift new file mode 100644 index 00000000..161a31c8 --- /dev/null +++ b/Projects/Domain/Entity/Sources/OnBoarding/SignUpEntity.swift @@ -0,0 +1,48 @@ +// +// UserSession.swift +// Entity +// +// Created by Wonji Suh on 12/31/25. +// + +import Foundation + +public struct UserSession: Equatable { + public var name: String + public var selectPart: SelectParts + public var userRole: Staff + public var managing: StaffManaging? + public var provider: SocialType + public var selectTeam: SelectTeams + public var token: String + public var generationId : Int? + public var inviteCode: String + + public init( + name: String = "", + selectPart: SelectParts = .all, + userRole: Staff = .member, + managing: StaffManaging? = nil, + provider: SocialType = .apple, + selectTeam: SelectTeams = .unknown, + token: String = "", + generationId: Int? = nil, + inviteCode: String = "" + ) { + self.name = name + self.selectPart = selectPart + self.userRole = userRole + self.managing = managing + self.provider = provider + self.selectTeam = selectTeam + self.token = token + self.generationId = generationId + self.inviteCode = inviteCode + } + +} + + +public extension UserSession { + static let empty = UserSession() +} diff --git a/Projects/Domain/Entity/Sources/OnBoarding/StaffManaging.swift b/Projects/Domain/Entity/Sources/OnBoarding/StaffManaging.swift new file mode 100644 index 00000000..b42102c4 --- /dev/null +++ b/Projects/Domain/Entity/Sources/OnBoarding/StaffManaging.swift @@ -0,0 +1,34 @@ +// +// StaffManaging.swift +// Entity +// +// Created by Wonji Suh on 12/31/25. +// + +import Foundation + +public enum StaffManaging: String, CaseIterable, Codable, Equatable { + case teamManaging = "TEAM_MANAGING" + case scheduleReminder = "SCHEDULE_REMINDER" + case photo = "PHOTO" + case locationRental = "LOCATION_RENTAL" + case snsManagement = "SNS_MANAGEMENT" + case attendanceCheck = "ATTENDANCE_CHECK" + + public var desc: String { + switch self { + case .teamManaging: return "팀매니징" + case .scheduleReminder: return "일정 리마인드" + case .photo: return "사진 촬영" + case .locationRental: return "장소 대관" + case .snsManagement: return "SNS 관리" + case .attendanceCheck: return "출석 체크" + } + } + + public var apiKey: String { rawValue } + + public static func from(apiKey: String) -> StaffManaging? { + StaffManaging(rawValue: apiKey.uppercased()) + } +} diff --git a/Projects/Domain/UseCase/Sources/OAuth/UnifiedOAuthUseCase.swift b/Projects/Domain/UseCase/Sources/OAuth/UnifiedOAuthUseCase.swift index 4276e9d0..31ea7452 100644 --- a/Projects/Domain/UseCase/Sources/OAuth/UnifiedOAuthUseCase.swift +++ b/Projects/Domain/UseCase/Sources/OAuth/UnifiedOAuthUseCase.swift @@ -10,13 +10,14 @@ import Dependencies import AuthenticationServices @preconcurrency import Entity import DomainInterface +import Sharing /// 통합 OAuth UseCase - 로그인/회원가입 플로우를 하나로 통합 public struct UnifiedOAuthUseCase { @Dependency(\.authRepository) private var authRepository: AuthInterface @Dependency(\.appleOAuthProvider) private var appleProvider: AppleOAuthProviderInterface @Dependency(\.googleOAuthProvider) private var googleProvider: GoogleOAuthProviderInterface - + @Shared(.inMemory("UserSession")) var userSession: UserSession = .empty public init() {} } @@ -54,6 +55,7 @@ public extension UnifiedOAuthUseCase { credential: credential, nonce: nonce ) + self.$userSession.withLock { $0.token = payload.idToken } return try await authRepository.login( provider: .apple, token: payload.idToken @@ -65,6 +67,7 @@ public extension UnifiedOAuthUseCase { token: String ) async throws -> LoginEntity { let processedToken = try await googleProvider.signInWithToken(token: token) + self.$userSession.withLock { $0.token = processedToken } return try await authRepository.login( provider: .google, token: processedToken diff --git a/Projects/Domain/UseCase/Sources/OnBoarding/OnBoardingUseCaseImpl.swift b/Projects/Domain/UseCase/Sources/OnBoarding/OnBoardingUseCaseImpl.swift index 001ffab4..a7587302 100644 --- a/Projects/Domain/UseCase/Sources/OnBoarding/OnBoardingUseCaseImpl.swift +++ b/Projects/Domain/UseCase/Sources/OnBoarding/OnBoardingUseCaseImpl.swift @@ -26,6 +26,10 @@ public struct OnBoardingUseCaseImpl: OnBoardingInterface { return try await repository.fetchJobs() } + public func fetchTeams(generationId: Int) async throws -> [SelectTeamEntity] { + return try await repository.fetchTeams(generationId: generationId) + } + } diff --git a/Projects/Network/Foundations/Sources/APIHeader/APIHeader.swift b/Projects/Network/Foundations/Sources/APIHeader/APIHeader.swift index de112d95..ebdd5065 100644 --- a/Projects/Network/Foundations/Sources/APIHeader/APIHeader.swift +++ b/Projects/Network/Foundations/Sources/APIHeader/APIHeader.swift @@ -16,7 +16,6 @@ public struct APIHeader { public static let contentType = "Content-Type" public static let accessToken = "Authorization" public static let accept = "accept" - public static let xcsrftoken = "X-CSRFTOKEN" // ← add `static` here @Shared(.inMemory("UserEntity")) From 37100487d29318e2635d6c93614311312a66d242 Mon Sep 17 00:00:00 2001 From: Roy Date: Wed, 31 Dec 2025 11:09:18 +0900 Subject: [PATCH 09/26] =?UTF-8?q?=F0=9F=94=A7[refactor]:=20UserEntity?= =?UTF-8?q?=EB=A5=BC=20UserSession=EC=9C=BC=EB=A1=9C=20=ED=86=B5=ED=95=A9?= =?UTF-8?q?=20=EB=B0=8F=20=EC=83=81=ED=83=9C=EA=B4=80=EB=A6=AC=20=EA=B0=9C?= =?UTF-8?q?=EC=84=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Reducer/AuthCoordinator.swift | 9 +- .../Auth/Sources/Login/Reducer/Login.swift | 35 +---- .../Reducer/SignUpInviteCode.swift | 27 ++-- .../SignUpName/Reducer/SignUpName.swift | 12 +- .../SignUpName/View/SignUpNameView.swift | 8 +- .../SignUpPart/Reducer/SignUpPart.swift | 20 +-- .../SignUpPart/View/SignUpPartView.swift | 5 +- .../Reducer/SignUpSelectTeam.swift | 137 +++++++----------- .../View/SignUpSelectTeamView.swift | 38 +++-- 9 files changed, 119 insertions(+), 172 deletions(-) diff --git a/Projects/Presentation/Auth/Sources/AuthCoordinator/Reducer/AuthCoordinator.swift b/Projects/Presentation/Auth/Sources/AuthCoordinator/Reducer/AuthCoordinator.swift index 75416707..ded7427a 100644 --- a/Projects/Presentation/Auth/Sources/AuthCoordinator/Reducer/AuthCoordinator.swift +++ b/Projects/Presentation/Auth/Sources/AuthCoordinator/Reducer/AuthCoordinator.swift @@ -9,6 +9,7 @@ import Foundation import Core import Utill +import Entity import ComposableArchitecture import TCACoordinators @@ -19,13 +20,11 @@ public struct AuthCoordinator { @ObservableState public struct State: Equatable { - var routes: [Route] - @Shared(.inMemory("Member")) var userSignUpMember: Member = .init() public init() { - @Shared(.inMemory("UserEntity")) var userEntity: UserEntity = .shared - self.routes = [.root(.login(.init(userEntity: userEntity)), embedInNavigationView: true)] + @Shared(.inMemory("UserSession")) var userSession: UserSession = .empty + self.routes = [.root(.login(.init(userSession: userSession)), embedInNavigationView: true)] } } @@ -99,7 +98,7 @@ public struct AuthCoordinator { // MARK: - 초대코드 입력 case .routeAction(id: _, action: .login(.navigation(.presentSignUpInviteView))): - state.routes.push(.signUpInviteCode(.init(userSignUp: state.userSignUpMember))) + state.routes.push(.signUpInviteCode(.init())) return .none case .routeAction(id: _, action: .login(.navigation(.presentCoreMemberMain))): diff --git a/Projects/Presentation/Auth/Sources/Login/Reducer/Login.swift b/Projects/Presentation/Auth/Sources/Login/Reducer/Login.swift index 1496476b..59a090d7 100644 --- a/Projects/Presentation/Auth/Sources/Login/Reducer/Login.swift +++ b/Projects/Presentation/Auth/Sources/Login/Reducer/Login.swift @@ -25,22 +25,15 @@ public struct Login { var nonce: String = "" var appleAccessToken: String = "" var appleLoginFullName: ASAuthorizationAppleIDCredential? = nil - @Shared(.inMemory("Member")) var userSignUpMember: Member = .init() - var userMember: UserDTOMember? = nil - @Shared(.appStorage("UserEmail")) var userEmail: String = "" - @Shared(.appStorage("AccessToken")) var accessToken: String = "" - - @Shared var userEntity: UserEntity - var signUpModel: SignUpModel? - var checkEmailModel: CheckEmailModel? + + @Shared var userSession: UserSession var loginEntity: LoginEntity? - var profileModel: ProfileResponseModel? var currentSocialType: SocialType? public init( - userEntity: UserEntity = .init() + userSession: UserSession = .empty ) { - self._userEntity = Shared(wrappedValue: userEntity, .inMemory("UserEntity")) + self._userSession = Shared(wrappedValue: userSession, .inMemory("UserSession")) } } @@ -79,16 +72,12 @@ public struct Login { } // MARK: - NavigationAction - public enum NavigationAction: Equatable { case presentSignUpInviteView case presentCoreMemberMain case presentMemberMain } - - @Dependency(\.authUseCase) var authUseCase - @Dependency(\.signUpUseCase) var signUpUseCase - @Dependency(\.profileUseCase) var profileUseCase + @Dependency(\.appleManger) var appleLoginManger @Dependency(\.unifiedOAuthUseCase) var unifiedOAuthUseCase @Dependency(\.continuousClock) var clock @@ -161,8 +150,9 @@ public struct Login { case .login(let socialType): state.currentSocialType = socialType + state.$userSession.withLock { $0.provider = socialType } return .run { [ - useEntity = state.userEntity, + useEntity = state.userSession, appleCredential = state.appleLoginFullName, nonce = state.nonce ] send in @@ -170,7 +160,7 @@ public struct Login { with: socialType, appleCredential: appleCredential, nonce: nonce, - googleToken: useEntity.userEmail + googleToken: useEntity.token ) return await send(.inner(.loginResponse(outcome))) } @@ -190,15 +180,6 @@ public struct Login { case .success(let loginEntity): state.loginEntity = loginEntity - //TODO: 차후에 해당 로직이 이동할 예정 -// UserDefaults.standard.set(loginEntity.token.accessToken, forKey: "ACCESS_TOKEN") -// state.$accessToken.withLock {$0 = loginEntity.token.accessToken} -// state.$userEntity.withLock { -// $0.userName = loginEntity.name -// $0.accessToken = loginEntity.token.accessToken -// $0.refreshToken = loginEntity.token.refreshToken -// } - if loginEntity.isNewUser { return .send(.navigation(.presentSignUpInviteView)) } else { diff --git a/Projects/Presentation/Auth/Sources/SignUpInviteCode/Reducer/SignUpInviteCode.swift b/Projects/Presentation/Auth/Sources/SignUpInviteCode/Reducer/SignUpInviteCode.swift index 04cad40d..410e848f 100644 --- a/Projects/Presentation/Auth/Sources/SignUpInviteCode/Reducer/SignUpInviteCode.swift +++ b/Projects/Presentation/Auth/Sources/SignUpInviteCode/Reducer/SignUpInviteCode.swift @@ -28,7 +28,7 @@ public struct SignUpInviteCode { var lastInviteCode: String = "" var verifyInviteCodeModel: VerifyCodeEntity? - @Shared(.inMemory("UserEntity")) var userEntity: UserEntity = .shared + @Shared(.inMemory("UserSession")) var userSession: UserSession = .empty @Presents public var alert: AlertState? var totalInviteCode: String { @@ -43,13 +43,9 @@ public struct SignUpInviteCode { } var isNotAvailableCode: Bool = false - @Shared var userSignUp: Member + - public init( - userSignUp: Member - ) { - self._userSignUp = Shared(wrappedValue: userSignUp, .inMemory("Member")) - } + public init() { } } @CasePathable @@ -158,13 +154,7 @@ public struct SignUpInviteCode { let verifyCodeResult = await Result { try await onBoardingUseCase.verifyCode(code: code) } - .mapError { error -> SignUpError in - if let authError = error as? SignUpError { - return authError - } else { - return .unknownError(error.localizedDescription) - } - } + .mapError(SignUpError.from) return await send(.inner(.verifyInviteCodeResponse(verifyCodeResult))) } @@ -191,10 +181,11 @@ public struct SignUpInviteCode { switch result { case .success(let data): state.verifyInviteCodeModel = data - //TODO: 차후에 수정 예정 -// state.$userEntity.withLock{ $0.userRole = UserRole(rawValue: state.validateInviteCodeDTOModel?.data.inviteType ?? "") -// $0.inviteCodeId = state.validateInviteCodeDTOModel?.data.inviteCodeID ?? "" -// } + state.$userSession.withLock { + $0.userRole = state.verifyInviteCodeModel?.type ?? .member + $0.generationId = state.verifyInviteCodeModel?.generationID + $0.inviteCode = state.totalInviteCode + } return .send(.navigation(.presentSignUpName)) case .failure(let error): diff --git a/Projects/Presentation/Auth/Sources/SignUpName/Reducer/SignUpName.swift b/Projects/Presentation/Auth/Sources/SignUpName/Reducer/SignUpName.swift index 6383ea7d..bbe472fe 100644 --- a/Projects/Presentation/Auth/Sources/SignUpName/Reducer/SignUpName.swift +++ b/Projects/Presentation/Auth/Sources/SignUpName/Reducer/SignUpName.swift @@ -9,6 +9,7 @@ import Foundation import Core import Utill +import Entity import ComposableArchitecture @@ -19,12 +20,13 @@ public struct SignUpName { @ObservableState public struct State: Equatable { public init() {} - @Shared(.inMemory("UserEntity")) var userEntity: UserEntity = .shared - + + @Shared(.inMemory("UserSession")) var userSession: UserSession = .empty + var isNotAvailableName: Bool = false var enableButton: Bool { - return !userEntity.signUpName.isEmpty && !isNotAvailableName + return !userSession.name.isEmpty && !isNotAvailableName } } @@ -70,7 +72,7 @@ public struct SignUpName { ) -> Effect { switch action { case .checkIsAvailableName: - if state.userEntity.signUpName.count > 5 { + if state.userSession.name.count > 5 { state.isNotAvailableName = true } else { state.isNotAvailableName = false @@ -82,7 +84,7 @@ public struct SignUpName { } case .initSignUpName: - state.$userEntity.withLock { $0.signUpName = "" } + state.$userSession.withLock { $0.name = "" } return .none } } diff --git a/Projects/Presentation/Auth/Sources/SignUpName/View/SignUpNameView.swift b/Projects/Presentation/Auth/Sources/SignUpName/View/SignUpNameView.swift index 47e0cbf4..bd559143 100644 --- a/Projects/Presentation/Auth/Sources/SignUpName/View/SignUpNameView.swift +++ b/Projects/Presentation/Auth/Sources/SignUpName/View/SignUpNameView.swift @@ -100,7 +100,7 @@ extension SignUpNameView { TextField( "", - text: $store.userEntity.signUpName, + text: $store.userSession.name, prompt: Text("이름을 입력해주세요.") .font(.pretendardFontFamily(family: .Medium, size: 16)) .foregroundColor(.white.opacity(0.6)) // placeholder 색 @@ -108,9 +108,9 @@ extension SignUpNameView { .pretendardCustomFont(textStyle: .body2NormalMedium) // 입력 글자 스타일 .foregroundStyle(.staticWhite) // 입력 글자 색 .frame(maxWidth: .infinity) - .onChange(of: store.userEntity.signUpName) { new, _ in + .onChange(of: store.userSession.name) { new, _ in if new.count > 5 { - store.userEntity.signUpName = String(new.prefix(5)) + store.userSession.name = String(new.prefix(5)) store.isNotAvailableName = false } } @@ -122,7 +122,7 @@ extension SignUpNameView { .scaledToFit() .frame(width: 20, height: 20) .onTapGesture { - store.userEntity.signUpName = "" + store.userSession.name = "" } Spacer() diff --git a/Projects/Presentation/Auth/Sources/SignUpPart/Reducer/SignUpPart.swift b/Projects/Presentation/Auth/Sources/SignUpPart/Reducer/SignUpPart.swift index 650cbd6c..b13a2cc6 100644 --- a/Projects/Presentation/Auth/Sources/SignUpPart/Reducer/SignUpPart.swift +++ b/Projects/Presentation/Auth/Sources/SignUpPart/Reducer/SignUpPart.swift @@ -26,7 +26,7 @@ public struct SignUpPart { var selectJobs: [SelectJob]? = [] var errorMessage: String? var loading: Bool = false - @Shared(.inMemory("UserEntity")) var userEntity: UserEntity = .shared + @Shared(.inMemory("UserSession")) var userSession: UserSession = .empty } public enum Action: ViewAction, BindableAction, FeatureAction { @@ -106,15 +106,13 @@ public struct SignUpPart { if state.selectPart == selectedPart { // 동일한 파트 재선택 → 해제 state.selectPart = nil - // TODO: - 차후에 수정 -// state.$userEntity.withLock { $0.role = nil } + state.$userSession.withLock { $0.selectPart = .all } state.activeSelectPart = false return .none } state.selectPart = selectedPart - // TODO: - 차후에 수정 -// state.$userEntity.withLock { $0.role = selectedPart } + state.$userSession.withLock { $0.selectPart = selectedPart } state.activeSelectPart = true // #logDebug("selectPart", state.userEntity.role) return .none @@ -134,8 +132,8 @@ public struct SignUpPart { case .presentSelectTeam: return .none case .presentNextStep: - return .run { [isAdmin = state.userEntity.userRole] send in - if isAdmin == .moderator { + return .run { [isAdmin = state.userSession.userRole] send in + if isAdmin == .manger { await send(.navigation(.presentManaging)) } else { await send(.navigation(.presentSelectTeam)) @@ -155,13 +153,7 @@ public struct SignUpPart { let jobListResult = await Result { try await onBoardingUseCase.fetchJobs() } - .mapError { error -> SignUpError in - if let authError = error as? SignUpError { - return authError - } else { - return .unknownError(error.localizedDescription) - } - } + .mapError(SignUpError.from) return await send(.inner(.jobListResponse(jobListResult))) } .cancellable(id: CancelID.fetchJobList, cancelInFlight: true) diff --git a/Projects/Presentation/Auth/Sources/SignUpPart/View/SignUpPartView.swift b/Projects/Presentation/Auth/Sources/SignUpPart/View/SignUpPartView.swift index 96787b31..b2e230e4 100644 --- a/Projects/Presentation/Auth/Sources/SignUpPart/View/SignUpPartView.swift +++ b/Projects/Presentation/Auth/Sources/SignUpPart/View/SignUpPartView.swift @@ -89,7 +89,10 @@ extension SignUpPartView { }, id: \.jobKeys ) { item in - SelectPartItem(content: item.job.desc, isActive: item.job == store.selectPart) { + SelectPartItem( + content: item.job.desc, + isActive: item.job == store.selectPart + ) { store.send(.view(.selectPartButton(selectPart: item))) } } diff --git a/Projects/Presentation/Auth/Sources/SignUpSelectTeam/Reducer/SignUpSelectTeam.swift b/Projects/Presentation/Auth/Sources/SignUpSelectTeam/Reducer/SignUpSelectTeam.swift index 8aeab718..47393746 100644 --- a/Projects/Presentation/Auth/Sources/SignUpSelectTeam/Reducer/SignUpSelectTeam.swift +++ b/Projects/Presentation/Auth/Sources/SignUpSelectTeam/Reducer/SignUpSelectTeam.swift @@ -9,6 +9,7 @@ import Foundation import Core import Utill +import Entity import ComposableArchitecture @@ -22,7 +23,12 @@ public struct SignUpSelectTeam { var activeButton: Bool = false var editProfileDTO: ProfileResponseModel? - @Shared(.inMemory("UserEntity")) var userEntity: UserEntity = .shared + var selectTeam: SelectTeams? = .unknown + var loading: Bool = false + var teams: [SelectTeamEntity]? = [] + + + @Shared(.inMemory("UserSession")) var userSession: UserSession = .empty } public enum Action: ViewAction, BindableAction, FeatureAction { @@ -37,21 +43,20 @@ public struct SignUpSelectTeam { @CasePathable public enum View { - case selectTeamButton(selectTeam: SelectTeam) + case selectTeamButton(selectTeam: SelectTeamEntity) + case onAppear } // MARK: - AsyncAction 비동기 처리 액션 public enum AsyncAction: Equatable { - case editProfile - case editProfileManger - case editProfileMember + case getTeams } // MARK: - 앱내에서 사용하는 액션 public enum InnerAction: Equatable { - case editProfileResponse(Result) + case teamListResponse(Result<[SelectTeamEntity], SignUpError>) } // MARK: - NavigationAction @@ -65,6 +70,7 @@ public struct SignUpSelectTeam { private struct SignUpSelectTeamCancel: Hashable {} @Dependency(\.signUpUseCase) var signUpUseCase + @Dependency(\.onBoardingUseCase) var onBoardingUseCase @Dependency(\.continuousClock) var clock @Dependency(\.profileUseCase) var profileUseCase @Dependency(\.mainQueue) var mainQueue @@ -97,15 +103,25 @@ public struct SignUpSelectTeam { ) -> Effect { switch action { case .selectTeamButton(let selectTeam): - if state.userEntity.memberTeam == selectTeam { - state.$userEntity.withLock { $0.memberTeam = nil} - state.activeButton = false + let selectTeam = selectTeam.teams + + if state.selectTeam == selectTeam { + // 동일한 파트 재선택 → 해제 + state.selectTeam = nil + state.$userSession.withLock { $0.selectTeam = .unknown } + state.activeButton = false + return .none + } + + state.selectTeam = selectTeam + state.$userSession.withLock { $0.selectTeam = selectTeam } + state.activeButton = true + // #logDebug("selectPart", state.userEntity.role) return .none - } - - state.$userEntity.withLock { $0.memberTeam = selectTeam } - state.activeButton = true - return .none + + case .onAppear: + return .send(.async(.getTeams)) + } } @@ -127,72 +143,19 @@ public struct SignUpSelectTeam { action: AsyncAction ) -> Effect { switch action { - case .editProfile: - return .run { [ - userEntity = state.userEntity, - ] send in - if userEntity.userRole == .moderator { - await send(.async(.editProfileManger)) - } else { - await send(.async(.editProfileMember)) - } - } - - case .editProfileManger: - return .run { [ - userEntity = state.userEntity, - ] send in - let memberTeam = userEntity.memberTeam?.rawValue ?? "" - let isAdminRole = "\(userEntity.managing?.rawValue ?? "")" - let editProfileResult = await Result { - try await profileUseCase.editProfileManger( - name: userEntity.signUpName, - inviteCode: userEntity.inviteCodeId ?? "", - role: userEntity.role?.rawValue ?? "", - team: memberTeam, - responsibility: isAdminRole - ) - } - - switch editProfileResult { - case .success(let profileDTOData): - if let profileDTOData = profileDTOData { - await send(.inner(.editProfileResponse(.success(profileDTOData)))) - await send(.navigation(.presentCoreMember)) + case .getTeams: + state.loading = true + return .run { + [userSession = state.userSession] + send in + let teamResult = await Result { + try await onBoardingUseCase.fetchTeams(generationId: userSession.generationId ?? .zero) } - - case .failure(let error): - await send(.inner(.editProfileResponse(.failure(.encodingError("프로필업데이트 실패 : \(error.localizedDescription)"))))) - } - } - .debounce(id: SignUpSelectTeamCancel(), for: 0.3, scheduler: mainQueue) - - case .editProfileMember: - return .run { [ - userEntity = state.userEntity, - ] send in - let memberTeam = userEntity.memberTeam?.rawValue ?? "" - let editProfileResult = await Result { - try await profileUseCase.editProfileMember( - name: userEntity.signUpName, - inviteCode: userEntity.inviteCodeId ?? "", - role: userEntity.role?.rawValue ?? "", - team: memberTeam - ) - } - - switch editProfileResult { - case .success(let profileDTOData): - if let profileDTOData = profileDTOData { - await send(.inner(.editProfileResponse(.success(profileDTOData)))) - await send(.navigation(.presentMember)) - } - - case .failure(let error): - await send(.inner(.editProfileResponse(.failure(.encodingError("프로필업데이트 실패 : \(error.localizedDescription)"))))) + .mapError(SignUpError.from) + return await send(.inner(.teamListResponse(teamResult))) + } - } - .debounce(id: SignUpSelectTeamCancel(), for: 0.3, scheduler: mainQueue) + } } @@ -202,15 +165,15 @@ public struct SignUpSelectTeam { action: InnerAction ) -> Effect { switch action { - case .editProfileResponse(let result): - switch result { - case .success(let profileDT0): - state.editProfileDTO = profileDT0 - - case .failure(let error): - #logNetwork("회원가입 프로핍 변경 에러", error.localizedDescription) - } - return .none + case .teamListResponse(let result): + switch result { + case .success(let data): + state.teams = data + state.loading = false + case .failure(let error): + #logError("네트워크 에러 ", error.errorDescription ?? "알 수 없음") + } + return .none } } } diff --git a/Projects/Presentation/Auth/Sources/SignUpSelectTeam/View/SignUpSelectTeamView.swift b/Projects/Presentation/Auth/Sources/SignUpSelectTeam/View/SignUpSelectTeamView.swift index 533df555..c6a1b6e1 100644 --- a/Projects/Presentation/Auth/Sources/SignUpSelectTeam/View/SignUpSelectTeamView.swift +++ b/Projects/Presentation/Auth/Sources/SignUpSelectTeam/View/SignUpSelectTeamView.swift @@ -6,9 +6,11 @@ // import SwiftUI + import DesignSystem + import ComposableArchitecture -import Model +import SDWebImageSwiftUI public struct SignUpSelectTeamView: View { @Bindable var store: StoreOf @@ -34,13 +36,28 @@ public struct SignUpSelectTeamView: View { StepNavigationBar(activeStep: 3, buttonAction: backAction) signUpSelectTeamText() - - selectTeamList() - - signUpSelectTeamButton() + + + if store.loading { + VStack { + Spacer() + + AnimatedImage(name: "DDDLoding.gif", isAnimating: .constant(true)) + .resizable() + .scaledToFit() + .frame(width: 200, height: 200) + + Spacer() + } + } else { + selectTeamList() + + signUpSelectTeamButton() + } } .onAppear { - store.userEntity.memberTeam = nil + store.userSession.selectTeam = .unknown + store.send(.view(.onAppear)) } } } @@ -65,10 +82,10 @@ extension SignUpSelectTeamView { ScrollView { VStack { - ForEach(SelectTeam.teamList, id: \.self) { item in + ForEach(store.teams ?? [], id: \.teamId) { item in SelectTeamIteam( - content: item.selectTeamDescription, - isActive: item == store.userEntity.memberTeam) { + content: item.teams.selectTeamDescription, + isActive: item.teams == store.userSession.selectTeam) { store.send(.view(.selectTeamButton(selectTeam: item))) } } @@ -86,7 +103,7 @@ extension SignUpSelectTeamView { CustomButton( action: { - store.send(.async(.editProfile)) + }, title: "가입 완료", config: CustomButtonConfig.create(), @@ -99,4 +116,3 @@ extension SignUpSelectTeamView { .padding(.horizontal, 24) } } - From 6820b6cf0f4e63f6ee25584bbcf06e2b985d7e93 Mon Sep 17 00:00:00 2001 From: Roy Date: Thu, 1 Jan 2026 22:17:07 +0900 Subject: [PATCH 10/26] =?UTF-8?q?=F0=9F=94=A7[refactor]:=20Data=20layer=20?= =?UTF-8?q?=ED=9A=8C=EC=9B=90=EA=B0=80=EC=9E=85=20API=20=EA=B5=AC=EC=A1=B0?= =?UTF-8?q?=20=EA=B0=9C=EC=84=A0=20=EB=B0=8F=20=ED=86=B5=ED=95=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Data/API/Sources/API/Auth/AuthAPI.swift | 11 +--- .../Sources/API/Base/AttendanceDomain.swift | 28 +++++---- .../API/OnBoarding/OnBoardingAPI.swift | 10 +-- .../API/Sources/API/SignUp/SignUpAPI.swift | 17 ++--- .../OnBoarding/DTO/SelectMangerRoleDTO.swift | 31 +++++++++ .../Mapper/SelectMangerRoleDTO+.swift | 30 +++++++++ .../OnBoarding/Mapper/VerifyCodeDTO+.swift | 2 +- .../CheckEmail/DTO/CheckEmailDTOModel.swift | 20 ------ .../CheckEmail/Domain/CheckEmailModel.swift | 20 ------ .../Extension/Extension+CheckEmailModel.swift | 20 ------ .../Model/Sources/SignUp/DTO/SignUpUser.swift | 19 ++++++ .../InviteCode/DTO/InviteCodeDTOModel.swift | 28 --------- .../InviteCode/Domain/InviteModel.swift | 31 --------- .../Extension/Extension+InviteCodeModel.swift | 26 -------- .../SignUp/Mapper/SignUpUserDTO+.swift | 23 +++++++ .../OnBoarding/OnBoardingRepositoryImpl.swift | 13 +++- .../SignUp/DefaultSignUpRepositoryImpl.swift | 31 --------- .../Sources/SignUp/SignUpRepositoryImpl.swift | 43 ++++++------- .../OnBoarding/OnBoardingService.swift | 14 +++-- .../Sources/SignUp/SignUpService.swift | 63 ++++--------------- .../Sources/SignUp/SignUpUserRequestDTO.swift | 37 +++++++++++ 21 files changed, 220 insertions(+), 297 deletions(-) create mode 100644 Projects/Data/Model/Sources/OnBoarding/DTO/SelectMangerRoleDTO.swift create mode 100644 Projects/Data/Model/Sources/OnBoarding/Mapper/SelectMangerRoleDTO+.swift delete mode 100644 Projects/Data/Model/Sources/SignUp/CheckEmail/DTO/CheckEmailDTOModel.swift delete mode 100644 Projects/Data/Model/Sources/SignUp/CheckEmail/Domain/CheckEmailModel.swift delete mode 100644 Projects/Data/Model/Sources/SignUp/CheckEmail/Extension/Extension+CheckEmailModel.swift create mode 100644 Projects/Data/Model/Sources/SignUp/DTO/SignUpUser.swift delete mode 100644 Projects/Data/Model/Sources/SignUp/InviteCode/DTO/InviteCodeDTOModel.swift delete mode 100644 Projects/Data/Model/Sources/SignUp/InviteCode/Domain/InviteModel.swift delete mode 100644 Projects/Data/Model/Sources/SignUp/InviteCode/Extension/Extension+InviteCodeModel.swift create mode 100644 Projects/Data/Model/Sources/SignUp/Mapper/SignUpUserDTO+.swift delete mode 100644 Projects/Data/Repository/Sources/SignUp/DefaultSignUpRepositoryImpl.swift create mode 100644 Projects/Data/Service/Sources/SignUp/SignUpUserRequestDTO.swift diff --git a/Projects/Data/API/Sources/API/Auth/AuthAPI.swift b/Projects/Data/API/Sources/API/Auth/AuthAPI.swift index 43df37bd..94d15512 100644 --- a/Projects/Data/API/Sources/API/Auth/AuthAPI.swift +++ b/Projects/Data/API/Sources/API/Auth/AuthAPI.swift @@ -9,16 +9,11 @@ import Foundation public enum AuthAPI: String, CaseIterable { case login - case sessionToJwt - - + public var authDescription: String { switch self { - case .login: - return "login" - - case .sessionToJwt: - return "session-to-jwt/" + case .login: + return "login" } } } diff --git a/Projects/Data/API/Sources/API/Base/AttendanceDomain.swift b/Projects/Data/API/Sources/API/Base/AttendanceDomain.swift index 25b3f9a9..25a0364e 100644 --- a/Projects/Data/API/Sources/API/Base/AttendanceDomain.swift +++ b/Projects/Data/API/Sources/API/Base/AttendanceDomain.swift @@ -12,6 +12,7 @@ import AsyncMoya public enum AttendanceDomain { case auth case onboarding + case user case invite case profile case qr @@ -26,21 +27,22 @@ extension AttendanceDomain: DomainType { public var url: String { switch self { - case .auth: - return "api/auth/" - + case .auth: + return "api/auth/" case .onboarding: return "api/onboarding/" - case .invite: - return "api/v1/invites/" - case .profile: - return "api/v1/profiles/" - case .qr: - return "api/v1/qrcodes/" - case .schedule: - return "api/v1/schedules/" - case .attendance: - return "api/v1/attendances/" + case .user: + return "api/users" + case .invite: + return "api/v1/invites/" + case .profile: + return "api/v1/profiles/" + case .qr: + return "api/v1/qrcodes/" + case .schedule: + return "api/v1/schedules/" + case .attendance: + return "api/v1/attendances/" } } } diff --git a/Projects/Data/API/Sources/API/OnBoarding/OnBoardingAPI.swift b/Projects/Data/API/Sources/API/OnBoarding/OnBoardingAPI.swift index 4a32fb25..99933482 100644 --- a/Projects/Data/API/Sources/API/OnBoarding/OnBoardingAPI.swift +++ b/Projects/Data/API/Sources/API/OnBoarding/OnBoardingAPI.swift @@ -12,20 +12,20 @@ public enum OnBoardingAPI: String, CaseIterable { case teams case jobs case mangerRole - + public var description: String { switch self { case .verifyCode: return "verify-code" - + case .teams: return "teams" - + case .jobs: return "jobs" - + case .mangerRole: - return "manager-roles" + return "manager-roles" } } } diff --git a/Projects/Data/API/Sources/API/SignUp/SignUpAPI.swift b/Projects/Data/API/Sources/API/SignUp/SignUpAPI.swift index bb53d95d..ca4e1b20 100644 --- a/Projects/Data/API/Sources/API/SignUp/SignUpAPI.swift +++ b/Projects/Data/API/Sources/API/SignUp/SignUpAPI.swift @@ -8,21 +8,12 @@ import Foundation public enum SignUpAPI: String , CaseIterable { - case registerAccount - case verifyInviteCode - case checkEmail - - + case signUpUser + public var signUpDescription: String { switch self { - case .registerAccount: - return "registration/" - - case .verifyInviteCode: - return "validate/" - - case .checkEmail: - return "check-email/" + case .signUpUser: + return "" } } diff --git a/Projects/Data/Model/Sources/OnBoarding/DTO/SelectMangerRoleDTO.swift b/Projects/Data/Model/Sources/OnBoarding/DTO/SelectMangerRoleDTO.swift new file mode 100644 index 00000000..4efc6f20 --- /dev/null +++ b/Projects/Data/Model/Sources/OnBoarding/DTO/SelectMangerRoleDTO.swift @@ -0,0 +1,31 @@ +// +// SelectMangerRoleDTO.swift +// Model +// +// Created by Wonji Suh on 1/1/26. +// + +import Foundation + +public struct SelectMangerRoleDTO: Decodable { + public let data: [SelectMangerRoleDTOReponse] + + public init(from decoder: Decoder) throws { + let container = try decoder.singleValueContainer() + if let array = try? container.decode([SelectMangerRoleDTOReponse].self) { + self.data = array + return + } + + let keyed = try decoder.container(keyedBy: CodingKeys.self) + self.data = try keyed.decode([SelectMangerRoleDTOReponse].self, forKey: .data) + } + + private enum CodingKeys: String, CodingKey { + case data + } +} + +public struct SelectMangerRoleDTOReponse: Decodable { + let key, description: String +} diff --git a/Projects/Data/Model/Sources/OnBoarding/Mapper/SelectMangerRoleDTO+.swift b/Projects/Data/Model/Sources/OnBoarding/Mapper/SelectMangerRoleDTO+.swift new file mode 100644 index 00000000..cb881dbc --- /dev/null +++ b/Projects/Data/Model/Sources/OnBoarding/Mapper/SelectMangerRoleDTO+.swift @@ -0,0 +1,30 @@ +// +// SelectMangerRoleDTO+.swift +// Model +// +// Created by Wonji Suh on 1/1/26. +// + +import Foundation +import Entity + +public extension SelectMangerRoleDTOReponse { + func toDomain() -> SelectManaging { + return SelectManaging( + managingKeys: self.description, + managing: StaffManaging(rawValue: self.key) ?? .attendanceCheck + ) + } +} + +public extension Array where Element == SelectMangerRoleDTOReponse { + func toDomain() -> [SelectManaging] { + return self.map { $0.toDomain() } + } +} + +public extension SelectMangerRoleDTO { + func toDomain() -> [SelectManaging] { + return self.data.toDomain() + } +} diff --git a/Projects/Data/Model/Sources/OnBoarding/Mapper/VerifyCodeDTO+.swift b/Projects/Data/Model/Sources/OnBoarding/Mapper/VerifyCodeDTO+.swift index 8cd5d883..8af04a22 100644 --- a/Projects/Data/Model/Sources/OnBoarding/Mapper/VerifyCodeDTO+.swift +++ b/Projects/Data/Model/Sources/OnBoarding/Mapper/VerifyCodeDTO+.swift @@ -13,7 +13,7 @@ public extension VerifyCodeDTO { func toDomain() -> VerifyCodeEntity { return VerifyCodeEntity( generationID: self.generationID, - type: Staff(rawValue: self.type) ?? .member + type: Staff.from(apiKey: self.type) ?? .member ) } } diff --git a/Projects/Data/Model/Sources/SignUp/CheckEmail/DTO/CheckEmailDTOModel.swift b/Projects/Data/Model/Sources/SignUp/CheckEmail/DTO/CheckEmailDTOModel.swift deleted file mode 100644 index 40b5c648..00000000 --- a/Projects/Data/Model/Sources/SignUp/CheckEmail/DTO/CheckEmailDTOModel.swift +++ /dev/null @@ -1,20 +0,0 @@ -// -// CheckEmailDTOModel.swift -// Model -// -// Created by Wonji Suh on 5/9/25. -// - -import Foundation - - -public typealias CheckEmailDTOModel = BaseResponse - -// MARK: - Welcome -public struct CheckEmailDTOResponseModel: Decodable { - let emailUsed: Bool? - - enum CodingKeys: String, CodingKey { - case emailUsed = "email_used" - } -} diff --git a/Projects/Data/Model/Sources/SignUp/CheckEmail/Domain/CheckEmailModel.swift b/Projects/Data/Model/Sources/SignUp/CheckEmail/Domain/CheckEmailModel.swift deleted file mode 100644 index a5d620f6..00000000 --- a/Projects/Data/Model/Sources/SignUp/CheckEmail/Domain/CheckEmailModel.swift +++ /dev/null @@ -1,20 +0,0 @@ -// -// CheckEmailModel.swift -// Model -// -// Created by Wonji Suh on 5/9/25. -// - -import Foundation - -public typealias CheckEmailModel = BaseResponseDTO - -public struct CheckEmailResponseModel: Decodable, Equatable { - public let emailUsed: Bool? - - public init( - emailUsed: Bool? - ) { - self.emailUsed = emailUsed - } -} diff --git a/Projects/Data/Model/Sources/SignUp/CheckEmail/Extension/Extension+CheckEmailModel.swift b/Projects/Data/Model/Sources/SignUp/CheckEmail/Extension/Extension+CheckEmailModel.swift deleted file mode 100644 index e2cf1bc2..00000000 --- a/Projects/Data/Model/Sources/SignUp/CheckEmail/Extension/Extension+CheckEmailModel.swift +++ /dev/null @@ -1,20 +0,0 @@ -// -// Extension+CheckEmailModel.swift -// Model -// -// Created by Wonji Suh on 5/9/25. -// - -import Foundation - -public extension CheckEmailDTOModel { - func toDomain() -> CheckEmailModel { - let data = CheckEmailResponseModel(emailUsed: self.data?.emailUsed ?? false) - - return CheckEmailModel( - code: self.code ?? .zero, - message: self.message ?? "", - data: data - ) - } -} diff --git a/Projects/Data/Model/Sources/SignUp/DTO/SignUpUser.swift b/Projects/Data/Model/Sources/SignUp/DTO/SignUpUser.swift new file mode 100644 index 00000000..e674e0b4 --- /dev/null +++ b/Projects/Data/Model/Sources/SignUp/DTO/SignUpUser.swift @@ -0,0 +1,19 @@ +// +// SignUpUser.swift +// Model +// +// Created by Wonji Suh on 1/1/26. +// +import Foundation + +public struct SignUpUserDTO: Decodable { + let userID: Int + let name, email, generation, team: String + let jobRole: String + let managerRoles: [String] + + enum CodingKeys: String, CodingKey { + case userID = "userId" + case name, email, generation, team, jobRole, managerRoles + } +} diff --git a/Projects/Data/Model/Sources/SignUp/InviteCode/DTO/InviteCodeDTOModel.swift b/Projects/Data/Model/Sources/SignUp/InviteCode/DTO/InviteCodeDTOModel.swift deleted file mode 100644 index 34032b8e..00000000 --- a/Projects/Data/Model/Sources/SignUp/InviteCode/DTO/InviteCodeDTOModel.swift +++ /dev/null @@ -1,28 +0,0 @@ -// -// InviteCodeDTOModel.swift -// Model -// -// Created by Wonji Suh on 5/8/25. -// - -import Foundation - -public typealias InviteCodeDTOModel = BaseResponse - -// MARK: - DataClass -public struct InviteCodeResponseDTOModel: Decodable { - let valid: Bool? - let inviteCodeID, inviteType: String? - let expireTime: String? - let oneTimeUse: Bool? - let error: String? - - enum CodingKeys: String, CodingKey { - case valid - case inviteCodeID = "invite_code_id" - case inviteType = "invite_type" - case expireTime = "expire_time" - case oneTimeUse = "one_time_use" - case error - } -} diff --git a/Projects/Data/Model/Sources/SignUp/InviteCode/Domain/InviteModel.swift b/Projects/Data/Model/Sources/SignUp/InviteCode/Domain/InviteModel.swift deleted file mode 100644 index 4b6df86f..00000000 --- a/Projects/Data/Model/Sources/SignUp/InviteCode/Domain/InviteModel.swift +++ /dev/null @@ -1,31 +0,0 @@ -// -// InviteModel.swift -// Model -// -// Created by Wonji Suh on 5/8/25. -// - -import Foundation - -public typealias InviteCodeModel = BaseResponseDTO - -public struct InviteCodeResponseModel: Decodable, Equatable { - public let valid: Bool - public let inviteCodeID, inviteType: String - public let oneTimeUse: Bool - public let errorMessage: String? - - public init( - valid: Bool, - inviteCodeID: String, - inviteType: String, - oneTimeUse: Bool, - errorMessage: String? = nil - ) { - self.valid = valid - self.inviteCodeID = inviteCodeID - self.inviteType = inviteType - self.oneTimeUse = oneTimeUse - self.errorMessage = errorMessage - } -} diff --git a/Projects/Data/Model/Sources/SignUp/InviteCode/Extension/Extension+InviteCodeModel.swift b/Projects/Data/Model/Sources/SignUp/InviteCode/Extension/Extension+InviteCodeModel.swift deleted file mode 100644 index 55d9d8f3..00000000 --- a/Projects/Data/Model/Sources/SignUp/InviteCode/Extension/Extension+InviteCodeModel.swift +++ /dev/null @@ -1,26 +0,0 @@ -// -// Extension+InviteCodeModel.swift -// Model -// -// Created by Wonji Suh on 5/8/25. -// - -import Foundation - -public extension InviteCodeDTOModel { - func toDomain() -> InviteCodeModel{ - let data = InviteCodeResponseModel( - valid: self.data?.valid ?? false, - inviteCodeID: self.data?.inviteCodeID ?? "", - inviteType: self.data?.inviteType ?? "", - oneTimeUse: self.data?.oneTimeUse ?? false, - errorMessage: self.data?.error ?? "" - ) - - return InviteCodeModel( - code: self.code ?? .zero, - message: self.message ?? "", - data: data - ) - } -} diff --git a/Projects/Data/Model/Sources/SignUp/Mapper/SignUpUserDTO+.swift b/Projects/Data/Model/Sources/SignUp/Mapper/SignUpUserDTO+.swift new file mode 100644 index 00000000..05c0fc0a --- /dev/null +++ b/Projects/Data/Model/Sources/SignUp/Mapper/SignUpUserDTO+.swift @@ -0,0 +1,23 @@ +// +// SignUpUserDTO+.swift +// Model +// +// Created by Wonji Suh on 1/1/26. +// + +import Foundation + +import Entity + +public extension SignUpUserDTO { + func toDomain() -> SignUpUser { + return SignUpUser( + name: self.name, + email: self.email, + generation: self.generation, + team: SelectTeams.from(name: self.team), + managing: self.managerRoles.compactMap { StaffManaging.from(apiKey: $0) }, + selectPart: SelectParts.from(apiKey: self.jobRole) ?? .all + ) + } +} diff --git a/Projects/Data/Repository/Sources/OnBoarding/OnBoardingRepositoryImpl.swift b/Projects/Data/Repository/Sources/OnBoarding/OnBoardingRepositoryImpl.swift index 21d76846..44d6fdd6 100644 --- a/Projects/Data/Repository/Sources/OnBoarding/OnBoardingRepositoryImpl.swift +++ b/Projects/Data/Repository/Sources/OnBoarding/OnBoardingRepositoryImpl.swift @@ -23,6 +23,7 @@ final public class OnBoardingRepositoryImpl: OnBoardingInterface { self.provider = provider } + // MARK: - 코드 검증 public func verifyCode( code: String ) async throws -> VerifyCodeEntity { @@ -30,14 +31,24 @@ final public class OnBoardingRepositoryImpl: OnBoardingInterface { return dto.toDomain() } + // MARK: - 직군 선택 public func fetchJobs() async throws -> [Entity.SelectJob] { let dtoArray: SelectJobsDTO = try await provider.request(.jobs) return dtoArray.data.toDomain() } - public func fetchTeams(generationId: Int) async throws -> [SelectTeamEntity] { + // MARK: - 팀 선택 + public func fetchTeams( + generationId: Int + ) async throws -> [SelectTeamEntity] { let dto : SelectTeamsDTO = try await provider.request(.teams(generationId: generationId)) return dto.data.toDomain() } + // MARK: - 매니저 역활 선택 + public func fetchManaging() async throws -> [SelectManaging] { + let dto: SelectMangerRoleDTO = try await provider.request(.mangerRole) + return dto.data.toDomain() + } + } diff --git a/Projects/Data/Repository/Sources/SignUp/DefaultSignUpRepositoryImpl.swift b/Projects/Data/Repository/Sources/SignUp/DefaultSignUpRepositoryImpl.swift deleted file mode 100644 index c4b65661..00000000 --- a/Projects/Data/Repository/Sources/SignUp/DefaultSignUpRepositoryImpl.swift +++ /dev/null @@ -1,31 +0,0 @@ -// -// DefaultSignUpRepositoryImpl.swift -// Repository -// -// Created by Wonji Suh on 7/23/25. -// - -import DomainInterface -import Model - -final public class DefaultSignUpRepositoryImpl: SignUpInterface { - public init() {} - - public func registerAccount( - email: String, - password: String - ) async throws -> SignUpModel? { - return nil - } - - public func validateInviteCode( - inviteCode: String - ) async throws -> InviteCodeModel? { - return nil - } - - public func checkEmail(email: String) async throws -> CheckEmailModel? { - return nil - } -} - diff --git a/Projects/Data/Repository/Sources/SignUp/SignUpRepositoryImpl.swift b/Projects/Data/Repository/Sources/SignUp/SignUpRepositoryImpl.swift index 4821cabd..2127c9ee 100644 --- a/Projects/Data/Repository/Sources/SignUp/SignUpRepositoryImpl.swift +++ b/Projects/Data/Repository/Sources/SignUp/SignUpRepositoryImpl.swift @@ -9,6 +9,7 @@ import Combine import DomainInterface import Model +import Entity import Service @preconcurrency import AsyncMoya @@ -26,30 +27,22 @@ final public class SignUpRepositoryImpl: SignUpInterface { } // Mark : - API 회원가입 - public func registerAccount( - email: String, - password: String - ) async throws -> SignUpModel? { - let signUpModel: SignUpDTOModel = try await provider.request( - .registerAccount( - email: email, - password1: password, - password2: password)) - return signUpModel.toDomain() - } - - // Mark : - 초대 코드 검증 - public func validateInviteCode( - inviteCode: String - ) async throws -> InviteCodeModel? { - let model: InviteCodeDTOModel = try await provider.request( - .verifyInviteCode(inviteCode: inviteCode)) - return model.toDomain() - } - - // MARK: - 이메일 검증 - public func checkEmail(email: String) async throws -> CheckEmailModel? { - let model: CheckEmailDTOModel = try await provider.request(.checkEmail(email: email)) - return model.toDomain() + public func registerUser( + input: SignUpUserInput + ) async throws -> SignUpUser { + let body = SignUpUserRequestDTO( + name: input.name, + generationId: input.generationId, + jobRole: input.jobRole.apiKey, + teamId: input.teamId, + managerRoles: input.managerRoles?.reduce(into: [String: String]()) { result, role in + result[role.apiKey] = role.desc + }, + provider: input.provider.description, + token: input.token, + invitationCode: input.invitationCode + ) + let dto: SignUpUserDTO = try await provider.request(.signUpUser(body: body)) + return dto.toDomain() } } diff --git a/Projects/Data/Service/Sources/OnBoarding/OnBoardingService.swift b/Projects/Data/Service/Sources/OnBoarding/OnBoardingService.swift index d979a964..a9b575e8 100644 --- a/Projects/Data/Service/Sources/OnBoarding/OnBoardingService.swift +++ b/Projects/Data/Service/Sources/OnBoarding/OnBoardingService.swift @@ -15,6 +15,7 @@ public enum OnBoardingService { case verifyCode(code : String) case jobs case teams(generationId: Int) + case mangerRole } @@ -35,29 +36,32 @@ extension OnBoardingService: BaseTargetType { case .teams: return OnBoardingAPI.teams.description + + case .mangerRole: + return OnBoardingAPI.mangerRole.description } } - + public var error: [Int : AsyncMoya.NetworkError]? { return nil } - + public var parameters: [String : Any]? { switch self { case .verifyCode(let code): return code.toDictionary(key: "code") - case .jobs: + case .jobs, .mangerRole: return nil case .teams(let generationId): return generationId.toDictionary(key: "generationId") } } - + public var method: Moya.Method { switch self { - case .verifyCode, .jobs, .teams: + case .verifyCode, .jobs, .teams, .mangerRole: return .get } } diff --git a/Projects/Data/Service/Sources/SignUp/SignUpService.swift b/Projects/Data/Service/Sources/SignUp/SignUpService.swift index 59b33d67..3a8d3de2 100644 --- a/Projects/Data/Service/Sources/SignUp/SignUpService.swift +++ b/Projects/Data/Service/Sources/SignUp/SignUpService.swift @@ -14,80 +14,43 @@ import AsyncMoya public enum SignUpService { // Mark : - 회원가입 - case registerAccount( - email: String, - password1: String, - password2: String - ) - case verifyInviteCode(inviteCode: String) - case checkEmail(email: String) + case signUpUser(body: SignUpUserRequestDTO) } extension SignUpService: BaseTargetType { public typealias Domain = AttendanceDomain - + public var domain: AttendanceDomain { switch self { - case .registerAccount, .checkEmail: - return .auth - - case .verifyInviteCode: - return .invite - + case .signUpUser: + return .user } } public var urlPath: String { switch self { - case .registerAccount: - return SignUpAPI.registerAccount.signUpDescription - - case .verifyInviteCode: - return SignUpAPI.verifyInviteCode.signUpDescription - - case .checkEmail: - return SignUpAPI.checkEmail.signUpDescription + case .signUpUser: + return SignUpAPI.signUpUser.signUpDescription + } } public var method: Moya.Method { switch self { - case .registerAccount, .verifyInviteCode, .checkEmail: - return .post - + case .signUpUser: + return .post + } } public var error: [Int : AsyncMoya.NetworkError]? { return nil } - + public var parameters: [String : Any]? { switch self { - case .registerAccount( - let email, - let password1, - let password2 - ): - let parameters: [String: Any] = [ - "email" : email, - "password1" : password1, - "password2" : password2 - ] - return parameters - - case .verifyInviteCode(let inviteCode): - let parameters: [String: Any] = [ - "invite_code" : inviteCode - ] - return parameters - - case .checkEmail(let email): - let parameters: [String: Any] = [ - "email": email - ] - return parameters + case .signUpUser(let body): + return body.toDictionary } } } - diff --git a/Projects/Data/Service/Sources/SignUp/SignUpUserRequestDTO.swift b/Projects/Data/Service/Sources/SignUp/SignUpUserRequestDTO.swift new file mode 100644 index 00000000..726be5e6 --- /dev/null +++ b/Projects/Data/Service/Sources/SignUp/SignUpUserRequestDTO.swift @@ -0,0 +1,37 @@ +// +// SignUpUserRequestDTO.swift +// Service +// +// Created by Wonji Suh on 1/1/26. +// + +public struct SignUpUserRequestDTO: Encodable { + public let name: String + public let generationId: Int + public let jobRole: String + public let teamId: Int? + public let managerRoles: [String: String]? + public let provider: String + public let token: String + public let invitationCode: String + + public init( + name: String, + generationId: Int, + jobRole: String, + teamId: Int?, + managerRoles: [String : String]?, + provider: String, + token: String, + invitationCode: String + ) { + self.name = name + self.generationId = generationId + self.jobRole = jobRole + self.teamId = teamId + self.managerRoles = managerRoles + self.provider = provider + self.token = token + self.invitationCode = invitationCode + } +} From 8af6e6c2608f741c04f23d8626b3f622385feda2 Mon Sep 17 00:00:00 2001 From: Roy Date: Thu, 1 Jan 2026 22:17:26 +0900 Subject: [PATCH 11/26] =?UTF-8?q?=F0=9F=94=A7[refactor]:=20=EC=98=A8?= =?UTF-8?q?=EB=B3=B4=EB=94=A9=20=EB=8F=84=EB=A9=94=EC=9D=B8=20=EC=9D=B8?= =?UTF-8?q?=ED=84=B0=ED=8E=98=EC=9D=B4=EC=8A=A4=20=EC=A0=95=EB=A6=AC=20?= =?UTF-8?q?=EB=B0=8F=20=EA=B4=80=EB=A6=AC=20=EC=97=AD=ED=95=A0=20=EA=B8=B0?= =?UTF-8?q?=EB=8A=A5=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../DefaultOnBoardingRepositoryImpl.swift | 48 ++- .../OnBoarding/OnBoardingInterface.swift | 1 + .../SignUp/DefaultSignUpRepositoryImpl.swift | 150 ++-------- .../Sources/SignUp/MockSignUpRepository.swift | 276 ------------------ .../Sources/SignUp/SignUpInterface.swift | 10 +- 5 files changed, 68 insertions(+), 417 deletions(-) delete mode 100644 Projects/Domain/DomainInterface/Sources/SignUp/MockSignUpRepository.swift diff --git a/Projects/Domain/DomainInterface/Sources/OnBoarding/DefaultOnBoardingRepositoryImpl.swift b/Projects/Domain/DomainInterface/Sources/OnBoarding/DefaultOnBoardingRepositoryImpl.swift index 482d6a18..b478b812 100644 --- a/Projects/Domain/DomainInterface/Sources/OnBoarding/DefaultOnBoardingRepositoryImpl.swift +++ b/Projects/Domain/DomainInterface/Sources/OnBoarding/DefaultOnBoardingRepositoryImpl.swift @@ -64,7 +64,7 @@ public final class DefaultOnBoardingRepositoryImpl: OnBoardingInterface, @unchec var staffType: Staff { switch self { case .managerRole: - return .manger + return .manager default: return .member } @@ -101,6 +101,8 @@ public final class DefaultOnBoardingRepositoryImpl: OnBoardingInterface, @unchec private var lastFetchJobsCall: Date? private var fetchTeamsCallCount = 0 private var lastFetchTeamsCall: Date? + private var fetchManagingCallCount = 0 + private var lastFetchManagingCall: Date? // MARK: - Initialization @@ -118,6 +120,8 @@ public final class DefaultOnBoardingRepositoryImpl: OnBoardingInterface, @unchec lastFetchJobsCall = nil fetchTeamsCallCount = 0 lastFetchTeamsCall = nil + fetchManagingCallCount = 0 + lastFetchManagingCall = nil } public func getVerifyCallCount() -> Int { @@ -144,6 +148,14 @@ public final class DefaultOnBoardingRepositoryImpl: OnBoardingInterface, @unchec return lastFetchTeamsCall } + public func getFetchManagingCallCount() -> Int { + return fetchManagingCallCount + } + + public func getLastFetchManagingCall() -> Date? { + return lastFetchManagingCall + } + public func reset() { configuration = .success verifyCallCount = 0 @@ -152,6 +164,8 @@ public final class DefaultOnBoardingRepositoryImpl: OnBoardingInterface, @unchec lastFetchJobsCall = nil fetchTeamsCallCount = 0 lastFetchTeamsCall = nil + fetchManagingCallCount = 0 + lastFetchManagingCall = nil } // MARK: - OnBoardingInterface Implementation @@ -187,7 +201,7 @@ public final class DefaultOnBoardingRepositoryImpl: OnBoardingInterface, @unchec case "5678", "admin", "manager": return VerifyCodeEntity( generationID: 2024, - type: .manger + type: .manager ) case "error", "fail": @@ -290,6 +304,36 @@ public final class DefaultOnBoardingRepositoryImpl: OnBoardingInterface, @unchec ) } } + + public func fetchManaging() async throws -> [SelectManaging] { + // Track call + fetchManagingCallCount += 1 + lastFetchManagingCall = Date() + + // Apply delay + if configuration.delay > 0 { + try await Task.sleep(for: .seconds(configuration.delay)) + } + + // Configuration 기반 응답 처리 + if !configuration.shouldSucceed, let error = configuration.error { + throw error + } + + let mockManaging = StaffManaging.allCases.map { managing in + SelectManaging( + managingKeys: managing.apiKey, + managing: managing + ) + } + + switch configuration { + case .memberRole: + return [] + default: + return mockManaging + } + } } // MARK: - Convenience Static Methods diff --git a/Projects/Domain/DomainInterface/Sources/OnBoarding/OnBoardingInterface.swift b/Projects/Domain/DomainInterface/Sources/OnBoarding/OnBoardingInterface.swift index adfe0a39..bd7a8a03 100644 --- a/Projects/Domain/DomainInterface/Sources/OnBoarding/OnBoardingInterface.swift +++ b/Projects/Domain/DomainInterface/Sources/OnBoarding/OnBoardingInterface.swift @@ -15,6 +15,7 @@ public protocol OnBoardingInterface: Sendable { func verifyCode(code: String) async throws -> VerifyCodeEntity func fetchJobs() async throws -> [SelectJob] func fetchTeams(generationId: Int) async throws -> [SelectTeamEntity] + func fetchManaging() async throws -> [SelectManaging] } public struct OnBoardingRepositoryDependency: DependencyKey { diff --git a/Projects/Domain/DomainInterface/Sources/SignUp/DefaultSignUpRepositoryImpl.swift b/Projects/Domain/DomainInterface/Sources/SignUp/DefaultSignUpRepositoryImpl.swift index 6dc631f3..63e8693c 100644 --- a/Projects/Domain/DomainInterface/Sources/SignUp/DefaultSignUpRepositoryImpl.swift +++ b/Projects/Domain/DomainInterface/Sources/SignUp/DefaultSignUpRepositoryImpl.swift @@ -7,7 +7,6 @@ // import Foundation -import Model import Entity /// SignUp Repository의 기본 구현체 (테스트/프리뷰용) @@ -97,10 +96,9 @@ final public class DefaultSignUpRepositoryImpl: SignUpInterface, @unchecked Send // MARK: - SignUpInterface Implementation - public func registerAccount( - email: String, - password: String - ) async throws -> SignUpModel? { + public func registerUser( + input: SignUpUserInput + ) async throws -> SignUpUser { // Track call registerCallCount += 1 lastCall = Date() @@ -111,30 +109,16 @@ final public class DefaultSignUpRepositoryImpl: SignUpInterface, @unchecked Send } // 입력 값 검증 - guard !email.isEmpty else { - throw SignUpError.missingRequiredField("이메일") + guard !input.name.isEmpty else { + throw SignUpError.missingRequiredField("이름") } - guard !password.isEmpty else { - throw SignUpError.missingRequiredField("비밀번호") + guard !input.token.isEmpty else { + throw SignUpError.missingRequiredField("토큰") } - // 특정 패턴 검사 (간소화) - if email == "invalid@" || !email.contains("@") { - throw SignUpError.missingRequiredField("유효한 이메일") - } - - if email == "duplicate@example.com" { - throw SignUpError.accountAlreadyExists - } - - // 비밀번호 검증 - if password.count < 8 { - throw SignUpError.missingRequiredField("8자 이상의 비밀번호") - } - - if password == "weak" || password == "123456" { - throw SignUpError.missingRequiredField("강력한 비밀번호") + if input.generationId <= 0 { + throw SignUpError.missingRequiredField("기수") } // Configuration 기반 응답 처리 @@ -143,113 +127,13 @@ final public class DefaultSignUpRepositoryImpl: SignUpInterface, @unchecked Send } // Success case - let mockUser = SignUPUser( - username: email.components(separatedBy: "@").first ?? "User", - email: email - ) - - let mockResponse = SignUpResponseModel( - accessToken: "mock_access_token_\(UUID().uuidString)", - refreshToken: "mock_refresh_token_\(UUID().uuidString)", - user: mockUser - ) - - return BaseResponseDTO( - code: 200, - message: "회원가입이 성공적으로 완료되었습니다", - data: mockResponse - ) - } - - public func validateInviteCode( - inviteCode: String - ) async throws -> InviteCodeModel? { - // Track call - validateCallCount += 1 - lastCall = Date() - - // Apply delay - if configuration.delay > 0 { - try await Task.sleep(for: .seconds(configuration.delay)) - } - - // 입력 값 검증 - guard !inviteCode.isEmpty else { - throw SignUpError.missingRequiredField("초대 코드") - } - - // 특정 코드별 처리 - switch inviteCode.lowercased() { - case "invalid", "wrong", "used", "format", "unauthorized", "forbidden", "team", "revoked": - throw SignUpError.invalidInviteCode - case "expired": - throw SignUpError.expiredInviteCode - case "error": - throw SignUpError.networkError - default: - break - } - - // Configuration 기반 응답 처리 - if !configuration.shouldSucceed, let error = configuration.signUpError { - throw error - } - - // Success case - let mockResponse = InviteCodeResponseModel( - valid: true, - inviteCodeID: inviteCode, - inviteType: "TEAM_INVITE", - oneTimeUse: true, - errorMessage: nil - ) - - return BaseResponseDTO( - code: 200, - message: "유효한 초대 코드입니다", - data: mockResponse - ) - } - - public func checkEmail( - email: String - ) async throws -> CheckEmailModel? { - // Track call - checkEmailCallCount += 1 - lastCall = Date() - - // Apply delay - if configuration.delay > 0 { - try await Task.sleep(for: .seconds(configuration.delay)) - } - - // 입력 값 검증 - guard !email.isEmpty else { - throw SignUpError.missingRequiredField("이메일") - } - - // 이메일 형식 검증 - if !email.contains("@") || !email.contains(".") { - throw SignUpError.missingRequiredField("유효한 이메일") - } - - // 특정 이메일별 처리 - let isUsed = email == "duplicate@example.com" || - email == "used@example.com" || - email == "admin@example.com" - - // Configuration 기반 응답 처리 - if !configuration.shouldSucceed, let error = configuration.signUpError { - throw error - } - - // Success case - let mockResponse = CheckEmailResponseModel(emailUsed: isUsed) - - return BaseResponseDTO( - code: 200, - message: isUsed ? "이미 사용 중인 이메일입니다" : "사용 가능한 이메일입니다", - data: mockResponse + return SignUpUser( + name: input.name, + email: "", + generation: String(input.generationId), + team: input.teamId == nil ? nil : .unknown, + managing: input.managerRoles, + selectPart: input.jobRole ) } } @@ -287,4 +171,4 @@ public extension DefaultSignUpRepositoryImpl { static func withDelay(_ delay: TimeInterval) -> DefaultSignUpRepositoryImpl { return DefaultSignUpRepositoryImpl(configuration: .customDelay(delay)) } -} \ No newline at end of file +} diff --git a/Projects/Domain/DomainInterface/Sources/SignUp/MockSignUpRepository.swift b/Projects/Domain/DomainInterface/Sources/SignUp/MockSignUpRepository.swift deleted file mode 100644 index 9f1e37cd..00000000 --- a/Projects/Domain/DomainInterface/Sources/SignUp/MockSignUpRepository.swift +++ /dev/null @@ -1,276 +0,0 @@ -// -// MockSignUpRepository.swift -// DomainInterface -// -// Created by Wonji Suh on 12/30/25. -// - -import Foundation -import Model -import Entity - -public actor MockSignUpRepository: SignUpInterface { - - // MARK: - Configuration - public enum Configuration { - case success - case failure - case invalidInviteCode - case expiredInviteCode - case networkError - case serverError - case emailCheckedValid - case emailCheckedUsed - case customDelay(TimeInterval) - - var shouldSucceed: Bool { - switch self { - case .success, .emailCheckedValid, .emailCheckedUsed, .customDelay: - return true - case .failure, .invalidInviteCode, .expiredInviteCode, - .networkError, .serverError: - return false - } - } - - var delay: TimeInterval { - switch self { - case .customDelay(let delay): - return delay - default: - return 0.1 // Fast for testing - } - } - - var isEmailUsed: Bool { - switch self { - case .emailCheckedUsed: - return true - default: - return false - } - } - - var signUpError: SignUpError? { - switch self { - case .success, .emailCheckedValid, .emailCheckedUsed, .customDelay: - return nil - case .failure: - return .accountCreationFailed - case .invalidInviteCode: - return .invalidInviteCode - case .expiredInviteCode: - return .expiredInviteCode - case .networkError: - return .networkError - case .serverError: - return .serverError("Mock 서버 오류") - } - } - } - - // MARK: - State - private var configuration: Configuration = .success - private var registerCallCount = 0 - private var validateCallCount = 0 - private var checkEmailCallCount = 0 - private var lastCall: Date? - - // MARK: - Public Configuration Methods - - public init(configuration: Configuration = .success) { - self.configuration = configuration - } - - public func setConfiguration(_ configuration: Configuration) { - self.configuration = configuration - registerCallCount = 0 - validateCallCount = 0 - checkEmailCallCount = 0 - lastCall = nil - } - - public func getRegisterCallCount() -> Int { registerCallCount } - public func getValidateCallCount() -> Int { validateCallCount } - public func getCheckEmailCallCount() -> Int { checkEmailCallCount } - public func getLastCall() -> Date? { lastCall } - - public func reset() { - configuration = .success - registerCallCount = 0 - validateCallCount = 0 - checkEmailCallCount = 0 - lastCall = nil - } - - // MARK: - SignUpInterface Implementation - - public func registerAccount( - email: String, - password: String - ) async throws -> SignUpModel? { - // Track call - registerCallCount += 1 - lastCall = Date() - - // Apply delay - if configuration.delay > 0 { - try await Task.sleep(for: .seconds(configuration.delay)) - } - - // Handle failure scenarios - if !configuration.shouldSucceed, let error = configuration.signUpError { - throw error - } - - // Success case - let mockUser = SignUPUser( - username: createMockUsername(from: email), - email: email - ) - - let mockResponse = SignUpResponseModel( - accessToken: createMockAccessToken(), - refreshToken: createMockRefreshToken(), - user: mockUser - ) - - return BaseResponseDTO( - code: 200, - message: "Mock 회원가입 성공", - data: mockResponse - ) - } - - public func validateInviteCode( - inviteCode: String - ) async throws -> InviteCodeModel? { - // Track call - validateCallCount += 1 - lastCall = Date() - - // Apply delay - if configuration.delay > 0 { - try await Task.sleep(for: .seconds(configuration.delay)) - } - - // Handle failure scenarios - if !configuration.shouldSucceed, let error = configuration.signUpError { - throw error - } - - // Success case - let mockResponse = InviteCodeResponseModel( - valid: true, - inviteCodeID: inviteCode, - inviteType: createMockInviteType(), - oneTimeUse: true, - errorMessage: nil - ) - - return BaseResponseDTO( - code: 200, - message: "Mock 초대 코드 검증 성공", - data: mockResponse - ) - } - - public func checkEmail( - email: String - ) async throws -> CheckEmailModel? { - // Track call - checkEmailCallCount += 1 - lastCall = Date() - - // Apply delay - if configuration.delay > 0 { - try await Task.sleep(for: .seconds(configuration.delay)) - } - - // Handle failure scenarios - if !configuration.shouldSucceed, let error = configuration.signUpError { - throw error - } - - // Success case - let mockResponse = CheckEmailResponseModel( - emailUsed: configuration.isEmailUsed - ) - - return BaseResponseDTO( - code: 200, - message: configuration.isEmailUsed ? "Mock 이메일 이미 사용됨" : "Mock 이메일 사용 가능", - data: mockResponse - ) - } - - // MARK: - Private Helper Methods - - private func createMockUsername(from email: String) -> String { - let prefix = email.components(separatedBy: "@").first ?? "MockUser" - return "\(prefix)_\(UUID().uuidString.prefix(4))" - } - - private func createMockAccessToken() -> String { - return "mock_access_\(UUID().uuidString.prefix(16))" - } - - private func createMockRefreshToken() -> String { - return "mock_refresh_\(UUID().uuidString.prefix(16))" - } - - private func createMockInviteType() -> String { - let types = ["TEAM_INVITE", "PROJECT_INVITE", "ORGANIZATION_INVITE"] - return types.randomElement() ?? "TEAM_INVITE" - } -} - -// MARK: - Convenience Static Methods - -public extension MockSignUpRepository { - - /// Creates a pre-configured actor for success scenario - static func success() -> MockSignUpRepository { - return MockSignUpRepository(configuration: .success) - } - - /// Creates a pre-configured actor for failure scenario - static func failure() -> MockSignUpRepository { - return MockSignUpRepository(configuration: .failure) - } - - /// Creates a pre-configured actor for invalid invite code scenario - static func invalidInviteCode() -> MockSignUpRepository { - return MockSignUpRepository(configuration: .invalidInviteCode) - } - - /// Creates a pre-configured actor for expired invite code scenario - static func expiredInviteCode() -> MockSignUpRepository { - return MockSignUpRepository(configuration: .expiredInviteCode) - } - - /// Creates a pre-configured actor for email check with valid result - static func emailValid() -> MockSignUpRepository { - return MockSignUpRepository(configuration: .emailCheckedValid) - } - - /// Creates a pre-configured actor for email check with used result - static func emailUsed() -> MockSignUpRepository { - return MockSignUpRepository(configuration: .emailCheckedUsed) - } - - /// Creates a pre-configured actor for network error scenario - static func networkError() -> MockSignUpRepository { - return MockSignUpRepository(configuration: .networkError) - } - - /// Creates a pre-configured actor for server error scenario - static func serverError() -> MockSignUpRepository { - return MockSignUpRepository(configuration: .serverError) - } - - /// Creates a pre-configured actor with custom delay - static func withDelay(_ delay: TimeInterval) -> MockSignUpRepository { - return MockSignUpRepository(configuration: .customDelay(delay)) - } -} diff --git a/Projects/Domain/DomainInterface/Sources/SignUp/SignUpInterface.swift b/Projects/Domain/DomainInterface/Sources/SignUp/SignUpInterface.swift index 58ad2950..a07197a5 100644 --- a/Projects/Domain/DomainInterface/Sources/SignUp/SignUpInterface.swift +++ b/Projects/Domain/DomainInterface/Sources/SignUp/SignUpInterface.swift @@ -7,15 +7,13 @@ // import Foundation +import Entity import WeaveDI public protocol SignUpInterface: Sendable { - func registerAccount( - email: String, - password: String - ) async throws -> SignUpModel? - func validateInviteCode(inviteCode: String) async throws -> InviteCodeModel? - func checkEmail(email: String) async throws -> CheckEmailModel? + func registerUser( + input: SignUpUserInput + ) async throws -> SignUpUser } /// SignUp Repository의 DependencyKey 구조체 From 63600a23f8d2509d00a36e820b261be439b062c8 Mon Sep 17 00:00:00 2001 From: Roy Date: Thu, 1 Jan 2026 22:17:47 +0900 Subject: [PATCH 12/26] =?UTF-8?q?=F0=9F=94=A7[refactor]:=20=EB=8F=84?= =?UTF-8?q?=EB=A9=94=EC=9D=B8=20=EC=97=94=ED=8B=B0=ED=8B=B0=20=EB=B0=8F=20?= =?UTF-8?q?=ED=9A=8C=EC=9B=90=EA=B0=80=EC=9E=85=20UseCase=20=EA=B5=AC?= =?UTF-8?q?=EC=A1=B0=20=EA=B0=9C=EC=84=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Sources/OnBoarding/SelctManging.swift | 21 ++++++++ .../Sources/OnBoarding/SignUpEntity.swift | 11 ++-- .../Entity/Sources/OnBoarding/Staff.swift | 8 ++- .../Entity/Sources/SignUp/SignUpUser.swift | 33 ++++++++++++ .../Sources/SignUp/SignUpUserInput.swift | 39 ++++++++++++++ .../OnBoarding/OnBoardingUseCaseImpl.swift | 8 ++- .../Sources/SignUp/ SignUpUseCaseImpl.swift | 52 +++++++++---------- 7 files changed, 138 insertions(+), 34 deletions(-) create mode 100644 Projects/Domain/Entity/Sources/OnBoarding/SelctManging.swift create mode 100644 Projects/Domain/Entity/Sources/SignUp/SignUpUser.swift create mode 100644 Projects/Domain/Entity/Sources/SignUp/SignUpUserInput.swift diff --git a/Projects/Domain/Entity/Sources/OnBoarding/SelctManging.swift b/Projects/Domain/Entity/Sources/OnBoarding/SelctManging.swift new file mode 100644 index 00000000..7456a6f4 --- /dev/null +++ b/Projects/Domain/Entity/Sources/OnBoarding/SelctManging.swift @@ -0,0 +1,21 @@ +// +// SelectManaging.swift +// Entity +// +// Created by Wonji Suh on 1/1/26. +// + +import Foundation + +public struct SelectManaging: Equatable { + public let managingKeys: String + public let managing: StaffManaging + + public init( + managingKeys: String, + managing: StaffManaging + ) { + self.managingKeys = managingKeys + self.managing = managing + } +} diff --git a/Projects/Domain/Entity/Sources/OnBoarding/SignUpEntity.swift b/Projects/Domain/Entity/Sources/OnBoarding/SignUpEntity.swift index 161a31c8..f3e3afc4 100644 --- a/Projects/Domain/Entity/Sources/OnBoarding/SignUpEntity.swift +++ b/Projects/Domain/Entity/Sources/OnBoarding/SignUpEntity.swift @@ -11,22 +11,24 @@ public struct UserSession: Equatable { public var name: String public var selectPart: SelectParts public var userRole: Staff - public var managing: StaffManaging? + public var managing: [StaffManaging] public var provider: SocialType public var selectTeam: SelectTeams + public var selectTeamId: Int? public var token: String - public var generationId : Int? + public var generationId : Int public var inviteCode: String public init( name: String = "", selectPart: SelectParts = .all, userRole: Staff = .member, - managing: StaffManaging? = nil, + managing: [StaffManaging] = [], provider: SocialType = .apple, selectTeam: SelectTeams = .unknown, + selectTeamId: Int? = nil, token: String = "", - generationId: Int? = nil, + generationId: Int = .zero, inviteCode: String = "" ) { self.name = name @@ -37,6 +39,7 @@ public struct UserSession: Equatable { self.selectTeam = selectTeam self.token = token self.generationId = generationId + self.selectTeamId = selectTeamId self.inviteCode = inviteCode } diff --git a/Projects/Domain/Entity/Sources/OnBoarding/Staff.swift b/Projects/Domain/Entity/Sources/OnBoarding/Staff.swift index 62429a2c..55487a3b 100644 --- a/Projects/Domain/Entity/Sources/OnBoarding/Staff.swift +++ b/Projects/Domain/Entity/Sources/OnBoarding/Staff.swift @@ -9,14 +9,18 @@ import Foundation public enum Staff: String, CaseIterable , Equatable{ case member - case manger + case manager public var description: String { switch self { case .member: return "MEMBER" - case .manger: + case .manager: return "MANAGER" } } + + public static func from(apiKey: String) -> Staff? { + Staff(rawValue: apiKey.lowercased()) + } } diff --git a/Projects/Domain/Entity/Sources/SignUp/SignUpUser.swift b/Projects/Domain/Entity/Sources/SignUp/SignUpUser.swift new file mode 100644 index 00000000..56a7c61d --- /dev/null +++ b/Projects/Domain/Entity/Sources/SignUp/SignUpUser.swift @@ -0,0 +1,33 @@ +// +// SignUpUser.swift +// Entity +// +// Created by Wonji Suh on 1/1/26. +// + +import Foundation + +public struct SignUpUser : Equatable { + public let name: String + public let email: String + public let generation: String + public let team: SelectTeams? + public let managing: [StaffManaging]? + public let selectPart: SelectParts + + public init( + name: String, + email: String, + generation: String, + team: SelectTeams?, + managing: [StaffManaging]?, + selectPart: SelectParts + ) { + self.name = name + self.email = email + self.generation = generation + self.team = team + self.managing = managing + self.selectPart = selectPart + } +} diff --git a/Projects/Domain/Entity/Sources/SignUp/SignUpUserInput.swift b/Projects/Domain/Entity/Sources/SignUp/SignUpUserInput.swift new file mode 100644 index 00000000..1f5ca9cd --- /dev/null +++ b/Projects/Domain/Entity/Sources/SignUp/SignUpUserInput.swift @@ -0,0 +1,39 @@ +// +// SignUpUserInput.swift +// Entity +// +// Created by Wonji Suh on 1/1/26. +// + +import Foundation + +public struct SignUpUserInput { + public let name: String + public let generationId: Int + public let jobRole: SelectParts + public let teamId: Int? + public let managerRoles: [StaffManaging]? + public let provider: SocialType + public let token: String + public let invitationCode: String + + public init( + name: String, + generationId: Int, + jobRole: SelectParts, + teamId: Int?, + managerRoles: [StaffManaging]?, + provider: SocialType, + token: String, + invitationCode: String + ) { + self.name = name + self.generationId = generationId + self.jobRole = jobRole + self.teamId = teamId + self.managerRoles = managerRoles + self.provider = provider + self.token = token + self.invitationCode = invitationCode + } +} diff --git a/Projects/Domain/UseCase/Sources/OnBoarding/OnBoardingUseCaseImpl.swift b/Projects/Domain/UseCase/Sources/OnBoarding/OnBoardingUseCaseImpl.swift index a7587302..326d12c5 100644 --- a/Projects/Domain/UseCase/Sources/OnBoarding/OnBoardingUseCaseImpl.swift +++ b/Projects/Domain/UseCase/Sources/OnBoarding/OnBoardingUseCaseImpl.swift @@ -26,13 +26,17 @@ public struct OnBoardingUseCaseImpl: OnBoardingInterface { return try await repository.fetchJobs() } - public func fetchTeams(generationId: Int) async throws -> [SelectTeamEntity] { + public func fetchTeams( + generationId: Int + ) async throws -> [SelectTeamEntity] { return try await repository.fetchTeams(generationId: generationId) } + public func fetchManaging() async throws -> [SelectManaging] { + return try await repository.fetchManaging() + } } - extension OnBoardingUseCaseImpl : DependencyKey { static public var liveValue = OnBoardingUseCaseImpl() static public var testValue = OnBoardingUseCaseImpl() diff --git a/Projects/Domain/UseCase/Sources/SignUp/ SignUpUseCaseImpl.swift b/Projects/Domain/UseCase/Sources/SignUp/ SignUpUseCaseImpl.swift index 292ef05a..bf4f148c 100644 --- a/Projects/Domain/UseCase/Sources/SignUp/ SignUpUseCaseImpl.swift +++ b/Projects/Domain/UseCase/Sources/SignUp/ SignUpUseCaseImpl.swift @@ -6,49 +6,49 @@ // import DomainInterface -import Model +import Entity import WeaveDI -public struct SignUpUseCaseImpl: SignUpInterface { +public protocol SignUpUseCaseInterface: Sendable { + func registerUser( + userSession: UserSession + ) async throws -> SignUpUser +} + +public struct SignUpUseCaseImpl: SignUpUseCaseInterface { @Dependency(\.signUpRepository) var repository public init() { } // MARK: - 회원가입 API - public func registerAccount( - email: String, - password: String - ) async throws -> SignUpModel? { - return try await repository.registerAccount( - email: email, - password: password + public func registerUser( + userSession: UserSession + ) async throws -> SignUpUser { + let isManager = userSession.userRole == .manager + let input = SignUpUserInput( + name: userSession.name, + generationId: userSession.generationId, + jobRole: userSession.selectPart, + teamId: isManager ? nil : userSession.selectTeamId, + managerRoles: isManager ? userSession.managing : nil, + provider: userSession.provider, + token: userSession.token, + invitationCode: userSession.inviteCode ) - } - - // MARK: -초대코드 확인 - public func validateInviteCode( - inviteCode: String - ) async throws -> InviteCodeModel? { - return try await repository.validateInviteCode(inviteCode: inviteCode) - } - - // MARK: - 이메일 검증 - public func checkEmail(email: String) async throws -> CheckEmailModel? { - return try await repository.checkEmail(email: email) + return try await repository.registerUser(input: input) } } extension SignUpUseCaseImpl: DependencyKey { - static public var liveValue: SignUpInterface = SignUpUseCaseImpl() - static public var testValue: SignUpInterface = SignUpUseCaseImpl() - static public var previewValue: SignUpInterface = liveValue + static public var liveValue: SignUpUseCaseInterface = SignUpUseCaseImpl() + static public var testValue: SignUpUseCaseInterface = SignUpUseCaseImpl() + static public var previewValue: SignUpUseCaseInterface = liveValue } public extension DependencyValues { - var signUpUseCase: SignUpInterface { + var signUpUseCase: SignUpUseCaseInterface { get { self[SignUpUseCaseImpl.self] } set { self[SignUpUseCaseImpl.self] = newValue } } } - From d436f7e683dd6ef5fc1c5eb33a65cc904938e6c9 Mon Sep 17 00:00:00 2001 From: Roy Date: Thu, 1 Jan 2026 22:18:33 +0900 Subject: [PATCH 13/26] =?UTF-8?q?=F0=9F=94=A7[refactor]:=20=EC=98=A8?= =?UTF-8?q?=EB=B3=B4=EB=94=A9=20=EA=B4=80=EB=A6=AC=EC=A7=84=20=EC=84=A0?= =?UTF-8?q?=ED=83=9D=20=EB=A1=9C=EC=A7=81=20=EA=B0=9C=EC=84=A0=20=EB=B0=8F?= =?UTF-8?q?=20=ED=83=80=EC=9E=85=20=EC=98=A4=EB=A5=98=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Reducer/AuthCoordinator.swift | 2 +- .../Reducer/SignUpInviteCode.swift | 2 +- .../SignUpPart/Reducer/SignUpPart.swift | 2 +- .../Reducer/SignUpSelectManging.swift | 193 ++++++++++-------- .../View/SignUpSelectMangingView.swift | 42 ++-- .../Reducer/SignUpSelectTeam.swift | 64 +++++- .../View/SignUpSelectTeamView.swift | 2 +- 7 files changed, 195 insertions(+), 112 deletions(-) diff --git a/Projects/Presentation/Auth/Sources/AuthCoordinator/Reducer/AuthCoordinator.swift b/Projects/Presentation/Auth/Sources/AuthCoordinator/Reducer/AuthCoordinator.swift index ded7427a..80a1f68e 100644 --- a/Projects/Presentation/Auth/Sources/AuthCoordinator/Reducer/AuthCoordinator.swift +++ b/Projects/Presentation/Auth/Sources/AuthCoordinator/Reducer/AuthCoordinator.swift @@ -130,7 +130,7 @@ public struct AuthCoordinator { case .routeAction(id: _, action: .signUpManaging(.navigation(.presentCoreMember))): return .send(.navigation(.presentCoreMember)) - case .routeAction(id: _, action: .signUpSelectTeam(.navigation(.presentCoreMember))): + case .routeAction(id: _, action: .signUpSelectTeam(.navigation(.presentManager))): return .send(.navigation(.presentCoreMember)) // MARK: - 멤버 선택 할팀 선택 diff --git a/Projects/Presentation/Auth/Sources/SignUpInviteCode/Reducer/SignUpInviteCode.swift b/Projects/Presentation/Auth/Sources/SignUpInviteCode/Reducer/SignUpInviteCode.swift index 410e848f..c3287e7b 100644 --- a/Projects/Presentation/Auth/Sources/SignUpInviteCode/Reducer/SignUpInviteCode.swift +++ b/Projects/Presentation/Auth/Sources/SignUpInviteCode/Reducer/SignUpInviteCode.swift @@ -183,7 +183,7 @@ public struct SignUpInviteCode { state.verifyInviteCodeModel = data state.$userSession.withLock { $0.userRole = state.verifyInviteCodeModel?.type ?? .member - $0.generationId = state.verifyInviteCodeModel?.generationID + $0.generationId = state.verifyInviteCodeModel?.generationID ?? .zero $0.inviteCode = state.totalInviteCode } return .send(.navigation(.presentSignUpName)) diff --git a/Projects/Presentation/Auth/Sources/SignUpPart/Reducer/SignUpPart.swift b/Projects/Presentation/Auth/Sources/SignUpPart/Reducer/SignUpPart.swift index b13a2cc6..fe154e95 100644 --- a/Projects/Presentation/Auth/Sources/SignUpPart/Reducer/SignUpPart.swift +++ b/Projects/Presentation/Auth/Sources/SignUpPart/Reducer/SignUpPart.swift @@ -133,7 +133,7 @@ public struct SignUpPart { return .none case .presentNextStep: return .run { [isAdmin = state.userSession.userRole] send in - if isAdmin == .manger { + if isAdmin == .manager { await send(.navigation(.presentManaging)) } else { await send(.navigation(.presentSelectTeam)) diff --git a/Projects/Presentation/Auth/Sources/SignUpSelectManging/Reducer/SignUpSelectManging.swift b/Projects/Presentation/Auth/Sources/SignUpSelectManging/Reducer/SignUpSelectManging.swift index e18bedc2..708789a4 100644 --- a/Projects/Presentation/Auth/Sources/SignUpSelectManging/Reducer/SignUpSelectManging.swift +++ b/Projects/Presentation/Auth/Sources/SignUpSelectManging/Reducer/SignUpSelectManging.swift @@ -7,7 +7,8 @@ import Foundation -import Core +import UseCase +import Entity import Utill import AsyncMoya @@ -21,10 +22,14 @@ public struct SignUpSelectManaging { public struct State: Equatable { public init() {} - + var loading: Bool = false var activeButton: Bool = false - var editProfileDTO: ProfileResponseModel? - @Shared(.inMemory("UserEntity")) var userEntity: UserEntity = .shared + var errorMessage: String? + var selectMangers: [SelectManaging]? = [ ] + var signUpUser: SignUpUser? + + @Shared(.inMemory("UserSession")) var userSession: UserSession = .empty + } @@ -40,137 +45,159 @@ public struct SignUpSelectManaging { @CasePathable public enum View { - case selectManagingButton(selectManaging: Managing) + case onAppear + case selectManagingButton(selectManaging: SelectManaging) } - + // MARK: - AsyncAction 비동기 처리 액션 - + public enum AsyncAction: Equatable { - case editProfile + case fetchMangerList + case signUpUser } - + // MARK: - 앱내에서 사용하는 액션 - + public enum InnerAction: Equatable { - case editProfileResponse(Result) + case mangerListResponse(Result<[SelectManaging], SignUpError>) + case signUpUserResponse(Result) } - + // MARK: - NavigationAction - + public enum NavigationAction: Equatable { case presentCoreMember case presentSelectTeam } - - struct SignUpSelectManagingCancel: Hashable {} - - @Dependency(\.profileUseCase) var profileUseCase + + nonisolated enum CancelID: Hashable { + case fetchMangerList + } + + @Dependency(\.onBoardingUseCase) var onBoardingUseCase + @Dependency(\.signUpUseCase) var signUpUseCase @Dependency(\.continuousClock) var clock @Dependency(\.mainQueue) var mainQueue - + public var body: some ReducerOf { BindingReducer() Reduce { state, action in switch action { - case .binding(_): - return .none - - case .view(let viewAction): - return handleViewAction(state: &state, action: viewAction) - - case .async(let asyncAction): - return handleAsyncAction(state: &state, action: asyncAction) - - case .inner(let innerAction): - return handleInnerAction(state: &state, action: innerAction) - - case .navigation(let navigationAction): - return handleNavigationAction(state: &state, action: navigationAction) + case .binding(_): + return .none + + case .view(let viewAction): + return handleViewAction(state: &state, action: viewAction) + + case .async(let asyncAction): + return handleAsyncAction(state: &state, action: asyncAction) + + case .inner(let innerAction): + return handleInnerAction(state: &state, action: innerAction) + + case .navigation(let navigationAction): + return handleNavigationAction(state: &state, action: navigationAction) } } } - + private func handleViewAction( state: inout State, action: View ) -> Effect { switch action { - case .selectManagingButton(let selectManaging): - if state.userEntity.managing == selectManaging { - state.$userEntity.withLock { $0.managing = nil } - state.activeButton = false + case .onAppear: + return .send(.async(.fetchMangerList)) + + case .selectManagingButton(let selectManaging): + let selectedManaging = selectManaging.managing + var updatedManaging: [StaffManaging] = [] + + state.$userSession.withLock { + var current = $0.managing + if let index = current.firstIndex(of: selectedManaging) { + current.remove(at: index) + } else { + current.append(selectedManaging) + } + $0.managing = current + updatedManaging = current + } + + state.activeButton = !updatedManaging.isEmpty return .none - } - state.$userEntity.withLock { $0.managing = selectManaging } - if let selectManaging = Managing(rawValue: selectManaging.managingDesc) { - state.$userEntity.withLock { $0.managing = selectManaging } - } - state.activeButton = true - return .none } } - + private func handleNavigationAction( state: inout State, action: NavigationAction ) -> Effect { switch action { - case .presentCoreMember: - return .none - - case .presentSelectTeam: - return .none + case .presentCoreMember: + return .none + + case .presentSelectTeam: + return .none } } - + private func handleAsyncAction( state: inout State, action: AsyncAction ) -> Effect { switch action { - case .editProfile: - return .run { [ - userEntity = state.userEntity, - ] send in - let editProfileResult = await Result { - try await profileUseCase.editProfileMangerNoTeam( - name: userEntity.signUpName, - inviteCode: userEntity.inviteCodeId ?? "", - role: userEntity.role?.rawValue ?? "", - responsibility: userEntity.managing?.rawValue ?? "" - ) + case .fetchMangerList: + state.loading = true + return .run { send in + let mangerResult = await Result { + try await onBoardingUseCase.fetchManaging() + } + .mapError(SignUpError.from) + return await send(.inner(.mangerListResponse(mangerResult))) } - - switch editProfileResult { - case .success(let profileDTOData): - if let profileDTOData = profileDTOData { - await send(.inner(.editProfileResponse(.success(profileDTOData)))) - await send(.navigation(.presentCoreMember)) + .cancellable(id: CancelID.fetchMangerList, cancelInFlight: true) + + case .signUpUser: + return .run { [ + userSession = state.userSession + ] send in + let signUpResult = await Result { + try await signUpUseCase.registerUser(userSession: userSession) } - - case .failure(let error): - await send(.inner(.editProfileResponse(.failure(.encodingError("프로필업데이트 실패 : \(error.localizedDescription)"))))) + .mapError(SignUpError.from) + return await send(.inner(.signUpUserResponse(signUpResult))) } - } - .debounce(id: SignUpSelectManagingCancel(), for: 0.3, scheduler: mainQueue) } } - + private func handleInnerAction( state: inout State, action: InnerAction ) -> Effect { switch action { - case .editProfileResponse(let result): - switch result { - case .success(let profileDT0): - state.editProfileDTO = profileDT0 - - case .failure(let error): - #logNetwork("회원가입 프로핍 변경 에러", error.localizedDescription) - } - return .none + case .mangerListResponse(let result): + switch result { + case .success(let data): + state.loading = false + state.selectMangers = data + + case .failure(let error): + state.errorMessage = error.errorDescription + + } + return .none + + case .signUpUserResponse(let result): + switch result { + case .success(let data): + state.signUpUser = data + case .failure(let error): + state.errorMessage = error.errorDescription + } + return .none } + } } diff --git a/Projects/Presentation/Auth/Sources/SignUpSelectManging/View/SignUpSelectMangingView.swift b/Projects/Presentation/Auth/Sources/SignUpSelectManging/View/SignUpSelectMangingView.swift index cafe87d2..357b04c6 100644 --- a/Projects/Presentation/Auth/Sources/SignUpSelectManging/View/SignUpSelectMangingView.swift +++ b/Projects/Presentation/Auth/Sources/SignUpSelectManging/View/SignUpSelectMangingView.swift @@ -8,7 +8,7 @@ import SwiftUI import DesignSystem import ComposableArchitecture -import Model +import SDWebImageSwiftUI public struct SignUpSelectManagingView: View { @Bindable var store: StoreOf @@ -34,14 +34,28 @@ public struct SignUpSelectManagingView: View { StepNavigationBar(activeStep: 3, buttonAction: backAction) signUpSelectManagingText() - - selectManagingList() - - signUpSelectMangeButton() - + + if store.loading { + VStack { + Spacer() + + AnimatedImage(name: "DDDLoding.gif", isAnimating: .constant(true)) + .resizable() + .scaledToFit() + .frame(width: 200, height: 200) + + Spacer() + } + } else { + selectManagingList() + + signUpSelectMangeButton() + } + } .onAppear { - store.userEntity.managing = nil + store.userSession.managing = [] + store.send(.view(.onAppear)) } } } @@ -67,11 +81,11 @@ extension SignUpSelectManagingView { ScrollView { VStack { - ForEach(Managing.managingList, id: \.self) { item in + ForEach(store.selectMangers ?? [], id: \.managingKeys) { item in SelectPartItem( - content: item.managingDesc, - isActive: item == store.userEntity.managing) { - + content: item.managing.desc, + isActive: store.userSession.managing.contains(item.managing)) { + store.send(.view(.selectManagingButton(selectManaging: item))) } } @@ -90,13 +104,13 @@ extension SignUpSelectManagingView { CustomButton( action: { - if store.userEntity.managing == .projectTeamManaging { + if store.userSession.managing.contains(.teamManaging) { store.send(.navigation(.presentSelectTeam)) } else { - store.send(.async(.editProfile)) + } }, - title: store.userEntity.managing == .projectTeamManaging ? "다음" : "가입완료", + title: store.userSession.managing.contains(.teamManaging) ? "다음" : "가입완료", config: CustomButtonConfig.create(), isEnable: store.activeButton ) diff --git a/Projects/Presentation/Auth/Sources/SignUpSelectTeam/Reducer/SignUpSelectTeam.swift b/Projects/Presentation/Auth/Sources/SignUpSelectTeam/Reducer/SignUpSelectTeam.swift index 47393746..1fd0d6af 100644 --- a/Projects/Presentation/Auth/Sources/SignUpSelectTeam/Reducer/SignUpSelectTeam.swift +++ b/Projects/Presentation/Auth/Sources/SignUpSelectTeam/Reducer/SignUpSelectTeam.swift @@ -25,7 +25,9 @@ public struct SignUpSelectTeam { var editProfileDTO: ProfileResponseModel? var selectTeam: SelectTeams? = .unknown var loading: Bool = false + var errorMessage: String? var teams: [SelectTeamEntity]? = [] + var signUpUser: SignUpUser? @Shared(.inMemory("UserSession")) var userSession: UserSession = .empty @@ -51,29 +53,32 @@ public struct SignUpSelectTeam { public enum AsyncAction: Equatable { case getTeams + case signUpUser } // MARK: - 앱내에서 사용하는 액션 public enum InnerAction: Equatable { case teamListResponse(Result<[SelectTeamEntity], SignUpError>) + case signUpUserResponse(Result) } // MARK: - NavigationAction public enum NavigationAction: Equatable { case presentMember - case presentCoreMember + case presentManager } - - private struct SignUpSelectTeamCancel: Hashable {} + nonisolated enum CancelID: Hashable { + case selectTeam + case signUpUser + } + @Dependency(\.signUpUseCase) var signUpUseCase @Dependency(\.onBoardingUseCase) var onBoardingUseCase @Dependency(\.continuousClock) var clock - @Dependency(\.profileUseCase) var profileUseCase - @Dependency(\.mainQueue) var mainQueue public var body: some ReducerOf { BindingReducer() @@ -102,19 +107,27 @@ public struct SignUpSelectTeam { action: View ) -> Effect { switch action { - case .selectTeamButton(let selectTeam): - let selectTeam = selectTeam.teams + case .selectTeamButton(let selectTeams): + let selectTeam = selectTeams.teams + let teamId = selectTeams.teamId if state.selectTeam == selectTeam { // 동일한 파트 재선택 → 해제 state.selectTeam = nil - state.$userSession.withLock { $0.selectTeam = .unknown } + state.$userSession.withLock { + $0.selectTeam = .unknown + $0.selectTeamId = nil + } state.activeButton = false return .none } state.selectTeam = selectTeam - state.$userSession.withLock { $0.selectTeam = selectTeam } + state.$userSession.withLock { + $0.selectTeam = selectTeam + $0.selectTeamId = teamId + } + state.activeButton = true // #logDebug("selectPart", state.userEntity.role) return .none @@ -133,7 +146,7 @@ public struct SignUpSelectTeam { case .presentMember: return .none - case .presentCoreMember: + case .presentManager: return .none } } @@ -149,12 +162,24 @@ public struct SignUpSelectTeam { [userSession = state.userSession] send in let teamResult = await Result { - try await onBoardingUseCase.fetchTeams(generationId: userSession.generationId ?? .zero) + try await onBoardingUseCase.fetchTeams(generationId: userSession.generationId) } .mapError(SignUpError.from) return await send(.inner(.teamListResponse(teamResult))) } + .cancellable(id: CancelID.selectTeam, cancelInFlight: true) + + case .signUpUser: + return .run { [ + userSession = state.userSession + ] send in + let signUpUserResult = await Result { + return try await signUpUseCase.registerUser(userSession: userSession) + } + .mapError(SignUpError.from) + return await send(.inner(.signUpUserResponse(signUpUserResult))) + } } @@ -174,6 +199,23 @@ public struct SignUpSelectTeam { #logError("네트워크 에러 ", error.errorDescription ?? "알 수 없음") } return .none + + case .signUpUserResponse(let result): + switch result { + case .success(let data): + state.signUpUser = data + + if state.userSession.userRole == .manager { + return .send(.navigation(.presentManager)) + } else { + return .send(.navigation(.presentMember)) + } + + case .failure(let error): + state.errorMessage = error.errorDescription + return .none + } + } } } diff --git a/Projects/Presentation/Auth/Sources/SignUpSelectTeam/View/SignUpSelectTeamView.swift b/Projects/Presentation/Auth/Sources/SignUpSelectTeam/View/SignUpSelectTeamView.swift index c6a1b6e1..5b9edf0e 100644 --- a/Projects/Presentation/Auth/Sources/SignUpSelectTeam/View/SignUpSelectTeamView.swift +++ b/Projects/Presentation/Auth/Sources/SignUpSelectTeam/View/SignUpSelectTeamView.swift @@ -103,7 +103,7 @@ extension SignUpSelectTeamView { CustomButton( action: { - + store.send(.async(.signUpUser)) }, title: "가입 완료", config: CustomButtonConfig.create(), From 37a98c8619496c2847d03b87bd144d1b3097158e Mon Sep 17 00:00:00 2001 From: Roy Date: Fri, 2 Jan 2026 01:12:27 +0900 Subject: [PATCH 14/26] =?UTF-8?q?=F0=9F=94=A7[refactor]:=20OAuth=20?= =?UTF-8?q?=ED=86=A0=ED=81=B0=20=EC=B2=98=EB=A6=AC=20=EB=B0=8F=20=ED=9A=8C?= =?UTF-8?q?=EC=9B=90=EA=B0=80=EC=9E=85=20=ED=95=84=EB=93=9C=20=EC=98=B5?= =?UTF-8?q?=EC=85=94=EB=84=90=20=EC=B2=98=EB=A6=AC=20=EA=B0=9C=EC=84=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Projects/Data/Model/Sources/SignUp/DTO/SignUpUser.swift | 7 +++++-- .../Model/Sources/SignUp/Mapper/SignUpUserDTO+.swift | 9 ++++++--- .../Sources/Google/GoogleOAuthRepositoryImpl.swift | 2 +- .../Repository/Sources/SignUp/SignUpRepositoryImpl.swift | 4 +--- .../Service/Sources/SignUp/SignUpUserRequestDTO.swift | 4 ++-- .../Domain/Entity/Sources/OnBoarding/SignUpEntity.swift | 3 +++ .../OAuth/Provider/Apple/AppleOAuthProvider.swift | 8 +++++--- .../OAuth/Provider/Google/GoogleOAuthProvider.swift | 7 +++++-- .../UseCase/Sources/OAuth/UnifiedOAuthUseCase.swift | 4 +++- .../UseCase/Sources/SignUp/ SignUpUseCaseImpl.swift | 3 +++ .../View/SignUpSelectMangingView.swift | 2 +- 11 files changed, 35 insertions(+), 18 deletions(-) diff --git a/Projects/Data/Model/Sources/SignUp/DTO/SignUpUser.swift b/Projects/Data/Model/Sources/SignUp/DTO/SignUpUser.swift index e674e0b4..71a78149 100644 --- a/Projects/Data/Model/Sources/SignUp/DTO/SignUpUser.swift +++ b/Projects/Data/Model/Sources/SignUp/DTO/SignUpUser.swift @@ -8,9 +8,12 @@ import Foundation public struct SignUpUserDTO: Decodable { let userID: Int - let name, email, generation, team: String + let name: String + let email: String? + let generation: String + let team: String? let jobRole: String - let managerRoles: [String] + let managerRoles: [String]? enum CodingKeys: String, CodingKey { case userID = "userId" diff --git a/Projects/Data/Model/Sources/SignUp/Mapper/SignUpUserDTO+.swift b/Projects/Data/Model/Sources/SignUp/Mapper/SignUpUserDTO+.swift index 05c0fc0a..0fe9baa7 100644 --- a/Projects/Data/Model/Sources/SignUp/Mapper/SignUpUserDTO+.swift +++ b/Projects/Data/Model/Sources/SignUp/Mapper/SignUpUserDTO+.swift @@ -13,10 +13,13 @@ public extension SignUpUserDTO { func toDomain() -> SignUpUser { return SignUpUser( name: self.name, - email: self.email, + email: self.email ?? "", generation: self.generation, - team: SelectTeams.from(name: self.team), - managing: self.managerRoles.compactMap { StaffManaging.from(apiKey: $0) }, + team: { + guard let team = self.team else { return nil } + return SelectTeams.from(name: team) + }(), + managing: self.managerRoles?.compactMap { StaffManaging.from(apiKey: $0) }, selectPart: SelectParts.from(apiKey: self.jobRole) ?? .all ) } diff --git a/Projects/Data/Repository/Sources/Google/GoogleOAuthRepositoryImpl.swift b/Projects/Data/Repository/Sources/Google/GoogleOAuthRepositoryImpl.swift index b7a06170..5a90ab17 100644 --- a/Projects/Data/Repository/Sources/Google/GoogleOAuthRepositoryImpl.swift +++ b/Projects/Data/Repository/Sources/Google/GoogleOAuthRepositoryImpl.swift @@ -63,7 +63,7 @@ public final class GoogleOAuthRepositoryImpl: GoogleOAuthInterface, @unchecked S let payload = GoogleOAuthPayload( idToken: idToken, - accessToken: "", + accessToken: result.user.refreshToken.tokenString, authorizationCode: result.serverAuthCode, displayName: result.user.profile?.name ) diff --git a/Projects/Data/Repository/Sources/SignUp/SignUpRepositoryImpl.swift b/Projects/Data/Repository/Sources/SignUp/SignUpRepositoryImpl.swift index 2127c9ee..cc553ad0 100644 --- a/Projects/Data/Repository/Sources/SignUp/SignUpRepositoryImpl.swift +++ b/Projects/Data/Repository/Sources/SignUp/SignUpRepositoryImpl.swift @@ -35,9 +35,7 @@ final public class SignUpRepositoryImpl: SignUpInterface { generationId: input.generationId, jobRole: input.jobRole.apiKey, teamId: input.teamId, - managerRoles: input.managerRoles?.reduce(into: [String: String]()) { result, role in - result[role.apiKey] = role.desc - }, + managerRoles: input.managerRoles?.map { $0.apiKey }, provider: input.provider.description, token: input.token, invitationCode: input.invitationCode diff --git a/Projects/Data/Service/Sources/SignUp/SignUpUserRequestDTO.swift b/Projects/Data/Service/Sources/SignUp/SignUpUserRequestDTO.swift index 726be5e6..fff751ea 100644 --- a/Projects/Data/Service/Sources/SignUp/SignUpUserRequestDTO.swift +++ b/Projects/Data/Service/Sources/SignUp/SignUpUserRequestDTO.swift @@ -10,7 +10,7 @@ public struct SignUpUserRequestDTO: Encodable { public let generationId: Int public let jobRole: String public let teamId: Int? - public let managerRoles: [String: String]? + public let managerRoles: [String]? public let provider: String public let token: String public let invitationCode: String @@ -20,7 +20,7 @@ public struct SignUpUserRequestDTO: Encodable { generationId: Int, jobRole: String, teamId: Int?, - managerRoles: [String : String]?, + managerRoles: [String]?, provider: String, token: String, invitationCode: String diff --git a/Projects/Domain/Entity/Sources/OnBoarding/SignUpEntity.swift b/Projects/Domain/Entity/Sources/OnBoarding/SignUpEntity.swift index f3e3afc4..3b6283dd 100644 --- a/Projects/Domain/Entity/Sources/OnBoarding/SignUpEntity.swift +++ b/Projects/Domain/Entity/Sources/OnBoarding/SignUpEntity.swift @@ -16,6 +16,7 @@ public struct UserSession: Equatable { public var selectTeam: SelectTeams public var selectTeamId: Int? public var token: String + public var accessToken: String public var generationId : Int public var inviteCode: String @@ -29,6 +30,7 @@ public struct UserSession: Equatable { selectTeamId: Int? = nil, token: String = "", generationId: Int = .zero, + accessToken: String = "", inviteCode: String = "" ) { self.name = name @@ -40,6 +42,7 @@ public struct UserSession: Equatable { self.token = token self.generationId = generationId self.selectTeamId = selectTeamId + self.accessToken = accessToken self.inviteCode = inviteCode } diff --git a/Projects/Domain/UseCase/Sources/OAuth/Provider/Apple/AppleOAuthProvider.swift b/Projects/Domain/UseCase/Sources/OAuth/Provider/Apple/AppleOAuthProvider.swift index 679f5d4d..7d91ac81 100644 --- a/Projects/Domain/UseCase/Sources/OAuth/Provider/Apple/AppleOAuthProvider.swift +++ b/Projects/Domain/UseCase/Sources/OAuth/Provider/Apple/AppleOAuthProvider.swift @@ -11,26 +11,27 @@ import LogMacro import AuthenticationServices @preconcurrency import Entity import DomainInterface +import Sharing public final class AppleOAuthProvider: AppleOAuthProviderInterface, @unchecked Sendable { @Dependency(\.appleOAuthRepository) private var appleRepository: AppleOAuthInterface - + @Shared(.inMemory("UserSession")) var userSession: UserSession = .empty public init() {} public func signInWithCredential( credential: ASAuthorizationAppleIDCredential, nonce: String ) async throws -> AppleOAuthPayload { - // appleRepository.signInWithCredential 사용 (credential을 직접 처리) let payload = try await appleRepository.signInWithCredential(credential, nonce: nonce) Log.info("Apple sign-in completed through repository with credential") return payload } public func signIn() async throws -> AppleOAuthPayload { - // Repository를 통해 Apple 로그인 처리 let payload = try await appleRepository.signIn() Log.info("Apple sign-in completed through repository (direct)") + self.$userSession.withLock { $0.accessToken = payload.authorizationCode ?? "" + } return payload } @@ -41,3 +42,4 @@ public final class AppleOAuthProvider: AppleOAuthProviderInterface, @unchecked S return name.isEmpty ? nil : name } } + diff --git a/Projects/Domain/UseCase/Sources/OAuth/Provider/Google/GoogleOAuthProvider.swift b/Projects/Domain/UseCase/Sources/OAuth/Provider/Google/GoogleOAuthProvider.swift index 0cc30703..c7f782b7 100644 --- a/Projects/Domain/UseCase/Sources/OAuth/Provider/Google/GoogleOAuthProvider.swift +++ b/Projects/Domain/UseCase/Sources/OAuth/Provider/Google/GoogleOAuthProvider.swift @@ -10,10 +10,11 @@ import Dependencies import LogMacro @preconcurrency import Entity import DomainInterface +import Sharing public final class GoogleOAuthProvider: GoogleOAuthProviderInterface, @unchecked Sendable { @Dependency(\.googleOAuthRepository) private var googleRepository - + @Shared(.inMemory("UserSession")) var userSession: UserSession = .empty public init() {} public func signInWithToken( @@ -21,6 +22,8 @@ public final class GoogleOAuthProvider: GoogleOAuthProviderInterface, @unchecked ) async throws -> String { Log.info("Starting Google OAuth flow") let payload = try await googleRepository.signIn() + self.$userSession.withLock { $0.accessToken = payload.accessToken ?? "" } + Log.debug("gooogle access", payload.accessToken) return payload.idToken } -} \ No newline at end of file +} diff --git a/Projects/Domain/UseCase/Sources/OAuth/UnifiedOAuthUseCase.swift b/Projects/Domain/UseCase/Sources/OAuth/UnifiedOAuthUseCase.swift index 31ea7452..0875ef2c 100644 --- a/Projects/Domain/UseCase/Sources/OAuth/UnifiedOAuthUseCase.swift +++ b/Projects/Domain/UseCase/Sources/OAuth/UnifiedOAuthUseCase.swift @@ -11,6 +11,7 @@ import AuthenticationServices @preconcurrency import Entity import DomainInterface import Sharing +import LogMacro /// 통합 OAuth UseCase - 로그인/회원가입 플로우를 하나로 통합 public struct UnifiedOAuthUseCase { @@ -55,10 +56,11 @@ public extension UnifiedOAuthUseCase { credential: credential, nonce: nonce ) + Log.debug("apple authcode", payload.authorizationCode) self.$userSession.withLock { $0.token = payload.idToken } return try await authRepository.login( provider: .apple, - token: payload.idToken + token: payload.authorizationCode ?? "" ) } diff --git a/Projects/Domain/UseCase/Sources/SignUp/ SignUpUseCaseImpl.swift b/Projects/Domain/UseCase/Sources/SignUp/ SignUpUseCaseImpl.swift index bf4f148c..62dab335 100644 --- a/Projects/Domain/UseCase/Sources/SignUp/ SignUpUseCaseImpl.swift +++ b/Projects/Domain/UseCase/Sources/SignUp/ SignUpUseCaseImpl.swift @@ -26,6 +26,9 @@ public struct SignUpUseCaseImpl: SignUpUseCaseInterface { userSession: UserSession ) async throws -> SignUpUser { let isManager = userSession.userRole == .manager + if !isManager, userSession.selectTeamId == nil { + throw SignUpError.missingRequiredField("팀") + } let input = SignUpUserInput( name: userSession.name, generationId: userSession.generationId, diff --git a/Projects/Presentation/Auth/Sources/SignUpSelectManging/View/SignUpSelectMangingView.swift b/Projects/Presentation/Auth/Sources/SignUpSelectManging/View/SignUpSelectMangingView.swift index 357b04c6..a4655420 100644 --- a/Projects/Presentation/Auth/Sources/SignUpSelectManging/View/SignUpSelectMangingView.swift +++ b/Projects/Presentation/Auth/Sources/SignUpSelectManging/View/SignUpSelectMangingView.swift @@ -107,7 +107,7 @@ extension SignUpSelectManagingView { if store.userSession.managing.contains(.teamManaging) { store.send(.navigation(.presentSelectTeam)) } else { - + store.send(.async(.signUpUser)) } }, title: store.userSession.managing.contains(.teamManaging) ? "다음" : "가입완료", From 8c3298b2a36b0920b5e3d031755e43396aec5326 Mon Sep 17 00:00:00 2001 From: Roy Date: Fri, 2 Jan 2026 09:59:54 +0900 Subject: [PATCH 15/26] =?UTF-8?q?=F0=9F=94=A7[refactor]:=20=ED=86=A0?= =?UTF-8?q?=ED=81=B0=20=EC=A0=9C=EA=B3=B5=EC=9E=90=20=EC=9D=98=EC=A1=B4?= =?UTF-8?q?=EC=84=B1=20=EC=A3=BC=EC=9E=85=20=ED=8C=A8=ED=84=B4=EC=9C=BC?= =?UTF-8?q?=EB=A1=9C=20APIHeader=20=EB=A6=AC=ED=8C=A9=ED=84=B0=EB=A7=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Projects/Network/Foundations/Project.swift | 2 +- .../Sources/APIHeader/APIHeader.swift | 14 ++---- .../Sources/APIHeader/TokenProviding.swift | 48 +++++++++++++++++++ Projects/Network/ThirdPartys/Project.swift | 1 + 4 files changed, 54 insertions(+), 11 deletions(-) create mode 100644 Projects/Network/Foundations/Sources/APIHeader/TokenProviding.swift diff --git a/Projects/Network/Foundations/Project.swift b/Projects/Network/Foundations/Project.swift index 60cfcf19..4af74093 100644 --- a/Projects/Network/Foundations/Project.swift +++ b/Projects/Network/Foundations/Project.swift @@ -10,7 +10,7 @@ let project = Project.makeModule( settings: .settings(), dependencies: [ .Network(implements: .ThirdPartys), - .Data(implements: .Model) + ], sources: ["Sources/**"] ) diff --git a/Projects/Network/Foundations/Sources/APIHeader/APIHeader.swift b/Projects/Network/Foundations/Sources/APIHeader/APIHeader.swift index ebdd5065..53439241 100644 --- a/Projects/Network/Foundations/Sources/APIHeader/APIHeader.swift +++ b/Projects/Network/Foundations/Sources/APIHeader/APIHeader.swift @@ -8,6 +8,7 @@ import Foundation import ComposableArchitecture +import WeaveDI import Model @@ -17,23 +18,16 @@ public struct APIHeader { public static let accessToken = "Authorization" public static let accept = "accept" - // ← add `static` here - @Shared(.inMemory("UserEntity")) - static var userEntity: UserEntity = .init() - - private static var _accessTokenKeyChain: String { - return UserDefaults.standard.string(forKey: "ACCESS_TOKEN") ?? "" // Returns an empty string if nil - } + @Dependency(\.tokenProvider) private var tokenProvider public static var accessTokenKeyChain: String { - get { _accessTokenKeyChain } + get { APIHeader().tokenProvider.accessToken() ?? "" } set { updateAccessToken(newValue) } } public static func updateAccessToken(_ token: String?) { guard let newToken = token, !newToken.isEmpty else { return } - UserDefaults.standard.set(newToken, forKey: "ACCESS_TOKEN") - self.$userEntity.withLock { $0.accessToken = newToken } + APIHeader().tokenProvider.saveAccessToken(newToken) } public init() {} diff --git a/Projects/Network/Foundations/Sources/APIHeader/TokenProviding.swift b/Projects/Network/Foundations/Sources/APIHeader/TokenProviding.swift new file mode 100644 index 00000000..fcbe569d --- /dev/null +++ b/Projects/Network/Foundations/Sources/APIHeader/TokenProviding.swift @@ -0,0 +1,48 @@ +// +// TokenProviding.swift +// Foundations +// +// Created by Wonji Suh on 1/2/26. +// + +import Foundation + +import Dependencies +import WeaveDI + +public protocol TokenProviding: Sendable { + func accessToken() -> String? + func saveAccessToken(_ token: String) +} + +private enum TokenProviderKey: DependencyKey { + static var liveValue: TokenProviding { + UnifiedDI.resolve(TokenProviding.self) ?? InMemoryTokenProvider() + } +} + +public extension DependencyValues { + var tokenProvider: TokenProviding { + get { self[TokenProviderKey.self] } + set { self[TokenProviderKey.self] = newValue } + } +} + +public final class InMemoryTokenProvider: TokenProviding, @unchecked Sendable { + private var storage: String? + private let lock = NSLock() + + public init() {} + + public func accessToken() -> String? { + lock.lock() + defer { lock.unlock() } + return storage + } + + public func saveAccessToken(_ token: String) { + lock.lock() + storage = token + lock.unlock() + } +} diff --git a/Projects/Network/ThirdPartys/Project.swift b/Projects/Network/ThirdPartys/Project.swift index b3c1573c..f98f4e31 100644 --- a/Projects/Network/ThirdPartys/Project.swift +++ b/Projects/Network/ThirdPartys/Project.swift @@ -11,6 +11,7 @@ let project = Project.makeModule( settings: .settings(), dependencies: [ .SPM.asyncMoya, + .SPM.weaveDI ], sources: ["Sources/**"] ) From 67de44f3df16b616b4c974e7007c5d9a203ab490 Mon Sep 17 00:00:00 2001 From: Roy Date: Fri, 2 Jan 2026 10:04:06 +0900 Subject: [PATCH 16/26] =?UTF-8?q?=F0=9F=94=A7[refactor]:=20=EC=9D=98?= =?UTF-8?q?=EC=A1=B4=EC=84=B1=20=EC=A3=BC=EC=9E=85=20=EA=B5=AC=EC=A1=B0=20?= =?UTF-8?q?=EA=B0=9C=EC=84=A0=20=EB=B0=8F=20=EC=A4=91=EB=B3=B5=20=EC=9D=B8?= =?UTF-8?q?=EC=A6=9D=20=ED=8C=8C=EC=9D=BC=20=EC=A0=9C=EA=B1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Projects/App/Sources/Di/DiRegister.swift | 3 + .../Sources/Di/Extension+AppDIContainer.swift | 22 ------ .../Sources/Di/KeychainTokenProvider.swift | 27 +++++++ .../Auth/Auth/RefreshAuth/AuthAPIManger.swift | 62 ---------------- .../Auth/RefreshAuth/AuthInterceptor.swift | 73 ------------------- 5 files changed, 30 insertions(+), 157 deletions(-) delete mode 100644 Projects/App/Sources/Di/Extension+AppDIContainer.swift create mode 100644 Projects/App/Sources/Di/KeychainTokenProvider.swift delete mode 100644 Projects/Core/Networking/UseCase/Sources/Auth/Auth/RefreshAuth/AuthAPIManger.swift delete mode 100644 Projects/Core/Networking/UseCase/Sources/Auth/Auth/RefreshAuth/AuthInterceptor.swift diff --git a/Projects/App/Sources/Di/DiRegister.swift b/Projects/App/Sources/Di/DiRegister.swift index e7a3b073..b89e4778 100644 --- a/Projects/App/Sources/Di/DiRegister.swift +++ b/Projects/App/Sources/Di/DiRegister.swift @@ -10,6 +10,7 @@ import Foundation import DomainInterface import Repository import Core +import Foundations import ComposableArchitecture import WeaveDI @@ -32,6 +33,8 @@ public class AppDIManager: @unchecked Sendable { .register { AppleOAuthRepositoryImpl() as AppleOAuthInterface } .register { AppleOAuthProvider() as AppleOAuthProviderInterface } .register { GoogleOAuthProvider() as GoogleOAuthProviderInterface } + .register { KeychainManager() as KeychainManaging } + .register { KeychainTokenProvider(keychainManager: KeychainManager()) as TokenProviding } // MARK: - 온보딩 관련 .register { OnBoardingRepositoryImpl() as OnBoardingInterface } .register { SignUpRepositoryImpl() as SignUpInterface } diff --git a/Projects/App/Sources/Di/Extension+AppDIContainer.swift b/Projects/App/Sources/Di/Extension+AppDIContainer.swift deleted file mode 100644 index 197c1a6f..00000000 --- a/Projects/App/Sources/Di/Extension+AppDIContainer.swift +++ /dev/null @@ -1,22 +0,0 @@ -// -// Extension+AppDIContainer.swift -// DDDAttendance -// -// Created by 서원지 on 6/8/24. -// - -//import Foundation -// -//import WeaveDI -//import UseCase -// -//extension AppWeaveDI.Container { -// func registerDefaultDependencies() async { -// await registerDependencies { container in -// // Repository 먼저 등록 -// let factory = ModuleFactoryManager() -// -// await factory.registerAll(to: container) -// } -// } -//} diff --git a/Projects/App/Sources/Di/KeychainTokenProvider.swift b/Projects/App/Sources/Di/KeychainTokenProvider.swift new file mode 100644 index 00000000..bc1bee79 --- /dev/null +++ b/Projects/App/Sources/Di/KeychainTokenProvider.swift @@ -0,0 +1,27 @@ +// +// KeychainTokenProvider.swift +// DDDAttendance +// +// Created by Wonji Suh on 1/2/26. +// + +import Foundation + +import DomainInterface +import Foundations + +struct KeychainTokenProvider: TokenProviding { + private let keychainManager: KeychainManaging + + init(keychainManager: KeychainManaging) { + self.keychainManager = keychainManager + } + + func accessToken() -> String? { + keychainManager.accessToken() + } + + func saveAccessToken(_ token: String) { + keychainManager.saveAccessToken(token) + } +} diff --git a/Projects/Core/Networking/UseCase/Sources/Auth/Auth/RefreshAuth/AuthAPIManger.swift b/Projects/Core/Networking/UseCase/Sources/Auth/Auth/RefreshAuth/AuthAPIManger.swift deleted file mode 100644 index 2816e067..00000000 --- a/Projects/Core/Networking/UseCase/Sources/Auth/Auth/RefreshAuth/AuthAPIManger.swift +++ /dev/null @@ -1,62 +0,0 @@ -// -// AuthAPIManger.swift -// UseCase -// -// Created by Wonji Suh on 7/22/25. -// - -import Foundation -import Model -import AsyncMoya -import Foundation -import API -import Alamofire -import Foundations -import ComposableArchitecture - - -public final actor AuthAPIManger { - public static let shared = AuthAPIManger() - var loginModel: LoginModel? = nil - @Shared(.inMemory("UserEntity")) var userEntity: UserEntity = .shared - - let repository = AuthRepository() - - public init() {} - - public func refeshTokenRsponse(_ result: Result) -> Result { - switch result { - case .success(let refeshModel): - self.loginModel = refeshModel - APIHeader.accessTokenKeyChain = refeshModel.data.accessToken - UserDefaults.standard.set(refeshModel.data.accessToken, forKey: "ACCESS_TOKEN") - return .success(refeshModel) - case .failure(let error): - Log.error("리프레쉬 에러", error.localizedDescription) - return .failure(error) - } - } - - public func getRefeshToken() async -> RetryResult { - let authResultData = await Result { - try await repository.loginUser(email: userEntity.userEmail) - } - - switch authResultData { - case .success(let authResultData): - if let authResultData = authResultData{ - _ = self.refeshTokenRsponse(.success(authResultData)) // 반환 값을 무시 - return .retry - } else { - // authResultData가 nil일 경우 - let error = CustomError.unknownError("AuthResultData is nil") - _ = refeshTokenRsponse(.failure(error)) - return .doNotRetryWithError(error) - } - - case .failure(let error): - _ = refeshTokenRsponse(.failure(CustomError.unknownError(error.localizedDescription))) // 반환 값을 무시 - return .doNotRetryWithError(error) - } - } -} diff --git a/Projects/Core/Networking/UseCase/Sources/Auth/Auth/RefreshAuth/AuthInterceptor.swift b/Projects/Core/Networking/UseCase/Sources/Auth/Auth/RefreshAuth/AuthInterceptor.swift deleted file mode 100644 index 1ba80ec0..00000000 --- a/Projects/Core/Networking/UseCase/Sources/Auth/Auth/RefreshAuth/AuthInterceptor.swift +++ /dev/null @@ -1,73 +0,0 @@ -// -// AuthInterceptor.swift -// UseCase -// -// Created by Wonji Suh on 7/22/25. -// - -import Alamofire -import AsyncMoya -import Foundation -import API - -final actor AuthInterceptor: RequestInterceptor { - - static let shared = AuthInterceptor() - - private init() {} - - // Request를 수정하여 토큰을 추가하는 메서드 - nonisolated func adapt(_ urlRequest: URLRequest, for session: Session, completion: @escaping (Result) -> Void) async { - guard urlRequest.url?.absoluteString.hasPrefix(BaseAPI.base.apiDescription) == true else { - completion(.success(urlRequest)) - return - } - - // Retrieve the access token from the Keychain - let accessToken = UserDefaults.standard.string(forKey: "ACCESS_TOKEN") - var urlRequest = urlRequest - - // Set the token as the Authorization header - urlRequest.setValue(accessToken, forHTTPHeaderField: "Authorization") // Ensure the correct format is used - - Log.debug("Adapted request with headers: ", urlRequest.headers) - completion(.success(urlRequest)) - } - - // 401 Unauthorized 응답 시 토큰 갱신 및 재시도 로직 - nonisolated func retry( - _ request: Request, - for session: Session, - dueTo error: Error, - completion: @escaping (RetryResult) -> Void) async { - Log.debug("Entered retry function") - typealias Task = _Concurrency.Task - // error의 상세 정보를 확인 - if let afError = error.asAFError, afError.isResponseValidationError { - Log.error("Response validation error detected.") - } else { - Log.error("Error is not responseValidationFailed: \(error)") - } - - // 401 상태 코드 확인 - guard let response = request.task?.response as? HTTPURLResponse else { - Log.debug("Response is not an HTTPURLResponse.") - completion(.doNotRetryWithError(error)) - return - } - - Log.debug("HTTP Status Code: \(response.statusCode)") - - switch response.statusCode { - case 400, 401: - Log.debug("401 Unauthorized detected, attempting to refresh token...") - Task { - let retryResult = await AuthAPIManger.shared.getRefeshToken() - completion(retryResult) - } - default: - Log.debug("Status code is not 401, not retrying.") - completion(.doNotRetryWithError(error)) - } - } -} From 542e03af22d89672b45eb3208f5905e4528d4689 Mon Sep 17 00:00:00 2001 From: Roy Date: Fri, 2 Jan 2026 10:08:08 +0900 Subject: [PATCH 17/26] =?UTF-8?q?=F0=9F=94=A7[refactor]:=20=EB=8D=B0?= =?UTF-8?q?=EB=AA=A8=20=EB=AA=A8=EB=93=88=20=ED=85=9C=ED=94=8C=EB=A6=BF?= =?UTF-8?q?=EC=97=90=EC=84=9C=20Core=20=EC=9D=98=EC=A1=B4=EC=84=B1=20?= =?UTF-8?q?=EC=A0=9C=EA=B1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Tuist/Templates/DemoModule/Project.stencil | 1 - 1 file changed, 1 deletion(-) diff --git a/Tuist/Templates/DemoModule/Project.stencil b/Tuist/Templates/DemoModule/Project.stencil index d2e03590..71dc8aac 100644 --- a/Tuist/Templates/DemoModule/Project.stencil +++ b/Tuist/Templates/DemoModule/Project.stencil @@ -10,7 +10,6 @@ bundleId: .appBundleID(name: ".{{name}}"), product: .staticFramework, settings: .appDemoSetting, dependencies: [ - .Core(implements: .Core), .Shared(implements: .Shareds), .Networking(implements: .Networkings) ], From 530a4eb3d0a49edbac60ab66face302d13855827 Mon Sep 17 00:00:00 2001 From: Roy Date: Fri, 2 Jan 2026 10:08:26 +0900 Subject: [PATCH 18/26] =?UTF-8?q?=F0=9F=94=A7[refactor]:=20=ED=94=84?= =?UTF-8?q?=EB=A0=88=EC=A0=A0=ED=85=8C=EC=9D=B4=EC=85=98=20=EB=A0=88?= =?UTF-8?q?=EC=9D=B4=EC=96=B4=20=EC=9D=98=EC=A1=B4=EC=84=B1=EC=9D=84=20Cor?= =?UTF-8?q?e=EC=97=90=EC=84=9C=20Domain=20UseCase=EB=A1=9C=20=EB=B3=80?= =?UTF-8?q?=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Projects/Presentation/Auth/Project.swift | 2 +- Projects/Presentation/Management/Project.swift | 2 +- Projects/Presentation/Member/Project.swift | 5 +---- Projects/Presentation/Profile/Project.swift | 2 +- Projects/Presentation/Splash/Project.swift | 1 - 5 files changed, 4 insertions(+), 8 deletions(-) diff --git a/Projects/Presentation/Auth/Project.swift b/Projects/Presentation/Auth/Project.swift index 38f9cfe5..f17da4e2 100644 --- a/Projects/Presentation/Auth/Project.swift +++ b/Projects/Presentation/Auth/Project.swift @@ -13,7 +13,7 @@ let project = Project.makeModule( dependencies: [ .Shared(implements: .Shareds), .Shared(implements: .DesignSystem), - .Core(implements: .Core) + .Domain(implements: .UseCase) ], sources: ["Sources/**"], hasTests: true diff --git a/Projects/Presentation/Management/Project.swift b/Projects/Presentation/Management/Project.swift index f9450360..0f8dc982 100644 --- a/Projects/Presentation/Management/Project.swift +++ b/Projects/Presentation/Management/Project.swift @@ -13,7 +13,7 @@ let project = Project.makeModule( dependencies: [ .Shared(implements: .Shareds), .Presentation(implements: .Profile), - .Core(implements: .Core) + .Domain(implements: .UseCase) ], sources: ["Sources/**"] ) diff --git a/Projects/Presentation/Member/Project.swift b/Projects/Presentation/Member/Project.swift index 445ee516..28797bb0 100644 --- a/Projects/Presentation/Member/Project.swift +++ b/Projects/Presentation/Member/Project.swift @@ -11,12 +11,9 @@ let project = Project.makeModule( product: Project.Environment.presentationProduct, settings: .settings(), dependencies: [ -// .SPM.composableArchitecture, -// .SPM.tcaCoordinator, -// .Shared(implements: .Shareds), // .Shared(implements: .DesignSystem), .Presentation(implements: .Profile), -// .Core(implements: .Core) + .Domain(implements: .UseCase) ], sources: ["Sources/**"] diff --git a/Projects/Presentation/Profile/Project.swift b/Projects/Presentation/Profile/Project.swift index 29d936d8..bb814d60 100644 --- a/Projects/Presentation/Profile/Project.swift +++ b/Projects/Presentation/Profile/Project.swift @@ -13,7 +13,7 @@ let project = Project.makeModule( dependencies: [ .Shared(implements: .Shareds), .Shared(implements: .DesignSystem), - .Core(implements: .Core) + .Domain(implements: .UseCase) ], sources: ["Sources/**"] ) diff --git a/Projects/Presentation/Splash/Project.swift b/Projects/Presentation/Splash/Project.swift index 4d41a35a..084dfd6b 100644 --- a/Projects/Presentation/Splash/Project.swift +++ b/Projects/Presentation/Splash/Project.swift @@ -13,7 +13,6 @@ let project = Project.makeModule( dependencies: [ .Shared(implements: .Shareds), .Shared(implements: .DesignSystem), - .Core(implements: .Core) ], sources: ["Sources/**"], hasTests: true From ea1fe34354d4e1ea39d3f928be119c21150de3df Mon Sep 17 00:00:00 2001 From: Roy Date: Fri, 2 Jan 2026 10:08:41 +0900 Subject: [PATCH 19/26] =?UTF-8?q?=F0=9F=94=A7[refactor]:=20App=20=EB=AA=A8?= =?UTF-8?q?=EB=93=88=EC=97=90=EC=84=9C=20Core=20=EC=9D=98=EC=A1=B4?= =?UTF-8?q?=EC=84=B1=20=EC=A0=9C=EA=B1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Projects/App/Project.swift | 1 - 1 file changed, 1 deletion(-) diff --git a/Projects/App/Project.swift b/Projects/App/Project.swift index 4193a93d..46a88034 100644 --- a/Projects/App/Project.swift +++ b/Projects/App/Project.swift @@ -17,7 +17,6 @@ let project = Project.makeAppModule( scripts: [], dependencies: [ .Shared(implements: .Shareds), - .Core(implements: .Core), .Presentation(implements: .Presentation), .Data(implements: .Repository) ], From f2c66a382a2a59548f2968ecd7952c29a9d95e90 Mon Sep 17 00:00:00 2001 From: Roy Date: Fri, 2 Jan 2026 10:08:55 +0900 Subject: [PATCH 20/26] =?UTF-8?q?=F0=9F=94=A7[refactor]:=20Core=20?= =?UTF-8?q?=EB=AA=A8=EB=93=88=20=EC=99=84=EC=A0=84=20=EC=A0=9C=EA=B1=B0=20?= =?UTF-8?q?=EB=B0=8F=20=EC=9D=98=EC=A1=B4=EC=84=B1=20=EC=A0=95=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Projects/Core/Core/Project.swift | 18 ----------- .../Sources/CoreExported/CoreExported.swift | 12 -------- Projects/Core/Core/Tests/Sources/Test.swift | 8 ----- .../Auth/Login/Domain/LoginModel.swift | 30 ------------------- 4 files changed, 68 deletions(-) delete mode 100644 Projects/Core/Core/Project.swift delete mode 100644 Projects/Core/Core/Sources/CoreExported/CoreExported.swift delete mode 100644 Projects/Core/Core/Tests/Sources/Test.swift delete mode 100644 Projects/Core/Networking/Model/Sources/Auth/Login/Domain/LoginModel.swift diff --git a/Projects/Core/Core/Project.swift b/Projects/Core/Core/Project.swift deleted file mode 100644 index 03a74e37..00000000 --- a/Projects/Core/Core/Project.swift +++ /dev/null @@ -1,18 +0,0 @@ -import Foundation -import ProjectDescription -import DependencyPlugin -import ProjectTemplatePlugin -import ProjectTemplatePlugin -import DependencyPackagePlugin - -let project = Project.makeModule( - name: "Core", - bundleId: .appBundleID(name: ".Core"), - product: .staticFramework, - settings: .settings(), - dependencies: [ - .Network(implements: .Networks), - .Domain(implements: .UseCase) - ], - sources: ["Sources/**"] -) diff --git a/Projects/Core/Core/Sources/CoreExported/CoreExported.swift b/Projects/Core/Core/Sources/CoreExported/CoreExported.swift deleted file mode 100644 index 298e6d9d..00000000 --- a/Projects/Core/Core/Sources/CoreExported/CoreExported.swift +++ /dev/null @@ -1,12 +0,0 @@ -// -// CoreExported.swift -// Core -// -// Created by Wonji Suh on 7/23/25. -// - -@_exported import Networks -@_exported import UseCase -@_exported import DomainInterface -@_exported import Model - diff --git a/Projects/Core/Core/Tests/Sources/Test.swift b/Projects/Core/Core/Tests/Sources/Test.swift deleted file mode 100644 index 39c26a6c..00000000 --- a/Projects/Core/Core/Tests/Sources/Test.swift +++ /dev/null @@ -1,8 +0,0 @@ -// -// base.swift -// DDDAttendance -// -// Created by Roy on 2025-07-23 -// Copyright © 2025 DDD , Ltd. All rights reserved. -// - diff --git a/Projects/Core/Networking/Model/Sources/Auth/Login/Domain/LoginModel.swift b/Projects/Core/Networking/Model/Sources/Auth/Login/Domain/LoginModel.swift deleted file mode 100644 index 455e804d..00000000 --- a/Projects/Core/Networking/Model/Sources/Auth/Login/Domain/LoginModel.swift +++ /dev/null @@ -1,30 +0,0 @@ -// -// LoginModel.swift -// Model -// -// Created by Wonji Suh on 5/9/25. -// - -import Foundation - -public typealias LoginModel = BaseResponseDTO - -// MARK: - Welcome -public struct LoginResponseModel: Decodable, Equatable { - public let email: String - public let id: Int - public let accessToken, refreshToken: String - - - public init( - id: Int, - email: String, - accessToken: String, - refreshToken: String, - ) { - self.id = id - self.email = email - self.accessToken = accessToken - self.refreshToken = refreshToken - } -} From ba54524e893c0f65bc4044c6460e3524b9660ec4 Mon Sep 17 00:00:00 2001 From: Roy Date: Fri, 2 Jan 2026 10:09:27 +0900 Subject: [PATCH 21/26] =?UTF-8?q?=F0=9F=94=A7[refactor]:=20Token=20?= =?UTF-8?q?=EA=B8=B0=EB=B0=98=20=EC=9D=B8=EC=A6=9D=20=EC=8B=9C=EC=8A=A4?= =?UTF-8?q?=ED=85=9C=EC=9C=BC=EB=A1=9C=20=EC=A0=84=ED=99=98=20=EB=B0=8F=20?= =?UTF-8?q?SignUp=20=EB=AA=A8=EB=93=88=20=EC=A0=95=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Data/API/Sources/API/Auth/AuthAPI.swift | 5 +- .../Sources/Auth/Token/DTO/TokenDTO.swift | 12 +++ .../Sources/Auth/Token/Mapper/TokenDTO+.swift | 18 +++++ .../SignUp/SignUpUser/DTO/SignUpModel.swift | 40 ---------- .../SignUpUser/Domain/SignUpDTOModel.swift | 24 ------ .../Extension/Extension+SignUpModel.swift | 31 -------- .../Attendance/AttendanceRepositoryImpl.swift | 3 +- .../AccessTokenAuthenticator.swift | 78 +++++++++++++++++++ .../RefreshToken/AccessTokenCredential.swift | 60 ++++++++++++++ .../Auth/RefreshToken/AuthAPIManger.swift | 62 --------------- .../Auth/RefreshToken/AuthInterceptor.swift | 75 ------------------ .../RefreshToken/AuthSessionManager.swift | 65 ++++++++++++++++ .../Extension+MoyaProvider+Auth.swift | 19 +++++ .../Auth/Repository/AuthRepositoryImpl.swift | 23 ++++-- .../Profile/ProfileRepositoryImpl.swift | 3 +- .../Sources/QRCode/QRCodeRepositoryImpl.swift | 3 +- .../Schedule/ScheduleRepositoryImpl.swift | 3 +- .../Service/Sources/Auth/AuthService.swift | 15 +++- 18 files changed, 287 insertions(+), 252 deletions(-) create mode 100644 Projects/Data/Model/Sources/Auth/Token/DTO/TokenDTO.swift create mode 100644 Projects/Data/Model/Sources/Auth/Token/Mapper/TokenDTO+.swift delete mode 100644 Projects/Data/Model/Sources/SignUp/SignUpUser/DTO/SignUpModel.swift delete mode 100644 Projects/Data/Model/Sources/SignUp/SignUpUser/Domain/SignUpDTOModel.swift delete mode 100644 Projects/Data/Model/Sources/SignUp/SignUpUser/Extension/Extension+SignUpModel.swift create mode 100644 Projects/Data/Repository/Sources/Auth/RefreshToken/AccessTokenAuthenticator.swift create mode 100644 Projects/Data/Repository/Sources/Auth/RefreshToken/AccessTokenCredential.swift delete mode 100644 Projects/Data/Repository/Sources/Auth/RefreshToken/AuthAPIManger.swift delete mode 100644 Projects/Data/Repository/Sources/Auth/RefreshToken/AuthInterceptor.swift create mode 100644 Projects/Data/Repository/Sources/Auth/RefreshToken/AuthSessionManager.swift create mode 100644 Projects/Data/Repository/Sources/Auth/RefreshToken/Extension+MoyaProvider+Auth.swift diff --git a/Projects/Data/API/Sources/API/Auth/AuthAPI.swift b/Projects/Data/API/Sources/API/Auth/AuthAPI.swift index 94d15512..42c465fd 100644 --- a/Projects/Data/API/Sources/API/Auth/AuthAPI.swift +++ b/Projects/Data/API/Sources/API/Auth/AuthAPI.swift @@ -9,11 +9,14 @@ import Foundation public enum AuthAPI: String, CaseIterable { case login + case refresh - public var authDescription: String { + public var description: String { switch self { case .login: return "login" + case .refresh: + return "refresh" } } } diff --git a/Projects/Data/Model/Sources/Auth/Token/DTO/TokenDTO.swift b/Projects/Data/Model/Sources/Auth/Token/DTO/TokenDTO.swift new file mode 100644 index 00000000..5d28a535 --- /dev/null +++ b/Projects/Data/Model/Sources/Auth/Token/DTO/TokenDTO.swift @@ -0,0 +1,12 @@ +// +// TokenDTO.swift +// Model +// +// Created by Wonji Suh on 1/2/26. +// + +import Foundation + +public struct TokenDTO: Decodable { + let accessToken, refreshToken: String +} diff --git a/Projects/Data/Model/Sources/Auth/Token/Mapper/TokenDTO+.swift b/Projects/Data/Model/Sources/Auth/Token/Mapper/TokenDTO+.swift new file mode 100644 index 00000000..e0deb436 --- /dev/null +++ b/Projects/Data/Model/Sources/Auth/Token/Mapper/TokenDTO+.swift @@ -0,0 +1,18 @@ +// +// TokenDTO+.swift +// Model +// +// Created by Wonji Suh on 1/2/26. +// + +import Foundation +import Entity + +public extension TokenDTO { + func toDomain() -> AuthTokens { + return AuthTokens( + accessToken: self.accessToken, + refreshToken: self.refreshToken + ) + } +} diff --git a/Projects/Data/Model/Sources/SignUp/SignUpUser/DTO/SignUpModel.swift b/Projects/Data/Model/Sources/SignUp/SignUpUser/DTO/SignUpModel.swift deleted file mode 100644 index 5a9a38bd..00000000 --- a/Projects/Data/Model/Sources/SignUp/SignUpUser/DTO/SignUpModel.swift +++ /dev/null @@ -1,40 +0,0 @@ -// -// SignUpModel.swift -// Model -// -// Created by Wonji Suh on 5/7/25. -// - -import Foundation - -public typealias SignUpModel = BaseResponseDTO - -public struct SignUpResponseModel: Equatable, Decodable { - public let accessToken: String? - public let refreshToken: String? - public let user: SignUPUser? - - - public init( - accessToken: String, - refreshToken: String, - user: SignUPUser? - ) { - self.accessToken = accessToken - self.refreshToken = refreshToken - self.user = user - } -} - - -public struct SignUPUser: Equatable, Decodable { - public let username, email: String - - public init( - username: String, - email: String - ) { - self.username = username - self.email = email - } -} diff --git a/Projects/Data/Model/Sources/SignUp/SignUpUser/Domain/SignUpDTOModel.swift b/Projects/Data/Model/Sources/SignUp/SignUpUser/Domain/SignUpDTOModel.swift deleted file mode 100644 index 3314822a..00000000 --- a/Projects/Data/Model/Sources/SignUp/SignUpUser/Domain/SignUpDTOModel.swift +++ /dev/null @@ -1,24 +0,0 @@ -// -// SignUpDTOModel.swift -// Model -// -// Created by Wonji Suh on 5/7/25. -// - -import Foundation -// MARK: - Welcome -public struct SignUpDTOModel: Decodable, Equatable { - let access, refresh: String? - let user: UserDTO? - -} - -// MARK: - User -struct UserDTO: Decodable, Equatable { - let pk: Int? - let username, email: String? - - enum CodingKeys: String, CodingKey { - case pk, username, email - } -} diff --git a/Projects/Data/Model/Sources/SignUp/SignUpUser/Extension/Extension+SignUpModel.swift b/Projects/Data/Model/Sources/SignUp/SignUpUser/Extension/Extension+SignUpModel.swift deleted file mode 100644 index 6a1f7276..00000000 --- a/Projects/Data/Model/Sources/SignUp/SignUpUser/Extension/Extension+SignUpModel.swift +++ /dev/null @@ -1,31 +0,0 @@ -// -// Extension+SignUpModel.swift -// Model -// -// Created by Wonji Suh on 5/7/25. -// - -import Foundation - -public extension SignUpDTOModel { - func toDomain() -> SignUpModel { - - let user = SignUPUser( - username: self.user?.username ?? "", - email: self.user?.email ?? "", - ) - - let data = SignUpResponseModel( - accessToken: self.access ?? "", - refreshToken: self.refresh ?? "", - user: user - ) - - return SignUpModel( - code: .zero, - message: "", - data: data - ) - - } -} diff --git a/Projects/Data/Repository/Sources/Attendance/AttendanceRepositoryImpl.swift b/Projects/Data/Repository/Sources/Attendance/AttendanceRepositoryImpl.swift index f986850c..a3c816ac 100644 --- a/Projects/Data/Repository/Sources/Attendance/AttendanceRepositoryImpl.swift +++ b/Projects/Data/Repository/Sources/Attendance/AttendanceRepositoryImpl.swift @@ -17,7 +17,7 @@ final public class AttendanceRepositoryImpl: AttendanceInterface , Sendable { private let provider: MoyaProvider public init( - provider: MoyaProvider = MoyaProvider.withSession(AuthInterceptor.shared) + provider: MoyaProvider = MoyaProvider.authorized ) { self.provider = provider } @@ -90,4 +90,3 @@ final public class AttendanceRepositoryImpl: AttendanceInterface , Sendable { return response.data.toDomain() } } - diff --git a/Projects/Data/Repository/Sources/Auth/RefreshToken/AccessTokenAuthenticator.swift b/Projects/Data/Repository/Sources/Auth/RefreshToken/AccessTokenAuthenticator.swift new file mode 100644 index 00000000..dd11aa16 --- /dev/null +++ b/Projects/Data/Repository/Sources/Auth/RefreshToken/AccessTokenAuthenticator.swift @@ -0,0 +1,78 @@ +// +// AccessTokenAuthenticator.swift +// Repository +// +// Created by Wonji Suh on 1/2/26. +// + +import Foundation + +import Alamofire +import Dependencies +import DomainInterface +import Entity + +enum TokenRefreshError: Error { + case missingRefreshToken + case invalidAccessToken +} + +final class AccessTokenAuthenticator: Authenticator, @unchecked Sendable { + typealias Credential = AccessTokenCredential + + @Dependency(\.authRepository) private var authRepository + @Dependency(\.keychainManager) private var keychainManager + + func apply(_ credential: Credential, to urlRequest: inout URLRequest) { + urlRequest.headers.add(.authorization(bearerToken: credential.accessToken)) + } + + func refresh( + _ credential: Credential, + for session: Session, + completion: @escaping @Sendable (Result) -> Void + ) { + Task { + completion(await refreshCredential(credential)) + } + } + + func didRequest( + _ urlRequest: URLRequest, + with response: HTTPURLResponse, + failDueToAuthenticationError error: Error + ) -> Bool { + response.statusCode == 401 + } + + func isRequest( + _ urlRequest: URLRequest, + authenticatedWith credential: Credential + ) -> Bool { + urlRequest.headers["Authorization"] == "Bearer \(credential.accessToken)" + } +} + +private extension AccessTokenAuthenticator { + func refreshCredential(_ credential: Credential) async -> Result { + guard let refreshToken = keychainManager.refreshToken(), !refreshToken.isEmpty else { + return .failure(TokenRefreshError.missingRefreshToken) + } + + do { + let tokens = try await authRepository.refresh() + keychainManager.save(accessToken: tokens.accessToken, refreshToken: tokens.refreshToken) + + guard let refreshedCredential = AccessTokenCredential.make( + accessToken: tokens.accessToken, + refreshToken: tokens.refreshToken + ) else { + return .failure(TokenRefreshError.invalidAccessToken) + } + + return .success(refreshedCredential) + } catch { + return .failure(error) + } + } +} diff --git a/Projects/Data/Repository/Sources/Auth/RefreshToken/AccessTokenCredential.swift b/Projects/Data/Repository/Sources/Auth/RefreshToken/AccessTokenCredential.swift new file mode 100644 index 00000000..77171aa2 --- /dev/null +++ b/Projects/Data/Repository/Sources/Auth/RefreshToken/AccessTokenCredential.swift @@ -0,0 +1,60 @@ +// +// AccessTokenCredential.swift +// Repository +// +// Created by Wonji Suh on 1/2/26. +// + +import Foundation +import Alamofire + +struct AccessTokenCredential: AuthenticationCredential, Sendable { + let accessToken: String + let refreshToken: String + let expiration: Date + + private let refreshLeadTime: TimeInterval = 5 * 60 + + var requiresRefresh: Bool { + Date().addingTimeInterval(refreshLeadTime) >= expiration + } + + static func make( + accessToken: String, + refreshToken: String + ) -> AccessTokenCredential? { + guard let expiration = decodeExpiration(from: accessToken) else { return nil } + return AccessTokenCredential( + accessToken: accessToken, + refreshToken: refreshToken, + expiration: expiration + ) + } +} + +private extension AccessTokenCredential { + static func decodeExpiration(from token: String) -> Date? { + let components = token.components(separatedBy: ".") + guard components.count == 3 else { return nil } + + let payload = components[1] + var base64 = payload + .replacingOccurrences(of: "-", with: "+") + .replacingOccurrences(of: "_", with: "/") + + let paddingLength = 4 - (base64.count % 4) + if paddingLength < 4 { + base64 += String(repeating: "=", count: paddingLength) + } + + guard let data = Data(base64Encoded: base64) else { return nil } + guard + let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any], + let exp = json["exp"] as? TimeInterval + else { + return nil + } + + return Date(timeIntervalSince1970: exp) + } +} diff --git a/Projects/Data/Repository/Sources/Auth/RefreshToken/AuthAPIManger.swift b/Projects/Data/Repository/Sources/Auth/RefreshToken/AuthAPIManger.swift deleted file mode 100644 index 6f10bad9..00000000 --- a/Projects/Data/Repository/Sources/Auth/RefreshToken/AuthAPIManger.swift +++ /dev/null @@ -1,62 +0,0 @@ -// -// AuthAPIManger.swift -// UseCase -// -// Created by Wonji Suh on 7/22/25. -// - -import Foundation -import Model -import AsyncMoya -import Foundation -import API -import Alamofire -import Foundations -import ComposableArchitecture - - -//public final actor AuthAPIManger { -// public static let shared = AuthAPIManger() -// var loginModel: LoginModel? = nil -// @Shared(.inMemory("UserEntity")) var userEntity: UserEntity = .shared -// -// let repository = AuthRepositoryImpl() -// -// public init() {} -// -// public func refeshTokenRsponse(_ result: Result) -> Result { -// switch result { -// case .success(let refeshModel): -// self.loginModel = refeshModel -// APIHeader.accessTokenKeyChain = refeshModel.data.accessToken -// UserDefaults.standard.set(refeshModel.data.accessToken, forKey: "ACCESS_TOKEN") -// return .success(refeshModel) -// case .failure(let error): -// Log.error("리프레쉬 에러", error.localizedDescription) -// return .failure(error) -// } -// } -// -// public func getRefeshToken() async -> RetryResult { -// let authResultData = await Result { -// try await repository.loginUser(email: userEntity.userEmail) -// } -// -// switch authResultData { -// case .success(let authResultData): -// if let authResultData = authResultData{ -// _ = self.refeshTokenRsponse(.success(authResultData)) // 반환 값을 무시 -// return .retry -// } else { -// // authResultData가 nil일 경우 -// let error = CustomError.unknownError("AuthResultData is nil") -// _ = refeshTokenRsponse(.failure(error)) -// return .doNotRetryWithError(error) -// } -// -// case .failure(let error): -// _ = refeshTokenRsponse(.failure(CustomError.unknownError(error.localizedDescription))) // 반환 값을 무시 -// return .doNotRetryWithError(error) -// } -// } -//} diff --git a/Projects/Data/Repository/Sources/Auth/RefreshToken/AuthInterceptor.swift b/Projects/Data/Repository/Sources/Auth/RefreshToken/AuthInterceptor.swift deleted file mode 100644 index 7c6fdba5..00000000 --- a/Projects/Data/Repository/Sources/Auth/RefreshToken/AuthInterceptor.swift +++ /dev/null @@ -1,75 +0,0 @@ -// -// AuthInterceptor.swift -// UseCase -// -// Created by Wonji Suh on 7/22/25. -// - -import Alamofire -import AsyncMoya -import Foundation -import API - -public final actor AuthInterceptor: RequestInterceptor { - public static let shared = AuthInterceptor() - - private init() {} - - // Request를 수정하여 토큰을 추가하는 메서드 - public nonisolated func adapt( - _ urlRequest: URLRequest, - for session: Session, - completion: @escaping (Result) -> Void - ) async { - guard urlRequest.url?.absoluteString.hasPrefix(BaseAPI.base.apiDescription) == true else { - completion(.success(urlRequest)) - return - } - - // Retrieve the access token from the Keychain - let accessToken = UserDefaults.standard.string(forKey: "ACCESS_TOKEN") - var urlRequest = urlRequest - - // Set the token as the Authorization header - urlRequest.setValue(accessToken, forHTTPHeaderField: "Authorization") // Ensure the correct format is used - - Log.debug("Adapted request with headers: ", urlRequest.headers) - completion(.success(urlRequest)) - } - - public nonisolated func retry( - _ request: Request, - for session: Session, - dueTo error: Error, - completion: @escaping (RetryResult) -> Void) async { - Log.debug("Entered retry function") - typealias Task = _Concurrency.Task - // error의 상세 정보를 확인 - if let afError = error.asAFError, afError.isResponseValidationError { - Log.error("Response validation error detected.") - } else { - Log.error("Error is not responseValidationFailed: \(error)") - } - - // 401 상태 코드 확인 - guard let response = request.task?.response as? HTTPURLResponse else { - Log.debug("Response is not an HTTPURLResponse.") - completion(.doNotRetryWithError(error)) - return - } - - Log.debug("HTTP Status Code: \(response.statusCode)") - - switch response.statusCode { - case 400, 401: - Log.debug("401 Unauthorized detected, attempting to refresh token...") - Task { -// let retryResult = await AuthAPIManger.shared.getRefeshToken() -// completion(retryResult) - } - default: - Log.debug("Status code is not 401, not retrying.") - completion(.doNotRetryWithError(error)) - } - } -} diff --git a/Projects/Data/Repository/Sources/Auth/RefreshToken/AuthSessionManager.swift b/Projects/Data/Repository/Sources/Auth/RefreshToken/AuthSessionManager.swift new file mode 100644 index 00000000..042c7c7c --- /dev/null +++ b/Projects/Data/Repository/Sources/Auth/RefreshToken/AuthSessionManager.swift @@ -0,0 +1,65 @@ +// +// AuthSessionManager.swift +// Repository +// +// Created by Wonji Suh on 1/2/26. +// + +import Foundation + +import Alamofire +import DomainInterface +import Entity +import WeaveDI + +final class AuthSessionManager { + static let shared = AuthSessionManager() + + let authenticator: AccessTokenAuthenticator + let interceptor: AuthenticationInterceptor + let session: Session + + private init(authenticator: AccessTokenAuthenticator = AccessTokenAuthenticator()) { + self.authenticator = authenticator + + let initialCredential = AuthSessionManager.loadCredentialFromKeychain() + self.interceptor = AuthenticationInterceptor( + authenticator: authenticator, + credential: initialCredential + ) + self.session = Session(interceptor: interceptor) + } + + func updateCredential(with tokens: AuthTokens) { + guard let credential = AccessTokenCredential.make( + accessToken: tokens.accessToken, + refreshToken: tokens.refreshToken + ) else { + interceptor.credential = nil + return + } + + interceptor.credential = credential + } + + func clear() { + interceptor.credential = nil + } +} + +private extension AuthSessionManager { + static func loadCredentialFromKeychain() -> AccessTokenCredential? { + let keychainManager = UnifiedDI.resolve(KeychainManaging.self) ?? InMemoryKeychainManager() + guard + let accessToken = keychainManager.accessToken(), + let refreshToken = keychainManager.refreshToken() + else { + return nil + } + + return AccessTokenCredential.make( + accessToken: accessToken, + refreshToken: refreshToken + ) + } +} diff --git a/Projects/Data/Repository/Sources/Auth/RefreshToken/Extension+MoyaProvider+Auth.swift b/Projects/Data/Repository/Sources/Auth/RefreshToken/Extension+MoyaProvider+Auth.swift new file mode 100644 index 00000000..8bfc4aa7 --- /dev/null +++ b/Projects/Data/Repository/Sources/Auth/RefreshToken/Extension+MoyaProvider+Auth.swift @@ -0,0 +1,19 @@ +// +// Extension+MoyaProvider+Auth.swift +// Repository +// +// Created by Wonji Suh on 1/2/26. +// + +import AsyncMoya + +public extension MoyaProvider { + static var authorized: MoyaProvider { + MoyaProvider( + session: AuthSessionManager.shared.session, + plugins: [ + MoyaLoggingPlugin() + ] + ) + } +} diff --git a/Projects/Data/Repository/Sources/Auth/Repository/AuthRepositoryImpl.swift b/Projects/Data/Repository/Sources/Auth/Repository/AuthRepositoryImpl.swift index a7781ab9..cf40ed09 100644 --- a/Projects/Data/Repository/Sources/Auth/Repository/AuthRepositoryImpl.swift +++ b/Projects/Data/Repository/Sources/Auth/Repository/AuthRepositoryImpl.swift @@ -10,24 +10,25 @@ import Model import Entity import Service +import WeaveDI +import Dependencies @preconcurrency import AsyncMoya -import FirebaseFirestore -@Observable -final public class AuthRepositoryImpl: AuthInterface, Sendable { +final public class AuthRepositoryImpl: AuthInterface, @unchecked Sendable { + @Dependency(\.keychainManager) private var keychainManager private let provider: MoyaProvider + private let authProvider: MoyaProvider public init( - provider: MoyaProvider = MoyaProvider.default + provider: MoyaProvider = MoyaProvider.default, + authProvider: MoyaProvider = MoyaProvider.authorized ) { self.provider = provider + self.authProvider = authProvider } - - // MARK: - 회원가입한 유저 조회 - // MARK: - 로그인 API public func login( provider socialProvider: SocialType, @@ -38,4 +39,12 @@ final public class AuthRepositoryImpl: AuthInterface, Sendable { ) return dto.toDomain() } + + +// MARK: - 토큰 재발급 + public func refresh() async throws -> AuthTokens { + let refreshToken = keychainManager.refreshToken() ?? "" + let dto: TokenDTO = try await authProvider.request(.refresh(refreshToken: refreshToken)) + return dto.toDomain() + } } diff --git a/Projects/Data/Repository/Sources/Profile/ProfileRepositoryImpl.swift b/Projects/Data/Repository/Sources/Profile/ProfileRepositoryImpl.swift index 8fd5a070..58acbf40 100644 --- a/Projects/Data/Repository/Sources/Profile/ProfileRepositoryImpl.swift +++ b/Projects/Data/Repository/Sources/Profile/ProfileRepositoryImpl.swift @@ -20,7 +20,7 @@ final public class ProfileRepositoryImpl: ProfileInterface , Sendable{ private let provider: MoyaProvider public init( - provider: MoyaProvider = MoyaProvider.withSession(AuthInterceptor.shared) + provider: MoyaProvider = MoyaProvider.authorized ) { self.provider = provider } @@ -89,4 +89,3 @@ final public class ProfileRepositoryImpl: ProfileInterface , Sendable{ return response.data.toDomain() } } - diff --git a/Projects/Data/Repository/Sources/QRCode/QRCodeRepositoryImpl.swift b/Projects/Data/Repository/Sources/QRCode/QRCodeRepositoryImpl.swift index f5a109c5..b25aa42b 100644 --- a/Projects/Data/Repository/Sources/QRCode/QRCodeRepositoryImpl.swift +++ b/Projects/Data/Repository/Sources/QRCode/QRCodeRepositoryImpl.swift @@ -20,7 +20,7 @@ final public class QRCodeRepositoryImpl: QRCodeInterface { private let provider: MoyaProvider public init( - provider: MoyaProvider = MoyaProvider.withSession(AuthInterceptor.shared) + provider: MoyaProvider = MoyaProvider.authorized ) { self.provider = provider } @@ -66,4 +66,3 @@ final public class QRCodeRepositoryImpl: QRCodeInterface { return qrModel.toDomain() } } - diff --git a/Projects/Data/Repository/Sources/Schedule/ScheduleRepositoryImpl.swift b/Projects/Data/Repository/Sources/Schedule/ScheduleRepositoryImpl.swift index 290432d5..1fcde4c0 100644 --- a/Projects/Data/Repository/Sources/Schedule/ScheduleRepositoryImpl.swift +++ b/Projects/Data/Repository/Sources/Schedule/ScheduleRepositoryImpl.swift @@ -18,7 +18,7 @@ final public class ScheduleRepositoryImpl: ScheduleInterface { private let provider: MoyaProvider public init( - provider: MoyaProvider = MoyaProvider.withSession(AuthInterceptor.shared) + provider: MoyaProvider = MoyaProvider.authorized ) { self.provider = provider } @@ -36,4 +36,3 @@ final public class ScheduleRepositoryImpl: ScheduleInterface { return scheduleModel.toDomain() } } - diff --git a/Projects/Data/Service/Sources/Auth/AuthService.swift b/Projects/Data/Service/Sources/Auth/AuthService.swift index 5c1050d5..abd1b00c 100644 --- a/Projects/Data/Service/Sources/Auth/AuthService.swift +++ b/Projects/Data/Service/Sources/Auth/AuthService.swift @@ -14,10 +14,10 @@ import AsyncMoya public enum AuthService { case login(body: OAuthLoginRequest) + case refresh(refreshToken: String) } - extension AuthService: BaseTargetType { public typealias Domain = AttendanceDomain @@ -28,8 +28,10 @@ extension AuthService: BaseTargetType { public var urlPath: String { switch self { case .login: - return AuthAPI.login.authDescription - + return AuthAPI.login.description + + case .refresh: + return AuthAPI.refresh.description } } @@ -39,7 +41,7 @@ extension AuthService: BaseTargetType { public var method: Moya.Method { switch self { - case .login: + case .login, .refresh: return .post } } @@ -48,11 +50,16 @@ extension AuthService: BaseTargetType { switch self { case .login(let body): return body.toDictionary + + case .refresh(let refreshToken): + return refreshToken.toDictionary(key: "refreshToken") } } public var headers: [String : String]? { switch self { + case .refresh: + return APIHeader.baseHeader default: return APIHeader.notAccessTokenHeader } From 9425777a878d64012fa44665546cb4bcc4bcd7e2 Mon Sep 17 00:00:00 2001 From: Roy Date: Fri, 2 Jan 2026 10:11:33 +0900 Subject: [PATCH 22/26] =?UTF-8?q?=F0=9F=94=A7[refactor]:=20=ED=82=A4?= =?UTF-8?q?=EC=B2=B4=EC=9D=B8=20=ED=86=A0=ED=81=B0=20=EA=B4=80=EB=A6=AC=20?= =?UTF-8?q?=EC=8B=9C=EC=8A=A4=ED=85=9C=20=EA=B5=AC=ED=98=84=20=EB=B0=8F=20?= =?UTF-8?q?OAuth=20=ED=86=A0=ED=81=B0=20=EC=A0=80=EC=9E=A5=20=EB=A1=9C?= =?UTF-8?q?=EC=A7=81=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Projects/App/Sources/Di/DiRegister.swift | 1 + .../Sources/Auth/AuthInterface.swift | 1 + .../Auth/DefaultAuthRepositoryImpl.swift | 17 +++- .../DefaultMemoryKeychainManager.swift | 41 ++++++++ .../Manager/KeychainManagerInterface.swift | 38 ++++++++ .../Sources/Auth/AuthUseCaseImpl.swift | 4 + .../Sources/Manager/KeychainManager.swift | 96 +++++++++++++++++++ .../Sources/OAuth/UnifiedOAuthUseCase.swift | 15 ++- 8 files changed, 210 insertions(+), 3 deletions(-) create mode 100644 Projects/Domain/DomainInterface/Sources/Manager/DefaultMemoryKeychainManager.swift create mode 100644 Projects/Domain/DomainInterface/Sources/Manager/KeychainManagerInterface.swift create mode 100644 Projects/Domain/UseCase/Sources/Manager/KeychainManager.swift diff --git a/Projects/App/Sources/Di/DiRegister.swift b/Projects/App/Sources/Di/DiRegister.swift index b89e4778..a4676843 100644 --- a/Projects/App/Sources/Di/DiRegister.swift +++ b/Projects/App/Sources/Di/DiRegister.swift @@ -33,6 +33,7 @@ public class AppDIManager: @unchecked Sendable { .register { AppleOAuthRepositoryImpl() as AppleOAuthInterface } .register { AppleOAuthProvider() as AppleOAuthProviderInterface } .register { GoogleOAuthProvider() as GoogleOAuthProviderInterface } + // MARK: - 토큰 등록 관련 .register { KeychainManager() as KeychainManaging } .register { KeychainTokenProvider(keychainManager: KeychainManager()) as TokenProviding } // MARK: - 온보딩 관련 diff --git a/Projects/Domain/DomainInterface/Sources/Auth/AuthInterface.swift b/Projects/Domain/DomainInterface/Sources/Auth/AuthInterface.swift index 8ecabf9b..2dd84645 100644 --- a/Projects/Domain/DomainInterface/Sources/Auth/AuthInterface.swift +++ b/Projects/Domain/DomainInterface/Sources/Auth/AuthInterface.swift @@ -13,6 +13,7 @@ import Entity /// Auth 관련 비즈니스 로직을 위한 Interface 프로토콜 public protocol AuthInterface: Sendable { func login(provider: SocialType, token: String) async throws -> LoginEntity + func refresh() async throws -> AuthTokens } /// Auth Repository의 DependencyKey 구조체 diff --git a/Projects/Domain/DomainInterface/Sources/Auth/DefaultAuthRepositoryImpl.swift b/Projects/Domain/DomainInterface/Sources/Auth/DefaultAuthRepositoryImpl.swift index 884f2756..68270789 100644 --- a/Projects/Domain/DomainInterface/Sources/Auth/DefaultAuthRepositoryImpl.swift +++ b/Projects/Domain/DomainInterface/Sources/Auth/DefaultAuthRepositoryImpl.swift @@ -16,6 +16,21 @@ final public class DefaultAuthRepositoryImpl: AuthInterface { public init() {} public func login(provider: Entity.SocialType, token: String) async throws -> Entity.LoginEntity { - return LoginEntity(name: "", isNewUser: false, provider: .google, token: AuthTokens(accessToken: "", refreshToken: "")) + return LoginEntity( + name: "Mock User", + isNewUser: false, + provider: provider, + token: AuthTokens( + accessToken: "mock_access_token_\(UUID().uuidString)", + refreshToken: "mock_refresh_token_\(UUID().uuidString)" + ) + ) + } + + public func refresh() async throws -> Entity.AuthTokens { + return AuthTokens( + accessToken: "mock_refreshed_access_token_\(UUID().uuidString)", + refreshToken: "mock_refreshed_refresh_token_\(UUID().uuidString)" + ) } } diff --git a/Projects/Domain/DomainInterface/Sources/Manager/DefaultMemoryKeychainManager.swift b/Projects/Domain/DomainInterface/Sources/Manager/DefaultMemoryKeychainManager.swift new file mode 100644 index 00000000..f975c587 --- /dev/null +++ b/Projects/Domain/DomainInterface/Sources/Manager/DefaultMemoryKeychainManager.swift @@ -0,0 +1,41 @@ +// +// DefaultMemoryKeychainManager.swift +// DomainInterface +// +// Created by Wonji Suh on 1/2/26. +// + +import Foundation + +public final class InMemoryKeychainManager: KeychainManaging, @unchecked Sendable { + private var accessTokenStorage: String? + private var refreshTokenStorage: String? + + public init() {} + + public func save(accessToken: String, refreshToken: String) { + accessTokenStorage = accessToken + refreshTokenStorage = refreshToken + } + + public func saveAccessToken(_ token: String) { + accessTokenStorage = token + } + + public func saveRefreshToken(_ token: String) { + refreshTokenStorage = token + } + + public func accessToken() -> String? { + accessTokenStorage + } + + public func refreshToken() -> String? { + refreshTokenStorage + } + + public func clear() { + accessTokenStorage = nil + refreshTokenStorage = nil + } +} diff --git a/Projects/Domain/DomainInterface/Sources/Manager/KeychainManagerInterface.swift b/Projects/Domain/DomainInterface/Sources/Manager/KeychainManagerInterface.swift new file mode 100644 index 00000000..a62b18b9 --- /dev/null +++ b/Projects/Domain/DomainInterface/Sources/Manager/KeychainManagerInterface.swift @@ -0,0 +1,38 @@ +// +// KeychainManagerInterface.swift +// DomainInterface +// +// Created by Wonji Suh on 1/2/26. +// + +import Foundation +import WeaveDI + +public protocol KeychainManaging: Sendable { + func save(accessToken: String, refreshToken: String) + func saveAccessToken(_ token: String) + func saveRefreshToken(_ token: String) + func accessToken() -> String? + func refreshToken() -> String? + func clear() +} + +public struct KeychainManagerDependency: DependencyKey { + public static var liveValue: KeychainManaging { + UnifiedDI.resolve(KeychainManaging.self) ?? InMemoryKeychainManager() + } + + public static var testValue: KeychainManaging { + InMemoryKeychainManager() + } + + public static var previewValue: KeychainManaging = testValue +} + +public extension DependencyValues { + var keychainManager: KeychainManaging { + get { self[KeychainManagerDependency.self] } + set { self[KeychainManagerDependency.self] = newValue } + } +} + diff --git a/Projects/Domain/UseCase/Sources/Auth/AuthUseCaseImpl.swift b/Projects/Domain/UseCase/Sources/Auth/AuthUseCaseImpl.swift index 166ee855..9877a6f1 100644 --- a/Projects/Domain/UseCase/Sources/Auth/AuthUseCaseImpl.swift +++ b/Projects/Domain/UseCase/Sources/Auth/AuthUseCaseImpl.swift @@ -22,6 +22,10 @@ public struct AuthUseCaseImpl: AuthInterface { ) async throws -> Entity.LoginEntity { return try await authRepository.login(provider: provider, token: token) } + + public func refresh() async throws -> Entity.AuthTokens { + return try await authRepository.refresh() + } } extension AuthUseCaseImpl: DependencyKey { diff --git a/Projects/Domain/UseCase/Sources/Manager/KeychainManager.swift b/Projects/Domain/UseCase/Sources/Manager/KeychainManager.swift new file mode 100644 index 00000000..581755eb --- /dev/null +++ b/Projects/Domain/UseCase/Sources/Manager/KeychainManager.swift @@ -0,0 +1,96 @@ +// +// KeychainManager.swift +// UseCase +// +// Created by Wonji Suh on 1/2/26. +// + +import Foundation + +import DomainInterface +import Security + +public final class KeychainManager: KeychainManaging, @unchecked Sendable { + private let service: String + + private enum Key { + static let accessToken = "ACCESS_TOKEN" + static let refreshToken = "REFRESH_TOKEN" + } + + public init(service: String = "io.dddstudy.attendance") { + self.service = service + } + + public func save(accessToken: String, refreshToken: String) { + saveAccessToken(accessToken) + saveRefreshToken(refreshToken) + } + + public func saveAccessToken(_ token: String) { + save(token, for: Key.accessToken) + } + + public func saveRefreshToken(_ token: String) { + save(token, for: Key.refreshToken) + } + + public func accessToken() -> String? { + read(for: Key.accessToken) + } + + public func refreshToken() -> String? { + read(for: Key.refreshToken) + } + + public func clear() { + delete(for: Key.accessToken) + delete(for: Key.refreshToken) + } + + private func save(_ value: String, for key: String) { + let data = Data(value.utf8) + let query: [CFString: Any] = [ + kSecClass: kSecClassGenericPassword, + kSecAttrService: service, + kSecAttrAccount: key + ] + + let attributes: [CFString: Any] = [ + kSecValueData: data + ] + + let status = SecItemUpdate(query as CFDictionary, attributes as CFDictionary) + if status == errSecItemNotFound { + var addQuery = query + addQuery[kSecValueData] = data + _ = SecItemAdd(addQuery as CFDictionary, nil) + } + } + + private func read(for key: String) -> String? { + let query: [CFString: Any] = [ + kSecClass: kSecClassGenericPassword, + kSecAttrService: service, + kSecAttrAccount: key, + kSecReturnData: true, + kSecMatchLimit: kSecMatchLimitOne + ] + + var result: AnyObject? + let status = SecItemCopyMatching(query as CFDictionary, &result) + guard status == errSecSuccess, let data = result as? Data else { + return nil + } + return String(data: data, encoding: .utf8) + } + + private func delete(for key: String) { + let query: [CFString: Any] = [ + kSecClass: kSecClassGenericPassword, + kSecAttrService: service, + kSecAttrAccount: key + ] + SecItemDelete(query as CFDictionary) + } +} diff --git a/Projects/Domain/UseCase/Sources/OAuth/UnifiedOAuthUseCase.swift b/Projects/Domain/UseCase/Sources/OAuth/UnifiedOAuthUseCase.swift index 0875ef2c..065c1a0d 100644 --- a/Projects/Domain/UseCase/Sources/OAuth/UnifiedOAuthUseCase.swift +++ b/Projects/Domain/UseCase/Sources/OAuth/UnifiedOAuthUseCase.swift @@ -18,6 +18,7 @@ public struct UnifiedOAuthUseCase { @Dependency(\.authRepository) private var authRepository: AuthInterface @Dependency(\.appleOAuthProvider) private var appleProvider: AppleOAuthProviderInterface @Dependency(\.googleOAuthProvider) private var googleProvider: GoogleOAuthProviderInterface + @Dependency(\.keychainManager) private var keychainManager: KeychainManaging @Shared(.inMemory("UserSession")) var userSession: UserSession = .empty public init() {} } @@ -58,10 +59,15 @@ public extension UnifiedOAuthUseCase { ) Log.debug("apple authcode", payload.authorizationCode) self.$userSession.withLock { $0.token = payload.idToken } - return try await authRepository.login( + let loginEntity = try await authRepository.login( provider: .apple, token: payload.authorizationCode ?? "" ) + keychainManager.save( + accessToken: loginEntity.token.accessToken, + refreshToken: loginEntity.token.refreshToken + ) + return loginEntity } /// Google 로그인 처리 @@ -70,10 +76,15 @@ public extension UnifiedOAuthUseCase { ) async throws -> LoginEntity { let processedToken = try await googleProvider.signInWithToken(token: token) self.$userSession.withLock { $0.token = processedToken } - return try await authRepository.login( + let loginEntity = try await authRepository.login( provider: .google, token: processedToken ) + keychainManager.save( + accessToken: loginEntity.token.accessToken, + refreshToken: loginEntity.token.refreshToken + ) + return loginEntity } /// OAuth 플로우 처리 (TCA용) From 05e8c68d442514a91c66082b626634ae9a6e718e Mon Sep 17 00:00:00 2001 From: Roy Date: Fri, 2 Jan 2026 13:24:30 +0900 Subject: [PATCH 23/26] =?UTF-8?q?=F0=9F=94=A7[chore]:=20=EC=95=B1=20?= =?UTF-8?q?=EB=B9=8C=EB=93=9C=20=EB=B2=84=EC=A0=84=2048=EC=97=90=EC=84=9C?= =?UTF-8?q?=2049=EB=A1=9C=20=EC=97=85=EB=8D=B0=EC=9D=B4=ED=8A=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Project+Templete/Extension+String.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Plugins/ProjectTemplatePlugin/ProjectDescriptionHelpers/Project+Templete/Extension+String.swift b/Plugins/ProjectTemplatePlugin/ProjectDescriptionHelpers/Project+Templete/Extension+String.swift index 58054f91..7eb3a9c6 100644 --- a/Plugins/ProjectTemplatePlugin/ProjectDescriptionHelpers/Project+Templete/Extension+String.swift +++ b/Plugins/ProjectTemplatePlugin/ProjectDescriptionHelpers/Project+Templete/Extension+String.swift @@ -17,7 +17,7 @@ extension String { return Project.Environment.bundlePrefix } - public static func appBuildVersion(buildVersion: String = "48") -> String { + public static func appBuildVersion(buildVersion: String = "49") -> String { return buildVersion } From aa3e99cef1be123c7b6204c3baf34e722e929ba3 Mon Sep 17 00:00:00 2001 From: Roy Date: Fri, 2 Jan 2026 13:28:32 +0900 Subject: [PATCH 24/26] =?UTF-8?q?=F0=9F=94=A7[feat]:=20=ED=9A=8C=EC=9B=90?= =?UTF-8?q?=ED=83=88=ED=87=B4=20API=20=EA=B5=AC=ED=98=84=20=EB=B0=8F=20Wit?= =?UTF-8?q?hdrawDTO=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Data/API/Sources/API/Auth/AuthAPI.swift | 3 +++ .../Sources/API/Base/AttendanceDomain.swift | 1 - .../Sources/Auth/Withdraw/WithdrawDTO+.swift | 20 ++++++++++++++ .../Sources/Auth/Withdraw/WithdrawDTO.swift | 20 ++++++++++++++ .../Extension+MoyaProvider+Response.swift | 21 +++++++++++++++ .../Auth/Repository/AuthRepositoryImpl.swift | 26 +++++++++++++++++++ .../Service/Sources/Auth/AuthService.swift | 26 ++++++++++++++----- .../Sources/APIHeader/APIHeader.swift | 3 --- 8 files changed, 110 insertions(+), 10 deletions(-) create mode 100644 Projects/Data/Model/Sources/Auth/Withdraw/WithdrawDTO+.swift create mode 100644 Projects/Data/Model/Sources/Auth/Withdraw/WithdrawDTO.swift create mode 100644 Projects/Data/Repository/Sources/Auth/RefreshToken/Extension+MoyaProvider+Response.swift diff --git a/Projects/Data/API/Sources/API/Auth/AuthAPI.swift b/Projects/Data/API/Sources/API/Auth/AuthAPI.swift index 42c465fd..e816317f 100644 --- a/Projects/Data/API/Sources/API/Auth/AuthAPI.swift +++ b/Projects/Data/API/Sources/API/Auth/AuthAPI.swift @@ -10,6 +10,7 @@ import Foundation public enum AuthAPI: String, CaseIterable { case login case refresh + case withDraw public var description: String { switch self { @@ -17,6 +18,8 @@ public enum AuthAPI: String, CaseIterable { return "login" case .refresh: return "refresh" + case .withDraw: + return "/me" } } } diff --git a/Projects/Data/API/Sources/API/Base/AttendanceDomain.swift b/Projects/Data/API/Sources/API/Base/AttendanceDomain.swift index 25a0364e..11840c32 100644 --- a/Projects/Data/API/Sources/API/Base/AttendanceDomain.swift +++ b/Projects/Data/API/Sources/API/Base/AttendanceDomain.swift @@ -46,4 +46,3 @@ extension AttendanceDomain: DomainType { } } } - diff --git a/Projects/Data/Model/Sources/Auth/Withdraw/WithdrawDTO+.swift b/Projects/Data/Model/Sources/Auth/Withdraw/WithdrawDTO+.swift new file mode 100644 index 00000000..bcd5464b --- /dev/null +++ b/Projects/Data/Model/Sources/Auth/Withdraw/WithdrawDTO+.swift @@ -0,0 +1,20 @@ +// +// WithdrawDTO+.swift +// Model +// +// Created by Wonji Suh on 1/2/26. +// + +import Foundation +import Entity + +public extension WithdrawDTO { + func toDomain(isSuccess: Bool) -> WithdrawEntity { + WithdrawEntity( + isSuccess: isSuccess, + code: code, + message: message, + detail: detail + ) + } +} diff --git a/Projects/Data/Model/Sources/Auth/Withdraw/WithdrawDTO.swift b/Projects/Data/Model/Sources/Auth/Withdraw/WithdrawDTO.swift new file mode 100644 index 00000000..f3064044 --- /dev/null +++ b/Projects/Data/Model/Sources/Auth/Withdraw/WithdrawDTO.swift @@ -0,0 +1,20 @@ +// +// WithdrawDTO.swift +// Model +// +// Created by Wonji Suh on 1/2/26. +// + +import Foundation + +public struct WithdrawDTO: Decodable { + public let code: String? + public let message: String? + public let detail: String? + + public init(code: String? = nil, message: String? = nil, detail: String? = nil) { + self.code = code + self.message = message + self.detail = detail + } +} diff --git a/Projects/Data/Repository/Sources/Auth/RefreshToken/Extension+MoyaProvider+Response.swift b/Projects/Data/Repository/Sources/Auth/RefreshToken/Extension+MoyaProvider+Response.swift new file mode 100644 index 00000000..2570f42f --- /dev/null +++ b/Projects/Data/Repository/Sources/Auth/RefreshToken/Extension+MoyaProvider+Response.swift @@ -0,0 +1,21 @@ +// +// Extension+MoyaProvider+Response.swift +// Repository +// +// Created by Wonji Suh on 1/2/26. +// + +import Foundation + +import AsyncMoya +import Moya + +public extension MoyaProvider { + func requestResponse(_ target: Target) async throws -> Response { + try await withCheckedThrowingContinuation { continuation in + request(target) { result in + continuation.resume(with: result) + } + } + } +} diff --git a/Projects/Data/Repository/Sources/Auth/Repository/AuthRepositoryImpl.swift b/Projects/Data/Repository/Sources/Auth/Repository/AuthRepositoryImpl.swift index cf40ed09..3e1c526c 100644 --- a/Projects/Data/Repository/Sources/Auth/Repository/AuthRepositoryImpl.swift +++ b/Projects/Data/Repository/Sources/Auth/Repository/AuthRepositoryImpl.swift @@ -12,6 +12,7 @@ import Entity import Service import WeaveDI import Dependencies +import Moya @preconcurrency import AsyncMoya @@ -47,4 +48,29 @@ final public class AuthRepositoryImpl: AuthInterface, @unchecked Sendable { let dto: TokenDTO = try await authProvider.request(.refresh(refreshToken: refreshToken)) return dto.toDomain() } + + // MARK: - 계정 삭제 + public func withDraw(token: String) async throws -> WithdrawEntity { + let response = try await provider.requestResponse(.withdraw(token: token)) + let decoder = JSONDecoder() + + if (200...299).contains(response.statusCode) { + if response.data.isEmpty { + return WithdrawEntity(isSuccess: true) + } + if let successDTO = try? decoder.decode(WithdrawDTO.self, from: response.data) { + return successDTO.toDomain(isSuccess: true) + } + return WithdrawEntity(isSuccess: true) + } + + if let errorDTO = try? decoder.decode(WithdrawDTO.self, from: response.data) { + return errorDTO.toDomain(isSuccess: false) + } + return WithdrawEntity( + isSuccess: false, + message: String(data: response.data, encoding: .utf8) + ) + } + } diff --git a/Projects/Data/Service/Sources/Auth/AuthService.swift b/Projects/Data/Service/Sources/Auth/AuthService.swift index abd1b00c..22b16a9e 100644 --- a/Projects/Data/Service/Sources/Auth/AuthService.swift +++ b/Projects/Data/Service/Sources/Auth/AuthService.swift @@ -15,6 +15,7 @@ import AsyncMoya public enum AuthService { case login(body: OAuthLoginRequest) case refresh(refreshToken: String) + case withdraw(token: String) } @@ -22,16 +23,24 @@ extension AuthService: BaseTargetType { public typealias Domain = AttendanceDomain public var domain: AttendanceDomain { - return .auth + switch self { + case .login, .refresh: + return .auth + case .withdraw: + return .user + } } public var urlPath: String { switch self { case .login: return AuthAPI.login.description - + case .refresh: return AuthAPI.refresh.description + + case .withdraw: + return AuthAPI.withDraw.description } } @@ -42,7 +51,9 @@ extension AuthService: BaseTargetType { public var method: Moya.Method { switch self { case .login, .refresh: - return .post + return .post + case .withdraw: + return .delete } } @@ -53,15 +64,18 @@ extension AuthService: BaseTargetType { case .refresh(let refreshToken): return refreshToken.toDictionary(key: "refreshToken") + + case .withdraw(let token): + return token.toDictionary(key: "token") } } public var headers: [String : String]? { switch self { - case .refresh: + case .refresh, .withdraw: return APIHeader.baseHeader - default: - return APIHeader.notAccessTokenHeader + default: + return APIHeader.notAccessTokenHeader } } } diff --git a/Projects/Network/Foundations/Sources/APIHeader/APIHeader.swift b/Projects/Network/Foundations/Sources/APIHeader/APIHeader.swift index 53439241..1a68737e 100644 --- a/Projects/Network/Foundations/Sources/APIHeader/APIHeader.swift +++ b/Projects/Network/Foundations/Sources/APIHeader/APIHeader.swift @@ -7,10 +7,7 @@ import Foundation -import ComposableArchitecture import WeaveDI -import Model - public struct APIHeader { From 6d926f2925a0f241ff7851ab5d754ed83d887c6d Mon Sep 17 00:00:00 2001 From: Roy Date: Fri, 2 Jan 2026 13:28:49 +0900 Subject: [PATCH 25/26] =?UTF-8?q?=F0=9F=94=A7[feat]:=20=ED=9A=8C=EC=9B=90?= =?UTF-8?q?=ED=83=88=ED=87=B4=20=EA=B8=B0=EB=8A=A5=20=EB=B0=8F=20OAuth=20?= =?UTF-8?q?=ED=86=A0=ED=81=B0=20=EA=B4=80=EB=A6=AC=20=EA=B0=9C=EC=84=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Projects/App/Sources/Di/DiRegister.swift | 2 +- .../Sources/Auth/AuthInterface.swift | 1 + .../Auth/DefaultAuthRepositoryImpl.swift | 5 +- .../Entity/Sources/Auth/WithdrawEntity.swift | 27 ++++++++++ .../Entity/Sources/Error/AuthError.swift | 50 +++++++++++++++++++ .../Sources/Auth/AuthUseCaseImpl.swift | 5 ++ .../Provider/Apple/AppleOAuthProvider.swift | 2 - .../Sources/OAuth/UnifiedOAuthUseCase.swift | 5 +- 8 files changed, 92 insertions(+), 5 deletions(-) create mode 100644 Projects/Domain/Entity/Sources/Auth/WithdrawEntity.swift diff --git a/Projects/App/Sources/Di/DiRegister.swift b/Projects/App/Sources/Di/DiRegister.swift index a4676843..52c4facc 100644 --- a/Projects/App/Sources/Di/DiRegister.swift +++ b/Projects/App/Sources/Di/DiRegister.swift @@ -9,8 +9,8 @@ import Foundation import DomainInterface import Repository -import Core import Foundations +import UseCase import ComposableArchitecture import WeaveDI diff --git a/Projects/Domain/DomainInterface/Sources/Auth/AuthInterface.swift b/Projects/Domain/DomainInterface/Sources/Auth/AuthInterface.swift index 2dd84645..1114bfb9 100644 --- a/Projects/Domain/DomainInterface/Sources/Auth/AuthInterface.swift +++ b/Projects/Domain/DomainInterface/Sources/Auth/AuthInterface.swift @@ -14,6 +14,7 @@ import Entity public protocol AuthInterface: Sendable { func login(provider: SocialType, token: String) async throws -> LoginEntity func refresh() async throws -> AuthTokens + func withDraw(token: String) async throws -> WithdrawEntity } /// Auth Repository의 DependencyKey 구조체 diff --git a/Projects/Domain/DomainInterface/Sources/Auth/DefaultAuthRepositoryImpl.swift b/Projects/Domain/DomainInterface/Sources/Auth/DefaultAuthRepositoryImpl.swift index 68270789..d0e476d4 100644 --- a/Projects/Domain/DomainInterface/Sources/Auth/DefaultAuthRepositoryImpl.swift +++ b/Projects/Domain/DomainInterface/Sources/Auth/DefaultAuthRepositoryImpl.swift @@ -12,7 +12,6 @@ import Entity /// Auth Repository의 기본 구현체 (테스트/프리뷰용) final public class DefaultAuthRepositoryImpl: AuthInterface { - public init() {} public func login(provider: Entity.SocialType, token: String) async throws -> Entity.LoginEntity { @@ -33,4 +32,8 @@ final public class DefaultAuthRepositoryImpl: AuthInterface { refreshToken: "mock_refreshed_refresh_token_\(UUID().uuidString)" ) } + + public func withDraw(token: String) async throws -> WithdrawEntity { + return WithdrawEntity(isSuccess: true) + } } diff --git a/Projects/Domain/Entity/Sources/Auth/WithdrawEntity.swift b/Projects/Domain/Entity/Sources/Auth/WithdrawEntity.swift new file mode 100644 index 00000000..671815cc --- /dev/null +++ b/Projects/Domain/Entity/Sources/Auth/WithdrawEntity.swift @@ -0,0 +1,27 @@ +// +// WithdrawEntity.swift +// Entity +// +// Created by Wonji Suh on 1/2/26. +// + +import Foundation + +public struct WithdrawEntity: Equatable { + public let isSuccess: Bool + public let code: String? + public let message: String? + public let detail: String? + + public init( + isSuccess: Bool, + code: String? = nil, + message: String? = nil, + detail: String? = nil + ) { + self.isSuccess = isSuccess + self.code = code + self.message = message + self.detail = detail + } +} diff --git a/Projects/Domain/Entity/Sources/Error/AuthError.swift b/Projects/Domain/Entity/Sources/Error/AuthError.swift index 900ff210..22745c5b 100644 --- a/Projects/Domain/Entity/Sources/Error/AuthError.swift +++ b/Projects/Domain/Entity/Sources/Error/AuthError.swift @@ -24,6 +24,12 @@ public enum AuthError: Error, Equatable, LocalizedError, Hashable { case backendError(String) /// 약관 동의가 필요한 경우 case needsTermsAgreement(String) + /// 회원 탈퇴 실패 + case accountDeletionFailed + /// 회원 탈퇴 권한 없음 + case accountDeletionNotAllowed + /// 이미 탈퇴된 계정 + case accountAlreadyDeleted /// 그 외 알 수 없는 에러 case unknownError(String) @@ -47,8 +53,52 @@ public enum AuthError: Error, Equatable, LocalizedError, Hashable { return "서버에서 오류가 발생했습니다: \(message)" case .needsTermsAgreement(let message): return "\(message)" + case .accountDeletionFailed: + return "회원 탈퇴에 실패했습니다." + case .accountDeletionNotAllowed: + return "회원 탈퇴 권한이 없습니다." + case .accountAlreadyDeleted: + return "이미 탈퇴된 계정입니다." case .unknownError(let message): return "알 수 없는 오류가 발생했습니다: \(message)" } } } + +// MARK: - Convenience Methods + +public extension AuthError { + static func from(_ error: Error) -> AuthError { + if let authError = error as? AuthError { + return authError + } + return .unknownError(error.localizedDescription) + } + + var isNetworkError: Bool { + switch self { + case .networkError: + return true + default: + return false + } + } + + var isRetryable: Bool { + switch self { + case .networkError, .backendError: + return true + default: + return false + } + } + + var isAccountDeletionError: Bool { + switch self { + case .accountDeletionFailed, .accountDeletionNotAllowed, .accountAlreadyDeleted: + return true + default: + return false + } + } +} diff --git a/Projects/Domain/UseCase/Sources/Auth/AuthUseCaseImpl.swift b/Projects/Domain/UseCase/Sources/Auth/AuthUseCaseImpl.swift index 9877a6f1..d0ccc22c 100644 --- a/Projects/Domain/UseCase/Sources/Auth/AuthUseCaseImpl.swift +++ b/Projects/Domain/UseCase/Sources/Auth/AuthUseCaseImpl.swift @@ -9,6 +9,7 @@ import DomainInterface import Entity import WeaveDI +import Foundation public struct AuthUseCaseImpl: AuthInterface { @Dependency(\.authRepository) var authRepository @@ -26,6 +27,10 @@ public struct AuthUseCaseImpl: AuthInterface { public func refresh() async throws -> Entity.AuthTokens { return try await authRepository.refresh() } + + public func withDraw(token: String) async throws -> WithdrawEntity { + return try await authRepository.withDraw(token: token) + } } extension AuthUseCaseImpl: DependencyKey { diff --git a/Projects/Domain/UseCase/Sources/OAuth/Provider/Apple/AppleOAuthProvider.swift b/Projects/Domain/UseCase/Sources/OAuth/Provider/Apple/AppleOAuthProvider.swift index 7d91ac81..6c979ee8 100644 --- a/Projects/Domain/UseCase/Sources/OAuth/Provider/Apple/AppleOAuthProvider.swift +++ b/Projects/Domain/UseCase/Sources/OAuth/Provider/Apple/AppleOAuthProvider.swift @@ -30,8 +30,6 @@ public final class AppleOAuthProvider: AppleOAuthProviderInterface, @unchecked S public func signIn() async throws -> AppleOAuthPayload { let payload = try await appleRepository.signIn() Log.info("Apple sign-in completed through repository (direct)") - self.$userSession.withLock { $0.accessToken = payload.authorizationCode ?? "" - } return payload } diff --git a/Projects/Domain/UseCase/Sources/OAuth/UnifiedOAuthUseCase.swift b/Projects/Domain/UseCase/Sources/OAuth/UnifiedOAuthUseCase.swift index 065c1a0d..1ff2db16 100644 --- a/Projects/Domain/UseCase/Sources/OAuth/UnifiedOAuthUseCase.swift +++ b/Projects/Domain/UseCase/Sources/OAuth/UnifiedOAuthUseCase.swift @@ -58,7 +58,10 @@ public extension UnifiedOAuthUseCase { nonce: nonce ) Log.debug("apple authcode", payload.authorizationCode) - self.$userSession.withLock { $0.token = payload.idToken } + self.$userSession.withLock { + $0.token = payload.idToken + $0.accessToken = payload.authorizationCode ?? "" + } let loginEntity = try await authRepository.login( provider: .apple, token: payload.authorizationCode ?? "" From f39e8c3e476d0604e878cc010262f47a5a26eefe Mon Sep 17 00:00:00 2001 From: Roy Date: Fri, 2 Jan 2026 13:29:05 +0900 Subject: [PATCH 26/26] =?UTF-8?q?=F0=9F=94=A7[refactor]:=20Core=20?= =?UTF-8?q?=EB=AA=A8=EB=93=88=20=EC=A0=9C=EA=B1=B0=20=EB=B0=8F=20import=20?= =?UTF-8?q?=EA=B5=AC=EC=A1=B0=20=EA=B0=9C=EC=84=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Reducer/AuthCoordinator.swift | 1 - .../Auth/Sources/Login/Reducer/Login.swift | 1 - .../Reducer/SignUpInviteCode.swift | 1 - .../SignUpName/Reducer/SignUpName.swift | 1 - .../SignUpPart/Reducer/SignUpPart.swift | 2 +- .../SignUpPart/View/SignUpPartView.swift | 1 - .../Reducer/SignUpSelectManging.swift | 3 +- .../Reducer/SignUpSelectTeam.swift | 4 +- .../Reducer/AttendanceCheck.swift | 2 +- .../Reducer/StaffCoordinator.swift | 1 - .../Sources/QrCode/Reducer/QRCode.swift | 5 +- .../Reducer/ScheduleManager.swift | 2 +- .../Sources/StaffMain/Reducer/Staff.swift | 2 +- .../Reducer/MemberCoordinator.swift | 1 - .../MemberMain/Reducer/MemberMain.swift | 3 +- .../Sources/QRCode/Reducer/MemberQRCode.swift | 2 +- .../CreateApp/View/CreateAppView.swift | 2 +- .../Sources/Main/Reducer/ProfileReducer.swift | 97 ++++++++++++++++--- .../Sources/Main/View/ProfileView.swift | 52 +++++++--- .../Splash/Sources/Reducer/Splash.swift | 3 +- 20 files changed, 140 insertions(+), 46 deletions(-) diff --git a/Projects/Presentation/Auth/Sources/AuthCoordinator/Reducer/AuthCoordinator.swift b/Projects/Presentation/Auth/Sources/AuthCoordinator/Reducer/AuthCoordinator.swift index 80a1f68e..94afaafa 100644 --- a/Projects/Presentation/Auth/Sources/AuthCoordinator/Reducer/AuthCoordinator.swift +++ b/Projects/Presentation/Auth/Sources/AuthCoordinator/Reducer/AuthCoordinator.swift @@ -7,7 +7,6 @@ import Foundation -import Core import Utill import Entity diff --git a/Projects/Presentation/Auth/Sources/Login/Reducer/Login.swift b/Projects/Presentation/Auth/Sources/Login/Reducer/Login.swift index 59a090d7..c0432525 100644 --- a/Projects/Presentation/Auth/Sources/Login/Reducer/Login.swift +++ b/Projects/Presentation/Auth/Sources/Login/Reducer/Login.swift @@ -7,7 +7,6 @@ import Foundation -import Core import Utill import Entity diff --git a/Projects/Presentation/Auth/Sources/SignUpInviteCode/Reducer/SignUpInviteCode.swift b/Projects/Presentation/Auth/Sources/SignUpInviteCode/Reducer/SignUpInviteCode.swift index c3287e7b..00b9c871 100644 --- a/Projects/Presentation/Auth/Sources/SignUpInviteCode/Reducer/SignUpInviteCode.swift +++ b/Projects/Presentation/Auth/Sources/SignUpInviteCode/Reducer/SignUpInviteCode.swift @@ -7,7 +7,6 @@ import Foundation -import Core import Utill import Entity import Model diff --git a/Projects/Presentation/Auth/Sources/SignUpName/Reducer/SignUpName.swift b/Projects/Presentation/Auth/Sources/SignUpName/Reducer/SignUpName.swift index bbe472fe..b8984c5c 100644 --- a/Projects/Presentation/Auth/Sources/SignUpName/Reducer/SignUpName.swift +++ b/Projects/Presentation/Auth/Sources/SignUpName/Reducer/SignUpName.swift @@ -7,7 +7,6 @@ import Foundation -import Core import Utill import Entity diff --git a/Projects/Presentation/Auth/Sources/SignUpPart/Reducer/SignUpPart.swift b/Projects/Presentation/Auth/Sources/SignUpPart/Reducer/SignUpPart.swift index fe154e95..6a450a51 100644 --- a/Projects/Presentation/Auth/Sources/SignUpPart/Reducer/SignUpPart.swift +++ b/Projects/Presentation/Auth/Sources/SignUpPart/Reducer/SignUpPart.swift @@ -7,11 +7,11 @@ import Foundation -import Core import Utill import Entity import ComposableArchitecture +import LogMacro @Reducer public struct SignUpPart { diff --git a/Projects/Presentation/Auth/Sources/SignUpPart/View/SignUpPartView.swift b/Projects/Presentation/Auth/Sources/SignUpPart/View/SignUpPartView.swift index b2e230e4..deb2e88e 100644 --- a/Projects/Presentation/Auth/Sources/SignUpPart/View/SignUpPartView.swift +++ b/Projects/Presentation/Auth/Sources/SignUpPart/View/SignUpPartView.swift @@ -8,7 +8,6 @@ import SwiftUI import DesignSystem -import Core import SDWebImageSwiftUI import ComposableArchitecture diff --git a/Projects/Presentation/Auth/Sources/SignUpSelectManging/Reducer/SignUpSelectManging.swift b/Projects/Presentation/Auth/Sources/SignUpSelectManging/Reducer/SignUpSelectManging.swift index 708789a4..9add3f26 100644 --- a/Projects/Presentation/Auth/Sources/SignUpSelectManging/Reducer/SignUpSelectManging.swift +++ b/Projects/Presentation/Auth/Sources/SignUpSelectManging/Reducer/SignUpSelectManging.swift @@ -193,10 +193,11 @@ public struct SignUpSelectManaging { switch result { case .success(let data): state.signUpUser = data + return .send(.navigation(.presentCoreMember)) case .failure(let error): state.errorMessage = error.errorDescription + return .none } - return .none } } diff --git a/Projects/Presentation/Auth/Sources/SignUpSelectTeam/Reducer/SignUpSelectTeam.swift b/Projects/Presentation/Auth/Sources/SignUpSelectTeam/Reducer/SignUpSelectTeam.swift index 1fd0d6af..1d3dd21f 100644 --- a/Projects/Presentation/Auth/Sources/SignUpSelectTeam/Reducer/SignUpSelectTeam.swift +++ b/Projects/Presentation/Auth/Sources/SignUpSelectTeam/Reducer/SignUpSelectTeam.swift @@ -7,11 +7,12 @@ import Foundation -import Core import Utill +import UseCase import Entity import ComposableArchitecture +import LogMacro @Reducer public struct SignUpSelectTeam { @@ -22,7 +23,6 @@ public struct SignUpSelectTeam { public init() {} var activeButton: Bool = false - var editProfileDTO: ProfileResponseModel? var selectTeam: SelectTeams? = .unknown var loading: Bool = false var errorMessage: String? diff --git a/Projects/Presentation/Management/Sources/AttendanceCheck/Reducer/AttendanceCheck.swift b/Projects/Presentation/Management/Sources/AttendanceCheck/Reducer/AttendanceCheck.swift index 919e4e3b..16bfe349 100644 --- a/Projects/Presentation/Management/Sources/AttendanceCheck/Reducer/AttendanceCheck.swift +++ b/Projects/Presentation/Management/Sources/AttendanceCheck/Reducer/AttendanceCheck.swift @@ -7,12 +7,12 @@ import Foundation -import Core import Shareds import ComposableArchitecture import LogMacro import FirebaseAuth +import Entity @Reducer public struct AttendanceCheck { diff --git a/Projects/Presentation/Management/Sources/Coordinator/Reducer/StaffCoordinator.swift b/Projects/Presentation/Management/Sources/Coordinator/Reducer/StaffCoordinator.swift index 2aaa4f21..c2faee53 100644 --- a/Projects/Presentation/Management/Sources/Coordinator/Reducer/StaffCoordinator.swift +++ b/Projects/Presentation/Management/Sources/Coordinator/Reducer/StaffCoordinator.swift @@ -7,7 +7,6 @@ import Foundation -import Core import Utill import Profile diff --git a/Projects/Presentation/Management/Sources/QrCode/Reducer/QRCode.swift b/Projects/Presentation/Management/Sources/QrCode/Reducer/QRCode.swift index 7ab4468e..8fdfe402 100644 --- a/Projects/Presentation/Management/Sources/QrCode/Reducer/QRCode.swift +++ b/Projects/Presentation/Management/Sources/QrCode/Reducer/QRCode.swift @@ -6,11 +6,12 @@ // import Foundation -import ComposableArchitecture -import Core import Shareds +import ComposableArchitecture +import LogMacro + @Reducer public struct QRCode { public init() {} diff --git a/Projects/Presentation/Management/Sources/ScheduleManager/Reducer/ScheduleManager.swift b/Projects/Presentation/Management/Sources/ScheduleManager/Reducer/ScheduleManager.swift index f04ea332..5d356c39 100644 --- a/Projects/Presentation/Management/Sources/ScheduleManager/Reducer/ScheduleManager.swift +++ b/Projects/Presentation/Management/Sources/ScheduleManager/Reducer/ScheduleManager.swift @@ -7,10 +7,10 @@ import Foundation -import Core import Shareds import ComposableArchitecture +import LogMacro @Reducer public struct ScheduleManager { diff --git a/Projects/Presentation/Management/Sources/StaffMain/Reducer/Staff.swift b/Projects/Presentation/Management/Sources/StaffMain/Reducer/Staff.swift index 9fa5e452..50e5c0d9 100644 --- a/Projects/Presentation/Management/Sources/StaffMain/Reducer/Staff.swift +++ b/Projects/Presentation/Management/Sources/StaffMain/Reducer/Staff.swift @@ -8,8 +8,8 @@ import Foundation import SwiftUI -import Core import Shareds +import Entity import ComposableArchitecture import KeychainAccess diff --git a/Projects/Presentation/Member/Sources/Coordinator/Reducer/MemberCoordinator.swift b/Projects/Presentation/Member/Sources/Coordinator/Reducer/MemberCoordinator.swift index d39cab9e..2d00b625 100644 --- a/Projects/Presentation/Member/Sources/Coordinator/Reducer/MemberCoordinator.swift +++ b/Projects/Presentation/Member/Sources/Coordinator/Reducer/MemberCoordinator.swift @@ -7,7 +7,6 @@ import Foundation -import Core import Shareds import ComposableArchitecture diff --git a/Projects/Presentation/Member/Sources/MemberMain/Reducer/MemberMain.swift b/Projects/Presentation/Member/Sources/MemberMain/Reducer/MemberMain.swift index 3196c960..aa228f55 100644 --- a/Projects/Presentation/Member/Sources/MemberMain/Reducer/MemberMain.swift +++ b/Projects/Presentation/Member/Sources/MemberMain/Reducer/MemberMain.swift @@ -7,11 +7,12 @@ import Foundation -import Core import Shareds +import UseCase import ComposableArchitecture import FirebaseAuth +import LogMacro @Reducer public struct MemberMain { diff --git a/Projects/Presentation/Member/Sources/QRCode/Reducer/MemberQRCode.swift b/Projects/Presentation/Member/Sources/QRCode/Reducer/MemberQRCode.swift index 5a68a111..418986f5 100644 --- a/Projects/Presentation/Member/Sources/QRCode/Reducer/MemberQRCode.swift +++ b/Projects/Presentation/Member/Sources/QRCode/Reducer/MemberQRCode.swift @@ -8,10 +8,10 @@ import Foundation import SwiftUI -import Core import Shareds import ComposableArchitecture +import LogMacro @Reducer public struct MemberQRCode { diff --git a/Projects/Presentation/Profile/Sources/CreateApp/View/CreateAppView.swift b/Projects/Presentation/Profile/Sources/CreateApp/View/CreateAppView.swift index b76e3b51..563815cb 100644 --- a/Projects/Presentation/Profile/Sources/CreateApp/View/CreateAppView.swift +++ b/Projects/Presentation/Profile/Sources/CreateApp/View/CreateAppView.swift @@ -9,8 +9,8 @@ import SwiftUI import ComposableArchitecture import SwiftUIX +import Model import DesignSystem -import Core struct CreateAppView: View { @Bindable private var store: StoreOf diff --git a/Projects/Presentation/Profile/Sources/Main/Reducer/ProfileReducer.swift b/Projects/Presentation/Profile/Sources/Main/Reducer/ProfileReducer.swift index 5a639cf1..b85fc113 100644 --- a/Projects/Presentation/Profile/Sources/Main/Reducer/ProfileReducer.swift +++ b/Projects/Presentation/Profile/Sources/Main/Reducer/ProfileReducer.swift @@ -7,12 +7,12 @@ import Foundation -import Core import Shareds +import UseCase import AsyncMoya import ComposableArchitecture -import KeychainAccess +import Entity @Reducer public struct ProfileReducer { @@ -28,13 +28,13 @@ public struct ProfileReducer { var managerProfileGeneration: String = "소속 기수" var logoutText: String = "로그아웃" - @Shared(.appStorage("UserEmail")) var userEmail: String = "" - @Shared(.appStorage("AccessToken")) var accessToken: String = "" - var userMember: UserDTOMember? = nil var profileDTOModel: ProfileResponseModel? + var deleteUser: WithdrawEntity? + @Shared(.inMemory("UserSession")) var userSession: UserSession = .empty @Presents var destination: Destination.State? + @Presents public var alert: AlertState? public init() {} } @@ -49,12 +49,13 @@ public struct ProfileReducer { case view(View) case async(AsyncAction) case inner(InnerAction) + case scope(ScopeAction) case navigation(NavigationAction) } // MARK: - View action - + @CasePathable public enum View { case startLoading case stopLoading @@ -63,26 +64,42 @@ public struct ProfileReducer { } // MARK: - 비동기 처리 액션 - + @CasePathable public enum AsyncAction: Equatable { case fetchUser + case deleteUser } // MARK: - 앱내에서 사용하는 액션 - + @CasePathable public enum InnerAction: Equatable { case fetchUserResponse(Result) + case deleteUserResponse(Result) } // MARK: - 네비게이션 연결 액션 - + @CasePathable public enum NavigationAction: Equatable { case presentLogOut case presentCreatByApp } - - fileprivate struct MangerProfileCancel: Hashable {} - + + + @CasePathable + public enum ScopeAction { + case alert(PresentationAction) + } + + @CasePathable + public enum AlertAction { + case confirmTapped + } + + nonisolated enum CancelID: Hashable { + case fetchProfile + case deleteUser + } + @Dependency(\.authUseCase) var authUseCase @Dependency(\.profileUseCase) var profileUseCase @Dependency(\.mainQueue) var mainQueue @@ -117,9 +134,13 @@ public struct ProfileReducer { case .navigation(let navigationAction): return handleNavigationAction(state: &state, action: navigationAction) + + case .scope: + return .none } } .ifLet(\.$destination, action: \.destination) + .ifLet(\.$alert, action: \.scope.alert) } private func handleViewAction( @@ -173,7 +194,22 @@ public struct ProfileReducer { await send(.inner(.fetchUserResponse(.failure(CustomError.firestoreError(error.localizedDescription))))) } } - .debounce(id: MangerProfileCancel(), for: 0.01, scheduler: mainQueue) + .cancellable(id: CancelID.fetchProfile, cancelInFlight: true) + + case .deleteUser: + return .run { + [ + userSession = state.userSession + ] send in + let deleteUserResult = await Result { + try await authUseCase.withDraw(token: userSession.accessToken) + } + .mapError(AuthError.from) + return await send(.inner(.deleteUserResponse(deleteUserResult))) + + } + .cancellable(id: CancelID.deleteUser, cancelInFlight: true) + } @@ -193,6 +229,39 @@ public struct ProfileReducer { #logError("유저 정보 가져오기", error.localizedDescription) } return .none + + + case .deleteUserResponse(let result): + switch result { + case .success(let data): + state.deleteUser = data + if data.isSuccess { + return .send(.navigation(.presentLogOut)) + } + state.alert = AlertState { + TextState("탈퇴실패") + } actions: { + ButtonState(action: .confirmTapped) { + TextState("확인") + } + } message: { + TextState("회원 탈퇴 실패: \(String(describing: data.message ?? "알 수 없는 오류"))") + } + return .none + + case .failure(let error): + state.alert = AlertState { + TextState("탈퇴실패") + } actions: { + ButtonState(action: .confirmTapped) { + TextState("확인") + } + } message: { + TextState("회원 탈퇴 실패: \(String(describing: error.errorDescription ?? error.localizedDescription))") + } + return .none + + } } } @@ -202,7 +271,6 @@ public struct ProfileReducer { ) -> Effect { switch action { case .presentLogOut: - state.$accessToken.withLock { $0 = ""} return .run { send in try await clock.sleep(for: .seconds(2)) } @@ -212,4 +280,3 @@ public struct ProfileReducer { } } } - diff --git a/Projects/Presentation/Profile/Sources/Main/View/ProfileView.swift b/Projects/Presentation/Profile/Sources/Main/View/ProfileView.swift index 08ce81d9..b7596f80 100644 --- a/Projects/Presentation/Profile/Sources/Main/View/ProfileView.swift +++ b/Projects/Presentation/Profile/Sources/Main/View/ProfileView.swift @@ -34,8 +34,9 @@ public struct ProfileView: View { mangerProfileLoadingData() } .task { - store.send(.async(.fetchUser)) +// store.send(.async(.fetchUser)) } + .alert($store.scope(state: \.alert, action: \.scope.alert)) if store.destination?.createApp != nil { VisualEffectBlur(blurStyle: .systemChromeMaterialDark) @@ -58,13 +59,31 @@ public struct ProfileView: View { extension ProfileView { @ViewBuilder fileprivate func mangerProfileLoadingData() -> some View { - if store.profileDTOModel == nil { - if store.isLoading { - profileLoadingView() - } - } else { - mangerProfileData() - } +// if store.profileDTOModel == nil { +// if store.isLoading { +// VStack { +// Spacer() +// .frame(height: 12) +// +// CustomNavigationBar(backAction: backAction, addAction: { +// store.send(.view(.appearModal)) +// }, image: .info) +// +// Spacer() +// +// profileLoadingView() +// +// Spacer() +// +// logoutButton() +// +// +// } +// } +// } else { +// mangerProfileData() +// } + mangerProfileData() } @ViewBuilder @@ -254,13 +273,24 @@ extension ProfileView { .frame(height: 23) HStack(alignment: .center) { + + Text("탈퇴하기") + .pretendardCustomFont(textStyle: .body2NormalMedium) + .foregroundStyle(.red40) + .onTapGesture { + store.send(.async(.deleteUser)) + } + + Spacer() + .frame(width: 10) + Text(store.logoutText) .pretendardCustomFont(textStyle: .body2NormalMedium) .foregroundStyle(.staticWhite) .underline(true, color: .staticWhite) - } - .onTapGesture { - store.send(.navigation(.presentLogOut)) + .onTapGesture { + store.send(.navigation(.presentLogOut)) + } } Spacer() diff --git a/Projects/Presentation/Splash/Sources/Reducer/Splash.swift b/Projects/Presentation/Splash/Sources/Reducer/Splash.swift index cf0e0548..6930bb0b 100644 --- a/Projects/Presentation/Splash/Sources/Reducer/Splash.swift +++ b/Projects/Presentation/Splash/Sources/Reducer/Splash.swift @@ -7,11 +7,12 @@ import Foundation -import Core import Shareds +import UseCase import ComposableArchitecture import FirebaseAuth +import LogMacro @Reducer public struct Splash {