diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md index c80563b..7077b83 100644 --- a/.github/PULL_REQUEST_TEMPLATE.md +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -1,6 +1,6 @@ diff --git a/Projects/DVData/Sources/RepositoryImpl/Project/ProjectRepositoryImpl.swift b/Projects/DVData/Sources/RepositoryImpl/Project/ProjectRepositoryImpl.swift new file mode 100644 index 0000000..4156a5a --- /dev/null +++ b/Projects/DVData/Sources/RepositoryImpl/Project/ProjectRepositoryImpl.swift @@ -0,0 +1,149 @@ +// Copyright © 2026 Devault. All rights reserved + +import DVDomain +import Foundation +import SwiftData + +@ModelActor +public actor ProjectRepositoryImpl: ProjectRepository { + public func create(_ project: DVDomain.Project) async throws -> DVDomain.Project { + do { + if try fetchLocalProject(id: project.id) != nil { + throw ProjectRepositoryError.duplicateID(id: project.id) + } + + if try containsProjectName(project.name, excluding: nil) { + throw ProjectRepositoryError.duplicateName(name: project.name) + } + + let localProject = SwiftDataModel.Project( + id: project.id, + name: project.name, + createdAt: project.createdAt, + updatedAt: project.updatedAt + ) + + modelContext.insert(localProject) + try modelContext.save() + + return localProject.toDomain() + } catch let error as ProjectRepositoryError { + throw error + } catch { + throw ProjectRepositoryError.persistenceFailed + } + } + + public func fetch(id: UUID) async throws -> DVDomain.Project? { + do { + guard let localProject = try fetchLocalProject(id: id) else { + return nil + } + return localProject.toDomain() + } catch let error as ProjectRepositoryError { + throw error + } catch { + throw ProjectRepositoryError.persistenceFailed + } + } + + public func fetchAll() async throws -> [DVDomain.Project] { + do { + let descriptor = FetchDescriptor( + sortBy: [SortDescriptor(\.name, order: .forward)] + ) + let localProjects = try modelContext.fetch(descriptor) + return localProjects.map { $0.toDomain() } + } catch let error as ProjectRepositoryError { + throw error + } catch { + throw ProjectRepositoryError.persistenceFailed + } + } + + public func patch(id: UUID, with patch: ProjectPatch) async throws -> DVDomain.Project { + do { + guard let localProject = try fetchLocalProject(id: id) else { + throw ProjectRepositoryError.notFound(id: id) + } + + let shouldSave = try apply(patch, to: localProject) + if shouldSave { + try modelContext.save() + } + + return localProject.toDomain() + } catch let error as ProjectRepositoryError { + throw error + } catch { + throw ProjectRepositoryError.persistenceFailed + } + } + + public func delete(id: UUID) async throws { + do { + guard let localProject = try fetchLocalProject(id: id) else { + throw ProjectRepositoryError.notFound(id: id) + } + + modelContext.delete(localProject) + try modelContext.save() + } catch let error as ProjectRepositoryError { + throw error + } catch { + throw ProjectRepositoryError.persistenceFailed + } + } +} + +extension ProjectRepositoryImpl { + private func fetchLocalProject(id: UUID) throws -> SwiftDataModel.Project? { + var descriptor = FetchDescriptor( + predicate: #Predicate { $0.id == id } + ) + descriptor.fetchLimit = 1 + return try modelContext.fetch(descriptor).first + } + + /// ProjectPatch를 SwiftData model에 반영하고 저장 필요 여부를 반환한다. + private func apply( + _ patch: ProjectPatch, + to project: SwiftDataModel.Project + ) throws -> Bool { + var shouldSave = false + + if case let .set(name) = patch.name { + if try containsProjectName(name, excluding: project.id) { + throw ProjectRepositoryError.duplicateName(name: name) + } + project.name = name + shouldSave = true + } + + if case let .set(updatedAt) = patch.updatedAt { + project.updatedAt = updatedAt + shouldSave = true + } + + return shouldSave + } + + /// 비교용 이름 key를 기준으로 같은 이름의 Project가 있는지 확인한다. + private func containsProjectName( + _ name: String, + excluding excludedID: UUID? + ) throws -> Bool { + let nameKey = projectNameKey(name) + let descriptor = FetchDescriptor() + let projects = try modelContext.fetch(descriptor) + + return projects.contains { project in + let isExcluded = excludedID.map { project.id == $0 } ?? false // 자기 자신 제외 + return !isExcluded && projectNameKey(project.name) == nameKey + } + } + + private func projectNameKey(_ name: String) -> String { + name.lowercased() + } +} diff --git a/Projects/DVData/Sources/RepositoryImpl/Secret/InMemorySecretQueryFilter.swift b/Projects/DVData/Sources/RepositoryImpl/Secret/InMemorySecretQueryFilter.swift index 2cbc73b..bba7bda 100644 --- a/Projects/DVData/Sources/RepositoryImpl/Secret/InMemorySecretQueryFilter.swift +++ b/Projects/DVData/Sources/RepositoryImpl/Secret/InMemorySecretQueryFilter.swift @@ -19,10 +19,10 @@ enum InMemorySecretQueryFilter { return true } - let fields = [ + let fields: [String?] = [ secret.name, - secret.secretType, - secret.subType, + secret.secretType.rawValue, + secret.subType?.rawValue, secret.service, secret.environment, secret.memo, diff --git a/Projects/DVData/Sources/RepositoryImpl/Secret/SecretRepositoryImpl.swift b/Projects/DVData/Sources/RepositoryImpl/Secret/SecretRepositoryImpl.swift index 41bebce..6de6467 100644 --- a/Projects/DVData/Sources/RepositoryImpl/Secret/SecretRepositoryImpl.swift +++ b/Projects/DVData/Sources/RepositoryImpl/Secret/SecretRepositoryImpl.swift @@ -15,8 +15,8 @@ public actor SecretRepositoryImpl: SecretRepository { let localSecret = SwiftDataModel.Secret( id: secret.id, name: secret.name, - secretType: secret.secretType, - subType: secret.subType, + secretType: secret.secretType.rawValue, + subType: secret.subType?.rawValue, service: secret.service, environment: secret.environment, expiresAt: secret.expiresAt, @@ -117,6 +117,81 @@ public actor SecretRepositoryImpl: SecretRepository { throw SecretRepositoryError.persistenceFailed } } + + public func fetchProjects(secretID: UUID) async throws -> [DVDomain.Project] { + do { + guard let localSecret = try fetchLocalSecret(id: secretID) else { + throw SecretRepositoryError.notFound(id: secretID) + } + + return localSecret.projects + .sorted { $0.name < $1.name } + .map { $0.toDomain() } + } catch let error as SecretRepositoryError { + throw error + } catch { + throw SecretRepositoryError.persistenceFailed + } + } + + public func linkProject(secretID: UUID, projectID: UUID) async throws { + do { + guard let localSecret = try fetchLocalSecret(id: secretID) else { + throw SecretRepositoryError.notFound(id: secretID) + } + + guard let localProject = try fetchLocalProject(id: projectID) else { + throw SecretRepositoryError.projectNotFound(id: projectID) + } + + if try fetchLocalProjectLink(secretID: secretID, projectID: projectID) != nil { + throw SecretRepositoryError.duplicateProjectLink( + secretID: secretID, + projectID: projectID + ) + } + + let link = SwiftDataModel.SecretProjectLink( + project: localProject, + secret: localSecret + ) + modelContext.insert(link) + try modelContext.save() + } catch let error as SecretRepositoryError { + throw error + } catch { + throw SecretRepositoryError.persistenceFailed + } + } + + public func unlinkProject(secretID: UUID, projectID: UUID) async throws { + do { + guard try fetchLocalSecret(id: secretID) != nil else { + throw SecretRepositoryError.notFound(id: secretID) + } + + guard try fetchLocalProject(id: projectID) != nil else { + throw SecretRepositoryError.projectNotFound(id: projectID) + } + + guard let link = try fetchLocalProjectLink( + secretID: secretID, + projectID: projectID + ) else { + throw SecretRepositoryError.projectLinkNotFound( + secretID: secretID, + projectID: projectID + ) + } + + modelContext.delete(link) + try modelContext.save() + } catch let error as SecretRepositoryError { + throw error + } catch { + throw SecretRepositoryError.persistenceFailed + } + } } extension SecretRepositoryImpl { @@ -128,16 +203,40 @@ extension SecretRepositoryImpl { return try modelContext.fetch(descriptor).first } + private func fetchLocalProject(id: UUID) throws -> SwiftDataModel.Project? { + var descriptor = FetchDescriptor( + predicate: #Predicate { $0.id == id } + ) + descriptor.fetchLimit = 1 + return try modelContext.fetch(descriptor).first + } + + private func fetchLocalProjectLink( + secretID: UUID, + projectID: UUID + ) throws -> SwiftDataModel.SecretProjectLink? { + let linkKey = projectLinkKey(secretID: secretID, projectID: projectID) + var descriptor = FetchDescriptor( + predicate: #Predicate { $0.linkKey == linkKey } + ) + descriptor.fetchLimit = 1 + return try modelContext.fetch(descriptor).first + } + + private func projectLinkKey(secretID: UUID, projectID: UUID) -> String { + "\(projectID.uuidString):\(secretID.uuidString)" + } + /// SecretPatch를 SwiftData model에 반영 private func apply(_ patch: SecretPatch, to secret: SwiftDataModel.Secret) { if case let .set(name) = patch.name { secret.name = name } if case let .set(secretType) = patch.secretType { - secret.secretType = secretType + secret.secretType = secretType.rawValue } if case let .set(subType) = patch.subType { - secret.subType = subType + secret.subType = subType?.rawValue } if case let .set(service) = patch.service { secret.service = service diff --git a/Projects/DVData/Sources/Storage/Local/Models/Project.swift b/Projects/DVData/Sources/Storage/Local/Models/Project.swift index 33887e4..b063900 100644 --- a/Projects/DVData/Sources/Storage/Local/Models/Project.swift +++ b/Projects/DVData/Sources/Storage/Local/Models/Project.swift @@ -1,5 +1,6 @@ // Copyright © 2026 Devault. All rights reserved +import DVDomain import Foundation import SwiftData @@ -32,3 +33,14 @@ extension SwiftDataModel { } } } + +extension SwiftDataModel.Project { + func toDomain() -> DVDomain.Project { + DVDomain.Project( + id: id, + name: name, + createdAt: createdAt, + updatedAt: updatedAt + ) + } +} diff --git a/Projects/DVData/Sources/Storage/Local/Models/Secret.swift b/Projects/DVData/Sources/Storage/Local/Models/Secret.swift index 0385f11..4cdb7dc 100644 --- a/Projects/DVData/Sources/Storage/Local/Models/Secret.swift +++ b/Projects/DVData/Sources/Storage/Local/Models/Secret.swift @@ -80,8 +80,8 @@ extension SwiftDataModel.Secret { return DVDomain.Secret( id: id, name: name, - secretType: secretType, - subType: subType, + secretType: DVDomain.SecretType(rawValue: secretType) ?? .etc, + subType: subType.flatMap(DVDomain.SecretSubType.init(rawValue:)), service: service, environment: environment, expiresAt: expiresAt, diff --git a/Projects/DVDomain/Sources/Entity/Project.swift b/Projects/DVDomain/Sources/Entity/Project.swift new file mode 100644 index 0000000..8f085cc --- /dev/null +++ b/Projects/DVDomain/Sources/Entity/Project.swift @@ -0,0 +1,22 @@ +// Copyright © 2026 Devault. All rights reserved + +import Foundation + +public struct Project: Equatable, Identifiable, Sendable { + public var id: UUID + public var name: String + public var createdAt: Date + public var updatedAt: Date + + public init( + id: UUID, + name: String, + createdAt: Date, + updatedAt: Date + ) { + self.id = id + self.name = name + self.createdAt = createdAt + self.updatedAt = updatedAt + } +} diff --git a/Projects/DVDomain/Sources/Entity/Secret.swift b/Projects/DVDomain/Sources/Entity/Secret.swift index a807d61..b0dd0c7 100644 --- a/Projects/DVDomain/Sources/Entity/Secret.swift +++ b/Projects/DVDomain/Sources/Entity/Secret.swift @@ -5,8 +5,8 @@ import Foundation public struct Secret: Equatable, Identifiable, Sendable { public var id: UUID public var name: String - public var secretType: String - public var subType: String? + public var secretType: SecretType + public var subType: SecretSubType? public var service: String? public var environment: String? public var expiresAt: Date? @@ -21,8 +21,8 @@ public struct Secret: Equatable, Identifiable, Sendable { public init( id: UUID, name: String, - secretType: String, - subType: String? = nil, + secretType: SecretType, + subType: SecretSubType? = nil, service: String? = nil, environment: String? = nil, expiresAt: Date? = nil, diff --git a/Projects/DVDomain/Sources/Entity/SecretType.swift b/Projects/DVDomain/Sources/Entity/SecretType.swift new file mode 100644 index 0000000..abfb86f --- /dev/null +++ b/Projects/DVDomain/Sources/Entity/SecretType.swift @@ -0,0 +1,42 @@ +// Copyright © 2026 Devault. All rights reserved + +public enum SecretType: String, Codable, CaseIterable, Sendable { + case apiKeyToken + case oauth + case database + case sshAndCredentials + case environmentVariableSet + case etc + + public var availableSubTypes: [SecretSubType] { + switch self { + case .apiKeyToken: return [.apiKey, .accessToken, .webhookSecret] + case .oauth: return [.oauthClient, .serviceAccount] + case .database: return [] + case .sshAndCredentials: return [.sshKey, .sslTlsCertificate] + case .environmentVariableSet: return [] + case .etc: return [.licenseKey, .custom] + } + } +} + +public enum SecretSubType: String, Codable, CaseIterable, Sendable { + case apiKey + case accessToken + case webhookSecret + case oauthClient + case serviceAccount + case sshKey + case sslTlsCertificate + case licenseKey + case custom + + public var secretType: SecretType { + switch self { + case .apiKey, .accessToken, .webhookSecret: return .apiKeyToken + case .oauthClient, .serviceAccount: return .oauth + case .sshKey, .sslTlsCertificate: return .sshAndCredentials + case .licenseKey, .custom: return .etc + } + } +} diff --git a/Projects/DVDomain/Sources/Repository/Error/ProjectRepositoryError.swift b/Projects/DVDomain/Sources/Repository/Error/ProjectRepositoryError.swift new file mode 100644 index 0000000..d0fd24e --- /dev/null +++ b/Projects/DVDomain/Sources/Repository/Error/ProjectRepositoryError.swift @@ -0,0 +1,13 @@ +// Copyright © 2026 Devault. All rights reserved + +import Foundation + +public enum ProjectRepositoryError: Error, Equatable, Sendable { + case notFound(id: UUID) + case duplicateID(id: UUID) + case duplicateName(name: String) + case invalidPatch + case corruptedStorage + case storageUnavailable + case persistenceFailed +} diff --git a/Projects/DVDomain/Sources/Repository/Error/SecretRepositoryError.swift b/Projects/DVDomain/Sources/Repository/Error/SecretRepositoryError.swift index 2b645bd..4e23709 100644 --- a/Projects/DVDomain/Sources/Repository/Error/SecretRepositoryError.swift +++ b/Projects/DVDomain/Sources/Repository/Error/SecretRepositoryError.swift @@ -5,6 +5,9 @@ import Foundation public enum SecretRepositoryError: Error, Equatable, Sendable { case notFound(id: UUID) case duplicateID(id: UUID) + case projectNotFound(id: UUID) + case duplicateProjectLink(secretID: UUID, projectID: UUID) + case projectLinkNotFound(secretID: UUID, projectID: UUID) case invalidQuery case invalidPatch case corruptedStorage diff --git a/Projects/DVDomain/Sources/Repository/Interface/ProjectRepository.swift b/Projects/DVDomain/Sources/Repository/Interface/ProjectRepository.swift new file mode 100644 index 0000000..6a0f6b0 --- /dev/null +++ b/Projects/DVDomain/Sources/Repository/Interface/ProjectRepository.swift @@ -0,0 +1,30 @@ +// Copyright © 2026 Devault. All rights reserved + +import Foundation + +public protocol ProjectRepository: Sendable { + /// Project를 저장소에 생성한다. + /// - Parameter project: 저장할 Project 엔티티 + /// - Returns: 저장된 Project + func create(_ project: Project) async throws -> Project + + /// ID로 단일 Project를 조회한다. + /// - Parameter id: 조회할 Project의 ID + /// - Returns: 해당 Project. 존재하지 않으면 nil + func fetch(id: UUID) async throws -> Project? + + /// 전체 Project 목록을 조회한다. + /// - Returns: 저장된 모든 Project 배열 + func fetchAll() async throws -> [Project] + + /// Project의 지정 필드를 수정한다. + /// - Parameters: + /// - id: 수정할 Project의 ID + /// - patch: 변경할 필드만 담은 ProjectPatch + /// - Returns: 수정된 Project + func patch(id: UUID, with patch: ProjectPatch) async throws -> Project + + /// Project를 영구 삭제한다. + /// - Parameter id: 삭제할 Project의 ID + func delete(id: UUID) async throws +} diff --git a/Projects/DVDomain/Sources/Repository/Interface/SecretRepository.swift b/Projects/DVDomain/Sources/Repository/Interface/SecretRepository.swift index f3a5107..9ab0a58 100644 --- a/Projects/DVDomain/Sources/Repository/Interface/SecretRepository.swift +++ b/Projects/DVDomain/Sources/Repository/Interface/SecretRepository.swift @@ -3,9 +3,46 @@ import Foundation public protocol SecretRepository: Sendable { + /// Secret을 저장소에 생성한다. + /// - Parameter secret: 저장할 Secret 엔티티 + /// - Returns: 저장된 Secret func create(_ secret: Secret) async throws -> Secret + + /// ID로 단일 Secret을 조회한다. + /// - Parameter id: 조회할 Secret의 ID + /// - Returns: 해당 Secret. 존재하지 않으면 nil func fetch(id: UUID) async throws -> Secret? + + /// 쿼리 조건에 맞는 Secret 목록을 조회한다. + /// - Parameter query: 필터·정렬 조건을 담은 SecretQuery + /// - Returns: 조건에 부합하는 Secret 배열 func fetch(_ query: SecretQuery) async throws -> [Secret] + + /// Secret의 지정 필드를 수정한다. + /// - Parameters: + /// - id: 수정할 Secret의 ID + /// - patch: 변경할 필드만 담은 SecretPatch + /// - Returns: 수정된 Secret func patch(id: UUID, with patch: SecretPatch) async throws -> Secret + + /// Secret을 영구 삭제한다. + /// - Parameter id: 삭제할 Secret의 ID func delete(id: UUID) async throws + + /// Secret에 연결된 Project 목록을 조회한다. + /// - Parameter secretID: 조회할 Secret의 ID + /// - Returns: 해당 Secret에 연결된 Project 배열 + func fetchProjects(secretID: UUID) async throws -> [Project] + + /// Secret을 Project에 연결한다. + /// - Parameters: + /// - secretID: 연결할 Secret의 ID + /// - projectID: 연결 대상 Project의 ID + func linkProject(secretID: UUID, projectID: UUID) async throws + + /// Secret과 Project의 연결을 해제한다. + /// - Parameters: + /// - secretID: 연결 해제할 Secret의 ID + /// - projectID: 연결 해제 대상 Project의 ID + func unlinkProject(secretID: UUID, projectID: UUID) async throws } diff --git a/Projects/DVDomain/Sources/Repository/PatchField.swift b/Projects/DVDomain/Sources/Repository/PatchField.swift new file mode 100644 index 0000000..2eb40d2 --- /dev/null +++ b/Projects/DVDomain/Sources/Repository/PatchField.swift @@ -0,0 +1,7 @@ +// Copyright © 2026 Devault. All rights reserved + +/// 일부 필드 변경 요청에서 변경 없음과 새 값을 구분합니다. +public enum PatchField: Equatable, Sendable { + case unchanged + case set(Value) +} diff --git a/Projects/DVDomain/Sources/Repository/ProjectPatch.swift b/Projects/DVDomain/Sources/Repository/ProjectPatch.swift new file mode 100644 index 0000000..f98ac0f --- /dev/null +++ b/Projects/DVDomain/Sources/Repository/ProjectPatch.swift @@ -0,0 +1,17 @@ +// Copyright © 2026 Devault. All rights reserved + +import Foundation + +/// Project의 일부 필드 변경 요청을 표현합니다. (현재는 name만, 후에 확장성을 고려함) +public struct ProjectPatch: Equatable, Sendable { + public var name: PatchField + public var updatedAt: PatchField + + public init( + name: PatchField = .unchanged, + updatedAt: PatchField = .unchanged + ) { + self.name = name + self.updatedAt = updatedAt + } +} diff --git a/Projects/DVDomain/Sources/Repository/SecretPatch.swift b/Projects/DVDomain/Sources/Repository/SecretPatch.swift index 406bb8a..7810b50 100644 --- a/Projects/DVDomain/Sources/Repository/SecretPatch.swift +++ b/Projects/DVDomain/Sources/Repository/SecretPatch.swift @@ -5,8 +5,8 @@ import Foundation /// Secret의 일부 필드 변경 요청을 표현합니다. public struct SecretPatch: Equatable, Sendable { public var name: PatchField - public var secretType: PatchField - public var subType: PatchField + public var secretType: PatchField + public var subType: PatchField public var service: PatchField public var environment: PatchField public var expiresAt: PatchField @@ -19,8 +19,8 @@ public struct SecretPatch: Equatable, Sendable { public init( name: PatchField = .unchanged, - secretType: PatchField = .unchanged, - subType: PatchField = .unchanged, + secretType: PatchField = .unchanged, + subType: PatchField = .unchanged, service: PatchField = .unchanged, environment: PatchField = .unchanged, expiresAt: PatchField = .unchanged, @@ -45,8 +45,3 @@ public struct SecretPatch: Equatable, Sendable { self.updatedAt = updatedAt } } - -public enum PatchField: Equatable, Sendable { - case unchanged - case set(Value) -} diff --git a/Projects/DVDomain/Sources/Service/Interface/SecretCryptoService.swift b/Projects/DVDomain/Sources/Service/Interface/SecretCryptoService.swift index 228eebb..6cbb694 100644 --- a/Projects/DVDomain/Sources/Service/Interface/SecretCryptoService.swift +++ b/Projects/DVDomain/Sources/Service/Interface/SecretCryptoService.swift @@ -2,19 +2,35 @@ /// Secret payload 암호화와 metadata 인코딩을 수행하는 서비스 프로토콜입니다. public protocol SecretCryptoService: Sendable { + /// payload를 암호화해 저장 가능한 SecretPayload로 변환한다. + /// - Parameter payload: 암호화할 원본 payload + /// - Returns: 암호화된 SecretPayload func encryptPayload( _ payload: Payload ) async throws -> SecretPayload + /// 암호화된 SecretPayload를 복호화해 원본 타입으로 변환한다. + /// - Parameters: + /// - payload: 복호화할 SecretPayload + /// - type: 복호화 결과로 변환할 타입 + /// - Returns: 복호화된 payload func decryptPayload( _ payload: SecretPayload, as type: Payload.Type ) async throws -> Payload + /// metadata를 인코딩해 저장 가능한 SecretMetadata로 변환한다. + /// - Parameter metadata: 인코딩할 원본 metadata + /// - Returns: 인코딩된 SecretMetadata func encodeMetadata( _ metadata: Metadata ) throws -> SecretMetadata + /// 인코딩된 SecretMetadata를 원본 타입으로 복원한다. + /// - Parameters: + /// - metadata: 복원할 SecretMetadata + /// - type: 복원 결과로 변환할 타입 + /// - Returns: 복원된 metadata func decodeMetadata( _ metadata: SecretMetadata, as type: Metadata.Type diff --git a/Projects/DVDomain/Sources/Service/Interface/UserAuthenticationService.swift b/Projects/DVDomain/Sources/Service/Interface/UserAuthenticationService.swift index dab2db4..77a4058 100644 --- a/Projects/DVDomain/Sources/Service/Interface/UserAuthenticationService.swift +++ b/Projects/DVDomain/Sources/Service/Interface/UserAuthenticationService.swift @@ -2,5 +2,7 @@ /// 현재 사용자가 민감 작업을 수행할 수 있는지 로컬 인증으로 확인하는 서비스입니다. public protocol UserAuthenticationService: Sendable { + /// 로컬 인증(생체인증·PIN 등)을 요청한다. 인증 실패 시 에러를 throw한다. + /// - Parameter reason: 인증 요청 시 사용자에게 표시할 이유 문구 func authenticate(reason: String) async throws } diff --git a/Projects/DVDomain/Sources/UseCase/Draft/SecretDraft.swift b/Projects/DVDomain/Sources/UseCase/Draft/SecretDraft.swift index 0682ad9..3d650cc 100644 --- a/Projects/DVDomain/Sources/UseCase/Draft/SecretDraft.swift +++ b/Projects/DVDomain/Sources/UseCase/Draft/SecretDraft.swift @@ -2,11 +2,11 @@ import Foundation -/// 아직 저장되지 않은 Secret 초안 정보를 표현합니다. +/// 아직 저장되지 않은 Secret 초안 정보를 표현합니다. 암호화 결과 등의 생성 책임을 UseCase 안으로 모으는 역할. public struct SecretDraft: Equatable, Sendable { public var name: String - public var secretType: String - public var subType: String? + public var secretType: SecretType + public var subType: SecretSubType? public var service: String? public var environment: String? public var expiresAt: Date? @@ -15,8 +15,8 @@ public struct SecretDraft: Equatable, Sendable { public init( name: String, - secretType: String, - subType: String? = nil, + secretType: SecretType, + subType: SecretSubType? = nil, service: String? = nil, environment: String? = nil, expiresAt: Date? = nil, diff --git a/Projects/DVDomain/Sources/UseCase/Error/ProjectUseCaseError.swift b/Projects/DVDomain/Sources/UseCase/Error/ProjectUseCaseError.swift new file mode 100644 index 0000000..b3ecbde --- /dev/null +++ b/Projects/DVDomain/Sources/UseCase/Error/ProjectUseCaseError.swift @@ -0,0 +1,27 @@ +// Copyright © 2026 Devault. All rights reserved + +import Foundation + +public enum ProjectUseCaseError: Error, Equatable, Sendable { + case invalidName + case projectNotFound(id: UUID) + case repositoryFailure(ProjectRepositoryError) + case unexpected +} + +extension ProjectUseCaseError { + static func map(_ error: Error) -> ProjectUseCaseError { + if let error = error as? ProjectUseCaseError { + return error + } + + if let error = error as? ProjectRepositoryError { + if case let .notFound(id) = error { + return .projectNotFound(id: id) + } + return .repositoryFailure(error) + } + + return .unexpected + } +} diff --git a/Projects/DVDomain/Sources/UseCase/Impl/Project/CreateProjectUseCaseImpl.swift b/Projects/DVDomain/Sources/UseCase/Impl/Project/CreateProjectUseCaseImpl.swift new file mode 100644 index 0000000..8bab99a --- /dev/null +++ b/Projects/DVDomain/Sources/UseCase/Impl/Project/CreateProjectUseCaseImpl.swift @@ -0,0 +1,35 @@ +// Copyright © 2026 Devault. All rights reserved + +import Foundation + +public struct CreateProjectUseCaseImpl: CreateProjectUseCase { + private let repository: any ProjectRepository + private let idGenerator: @Sendable () -> UUID + private let dateProvider: @Sendable () -> Date + + public init( + repository: any ProjectRepository, + idGenerator: @escaping @Sendable () -> UUID = { UUID() }, + dateProvider: @escaping @Sendable () -> Date = { Date() } + ) { + self.repository = repository + self.idGenerator = idGenerator + self.dateProvider = dateProvider + } + + public func execute(name: String) async throws -> Project { + do { + let normalizedName = try ProjectUseCaseHelper.normalizedName(name) + let now = dateProvider() + let project = Project( + id: idGenerator(), + name: normalizedName, + createdAt: now, + updatedAt: now + ) + return try await repository.create(project) + } catch { + throw ProjectUseCaseError.map(error) + } + } +} diff --git a/Projects/DVDomain/Sources/UseCase/Impl/Project/DeleteProjectUseCaseImpl.swift b/Projects/DVDomain/Sources/UseCase/Impl/Project/DeleteProjectUseCaseImpl.swift new file mode 100644 index 0000000..c487756 --- /dev/null +++ b/Projects/DVDomain/Sources/UseCase/Impl/Project/DeleteProjectUseCaseImpl.swift @@ -0,0 +1,19 @@ +// Copyright © 2026 Devault. All rights reserved + +import Foundation + +public struct DeleteProjectUseCaseImpl: DeleteProjectUseCase { + private let repository: any ProjectRepository + + public init(repository: any ProjectRepository) { + self.repository = repository + } + + public func delete(id: UUID) async throws { + do { + try await repository.delete(id: id) + } catch { + throw ProjectUseCaseError.map(error) + } + } +} diff --git a/Projects/DVDomain/Sources/UseCase/Impl/Project/FetchProjectUseCaseImpl.swift b/Projects/DVDomain/Sources/UseCase/Impl/Project/FetchProjectUseCaseImpl.swift new file mode 100644 index 0000000..1325d3c --- /dev/null +++ b/Projects/DVDomain/Sources/UseCase/Impl/Project/FetchProjectUseCaseImpl.swift @@ -0,0 +1,27 @@ +// Copyright © 2026 Devault. All rights reserved + +import Foundation + +public struct FetchProjectUseCaseImpl: FetchProjectUseCase { + private let repository: any ProjectRepository + + public init(repository: any ProjectRepository) { + self.repository = repository + } + + public func fetch(id: UUID) async throws -> Project? { + do { + return try await repository.fetch(id: id) + } catch { + throw ProjectUseCaseError.map(error) + } + } + + public func fetchAll() async throws -> [Project] { + do { + return try await repository.fetchAll() + } catch { + throw ProjectUseCaseError.map(error) + } + } +} diff --git a/Projects/DVDomain/Sources/UseCase/Impl/Project/ProjectUseCaseHelper.swift b/Projects/DVDomain/Sources/UseCase/Impl/Project/ProjectUseCaseHelper.swift new file mode 100644 index 0000000..3b82d17 --- /dev/null +++ b/Projects/DVDomain/Sources/UseCase/Impl/Project/ProjectUseCaseHelper.swift @@ -0,0 +1,14 @@ +// Copyright © 2026 Devault. All rights reserved + +import Foundation + +enum ProjectUseCaseHelper { + /// Project 이름의 앞뒤 공백을 제거하고 기본 필수값을 검증합니다. + static func normalizedName(_ name: String) throws -> String { + let normalizedName = name.trimmingCharacters(in: .whitespacesAndNewlines) + guard !normalizedName.isEmpty else { + throw ProjectUseCaseError.invalidName + } + return normalizedName + } +} diff --git a/Projects/DVDomain/Sources/UseCase/Impl/Project/RenameProjectUseCaseImpl.swift b/Projects/DVDomain/Sources/UseCase/Impl/Project/RenameProjectUseCaseImpl.swift new file mode 100644 index 0000000..932852f --- /dev/null +++ b/Projects/DVDomain/Sources/UseCase/Impl/Project/RenameProjectUseCaseImpl.swift @@ -0,0 +1,29 @@ +// Copyright © 2026 Devault. All rights reserved + +import Foundation + +public struct RenameProjectUseCaseImpl: RenameProjectUseCase { + private let repository: any ProjectRepository + private let dateProvider: @Sendable () -> Date + + public init( + repository: any ProjectRepository, + dateProvider: @escaping @Sendable () -> Date = { Date() } + ) { + self.repository = repository + self.dateProvider = dateProvider + } + + public func rename(id: UUID, name: String) async throws -> Project { + do { + let normalizedName = try ProjectUseCaseHelper.normalizedName(name) + let patch = ProjectPatch( + name: .set(normalizedName), + updatedAt: .set(dateProvider()) + ) + return try await repository.patch(id: id, with: patch) + } catch { + throw ProjectUseCaseError.map(error) + } + } +} diff --git a/Projects/DVDomain/Sources/UseCase/Impl/Secret/CreateSecretUseCaseImpl.swift b/Projects/DVDomain/Sources/UseCase/Impl/Secret/CreateSecretUseCaseImpl.swift index c8b5f46..4e023a5 100644 --- a/Projects/DVDomain/Sources/UseCase/Impl/Secret/CreateSecretUseCaseImpl.swift +++ b/Projects/DVDomain/Sources/UseCase/Impl/Secret/CreateSecretUseCaseImpl.swift @@ -22,10 +22,11 @@ public struct CreateSecretUseCaseImpl: CreateSecretUseCase { public func execute( draft: SecretDraft, - payload: Payload + payload: Payload, + projectIDs: [UUID] ) async throws -> Secret { do { - try SecretUseCaseHelper.validateDraft(draft) + let draft = try SecretUseCaseHelper.normalizedDraft(draft) let now = dateProvider() let encryptedPayload = try await cryptoService.encryptPayload(payload) let secret = Secret( @@ -42,7 +43,7 @@ public struct CreateSecretUseCaseImpl: CreateSecretUseCase { updatedAt: now, payload: encryptedPayload ) - return try await repository.create(secret) + return try await createAndLink(secret, projectIDs: projectIDs) } catch { throw SecretUseCaseError.map(error) } @@ -51,10 +52,11 @@ public struct CreateSecretUseCaseImpl: CreateSecretUseCase { public func execute( draft: SecretDraft, payload: Payload, - metadata: Metadata + metadata: Metadata, + projectIDs: [UUID] ) async throws -> Secret { do { - try SecretUseCaseHelper.validateDraft(draft) + let draft = try SecretUseCaseHelper.normalizedDraft(draft) let now = dateProvider() let encryptedPayload = try await cryptoService.encryptPayload(payload) let encodedMetadata = try cryptoService.encodeMetadata(metadata) @@ -73,9 +75,25 @@ public struct CreateSecretUseCaseImpl: CreateSecretUseCase { payload: encryptedPayload, metadata: encodedMetadata ) - return try await repository.create(secret) + return try await createAndLink(secret, projectIDs: projectIDs) } catch { throw SecretUseCaseError.map(error) } } + +} + +private extension CreateSecretUseCaseImpl { + func createAndLink(_ secret: Secret, projectIDs: [UUID]) async throws -> Secret { + let created = try await repository.create(secret) + do { + for projectID in projectIDs { + try await repository.linkProject(secretID: created.id, projectID: projectID) + } + } catch { + try? await repository.delete(id: created.id) + throw error + } + return created + } } diff --git a/Projects/DVDomain/Sources/UseCase/Impl/Secret/FetchSecretUseCaseImpl.swift b/Projects/DVDomain/Sources/UseCase/Impl/Secret/FetchSecretUseCaseImpl.swift index d08aead..b74f5d0 100644 --- a/Projects/DVDomain/Sources/UseCase/Impl/Secret/FetchSecretUseCaseImpl.swift +++ b/Projects/DVDomain/Sources/UseCase/Impl/Secret/FetchSecretUseCaseImpl.swift @@ -33,6 +33,14 @@ public struct FetchSecretUseCaseImpl: FetchSecretUseCase { } } + public func fetchProjects(secretID: UUID) async throws -> [Project] { + do { + return try await repository.fetchProjects(secretID: secretID) + } catch { + throw SecretUseCaseError.map(error) + } + } + public func revealPayload( id: UUID, as type: Payload.Type diff --git a/Projects/DVDomain/Sources/UseCase/Impl/Secret/PatchSecretUseCaseImpl.swift b/Projects/DVDomain/Sources/UseCase/Impl/Secret/PatchSecretUseCaseImpl.swift index 5a101b0..52238c9 100644 --- a/Projects/DVDomain/Sources/UseCase/Impl/Secret/PatchSecretUseCaseImpl.swift +++ b/Projects/DVDomain/Sources/UseCase/Impl/Secret/PatchSecretUseCaseImpl.swift @@ -26,50 +26,58 @@ public struct PatchSecretUseCaseImpl: PatchSecretUseCase { } } - public func updatePayload( + public func update( id: UUID, - payload: Payload + patch: SecretPatch, + payload: Payload, + projectIDs: [UUID] ) async throws -> Secret { do { let encryptedPayload = try await cryptoService.encryptPayload(payload) - let now = dateProvider() - let patch = SecretPatch( - payload: .set(encryptedPayload), - updatedAt: .set(now) - ) - return try await repository.patch(id: id, with: patch) + var fullPatch = patch + fullPatch.payload = .set(encryptedPayload) + fullPatch.updatedAt = .set(dateProvider()) + let updated = try await repository.patch(id: id, with: fullPatch) + try await reconcileProjects(secretID: id, desiredIDs: projectIDs) + return updated } catch { throw SecretUseCaseError.map(error) } } - public func updateMetadata( + public func update( id: UUID, - metadata: Metadata + patch: SecretPatch, + payload: Payload, + metadata: Metadata, + projectIDs: [UUID] ) async throws -> Secret { do { + let encryptedPayload = try await cryptoService.encryptPayload(payload) let encodedMetadata = try cryptoService.encodeMetadata(metadata) - let now = dateProvider() - let patch = SecretPatch( - metadata: .set(encodedMetadata), - updatedAt: .set(now) - ) - return try await repository.patch(id: id, with: patch) + var fullPatch = patch + fullPatch.payload = .set(encryptedPayload) + fullPatch.metadata = .set(encodedMetadata) + fullPatch.updatedAt = .set(dateProvider()) + let updated = try await repository.patch(id: id, with: fullPatch) + try await reconcileProjects(secretID: id, desiredIDs: projectIDs) + return updated } catch { throw SecretUseCaseError.map(error) } } - public func removeMetadata(id: UUID) async throws -> Secret { - do { - let now = dateProvider() - let patch = SecretPatch( - metadata: .set(nil), - updatedAt: .set(now) - ) - return try await repository.patch(id: id, with: patch) - } catch { - throw SecretUseCaseError.map(error) +} + +private extension PatchSecretUseCaseImpl { + func reconcileProjects(secretID: UUID, desiredIDs: [UUID]) async throws { + let currentIDs = Set(try await repository.fetchProjects(secretID: secretID).map(\.id)) + let desiredIDs = Set(desiredIDs) + for projectID in desiredIDs.subtracting(currentIDs) { + try await repository.linkProject(secretID: secretID, projectID: projectID) + } + for projectID in currentIDs.subtracting(desiredIDs) { + try await repository.unlinkProject(secretID: secretID, projectID: projectID) } } } diff --git a/Projects/DVDomain/Sources/UseCase/Impl/Secret/SecretProjectRelationUseCaseImpl.swift b/Projects/DVDomain/Sources/UseCase/Impl/Secret/SecretProjectRelationUseCaseImpl.swift new file mode 100644 index 0000000..e4e00e7 --- /dev/null +++ b/Projects/DVDomain/Sources/UseCase/Impl/Secret/SecretProjectRelationUseCaseImpl.swift @@ -0,0 +1,27 @@ +// Copyright © 2026 Devault. All rights reserved + +import Foundation + +public struct SecretProjectRelationUseCaseImpl: SecretProjectRelationUseCase { + private let repository: any SecretRepository + + public init(repository: any SecretRepository) { + self.repository = repository + } + + public func link(secretID: UUID, projectID: UUID) async throws { + do { + try await repository.linkProject(secretID: secretID, projectID: projectID) + } catch { + throw SecretUseCaseError.map(error) + } + } + + public func unlink(secretID: UUID, projectID: UUID) async throws { + do { + try await repository.unlinkProject(secretID: secretID, projectID: projectID) + } catch { + throw SecretUseCaseError.map(error) + } + } +} diff --git a/Projects/DVDomain/Sources/UseCase/Impl/Secret/SecretUseCaseHelper.swift b/Projects/DVDomain/Sources/UseCase/Impl/Secret/SecretUseCaseHelper.swift index 10494f9..1d7a340 100644 --- a/Projects/DVDomain/Sources/UseCase/Impl/Secret/SecretUseCaseHelper.swift +++ b/Projects/DVDomain/Sources/UseCase/Impl/Secret/SecretUseCaseHelper.swift @@ -3,15 +3,14 @@ import Foundation enum SecretUseCaseHelper { - /// Secret 초안의 기본 필수값을 검증합니다. - static func validateDraft(_ draft: SecretDraft) throws { - guard !draft.name.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { - throw SecretUseCaseError.invalidName - } + /// Secret 초안의 이름을 정규화하고 필수값을 검증한 초안을 반환합니다. + static func normalizedDraft(_ draft: SecretDraft) throws -> SecretDraft { + let name = draft.name.trimmingCharacters(in: .whitespacesAndNewlines) + guard !name.isEmpty else { throw SecretUseCaseError.invalidName } - guard !draft.secretType.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { - throw SecretUseCaseError.invalidSecretType - } + var normalized = draft + normalized.name = name + return normalized } /// patch에 updatedAt이 없으면 전달받은 시각으로 채웁니다. diff --git a/Projects/DVDomain/Sources/UseCase/Interface/Project/CreateProjectUseCase.swift b/Projects/DVDomain/Sources/UseCase/Interface/Project/CreateProjectUseCase.swift new file mode 100644 index 0000000..bb1a8c7 --- /dev/null +++ b/Projects/DVDomain/Sources/UseCase/Interface/Project/CreateProjectUseCase.swift @@ -0,0 +1,10 @@ +// Copyright © 2026 Devault. All rights reserved + +import Foundation + +public protocol CreateProjectUseCase: Sendable { + /// Project를 생성한다. + /// - Parameter name: 생성할 Project의 이름 + /// - Returns: 생성된 Project + func execute(name: String) async throws -> Project +} diff --git a/Projects/DVDomain/Sources/UseCase/Interface/Project/DeleteProjectUseCase.swift b/Projects/DVDomain/Sources/UseCase/Interface/Project/DeleteProjectUseCase.swift new file mode 100644 index 0000000..addceeb --- /dev/null +++ b/Projects/DVDomain/Sources/UseCase/Interface/Project/DeleteProjectUseCase.swift @@ -0,0 +1,9 @@ +// Copyright © 2026 Devault. All rights reserved + +import Foundation + +public protocol DeleteProjectUseCase: Sendable { + /// Project를 영구 삭제한다. 복구 불가. + /// - Parameter id: 삭제할 Project의 ID + func delete(id: UUID) async throws +} diff --git a/Projects/DVDomain/Sources/UseCase/Interface/Project/FetchProjectUseCase.swift b/Projects/DVDomain/Sources/UseCase/Interface/Project/FetchProjectUseCase.swift new file mode 100644 index 0000000..f7cb8ff --- /dev/null +++ b/Projects/DVDomain/Sources/UseCase/Interface/Project/FetchProjectUseCase.swift @@ -0,0 +1,14 @@ +// Copyright © 2026 Devault. All rights reserved + +import Foundation + +public protocol FetchProjectUseCase: Sendable { + /// ID로 단일 Project를 조회한다. + /// - Parameter id: 조회할 Project의 ID + /// - Returns: 해당 Project. 존재하지 않으면 nil + func fetch(id: UUID) async throws -> Project? + + /// 전체 Project 목록을 조회한다. + /// - Returns: 저장된 모든 Project 배열 + func fetchAll() async throws -> [Project] +} diff --git a/Projects/DVDomain/Sources/UseCase/Interface/Project/RenameProjectUseCase.swift b/Projects/DVDomain/Sources/UseCase/Interface/Project/RenameProjectUseCase.swift new file mode 100644 index 0000000..336255f --- /dev/null +++ b/Projects/DVDomain/Sources/UseCase/Interface/Project/RenameProjectUseCase.swift @@ -0,0 +1,12 @@ +// Copyright © 2026 Devault. All rights reserved + +import Foundation + +public protocol RenameProjectUseCase: Sendable { + /// Project의 이름을 변경한다. + /// - Parameters: + /// - id: 이름을 변경할 Project의 ID + /// - name: 변경할 새 이름 + /// - Returns: 이름이 변경된 Project + func rename(id: UUID, name: String) async throws -> Project +} diff --git a/Projects/DVDomain/Sources/UseCase/Interface/Secret/CreateSecretUseCase.swift b/Projects/DVDomain/Sources/UseCase/Interface/Secret/CreateSecretUseCase.swift index cc6357e..c9d58b7 100644 --- a/Projects/DVDomain/Sources/UseCase/Interface/Secret/CreateSecretUseCase.swift +++ b/Projects/DVDomain/Sources/UseCase/Interface/Secret/CreateSecretUseCase.swift @@ -1,14 +1,33 @@ // Copyright © 2026 Devault. All rights reserved +import Foundation + public protocol CreateSecretUseCase: Sendable { + /// Secret을 생성하고 지정한 Project에 연결한다. + /// payload는 내부에서 암호화된다. link 실패 시 생성된 Secret을 rollback한다. + /// - Parameters: + /// - draft: 이름·타입 등 Secret의 기본 정보 + /// - payload: 암호화할 Secret 값 + /// - projectIDs: 생성 후 연결할 Project ID 목록. 빈 배열이면 연결 없이 생성 + /// - Returns: 생성된 Secret func execute( draft: SecretDraft, - payload: Payload + payload: Payload, + projectIDs: [UUID] ) async throws -> Secret + /// Secret을 생성하고 metadata를 포함해 지정한 Project에 연결한다. + /// payload와 metadata는 내부에서 암호화/인코딩된다. link 실패 시 생성된 Secret을 rollback한다. + /// - Parameters: + /// - draft: 이름·타입 등 Secret의 기본 정보 + /// - payload: 암호화할 Secret 값 + /// - metadata: 인코딩할 부가 정보 + /// - projectIDs: 생성 후 연결할 Project ID 목록. 빈 배열이면 연결 없이 생성 + /// - Returns: 생성된 Secret func execute( draft: SecretDraft, payload: Payload, - metadata: Metadata + metadata: Metadata, + projectIDs: [UUID] ) async throws -> Secret } diff --git a/Projects/DVDomain/Sources/UseCase/Interface/Secret/DeleteSecretUseCase.swift b/Projects/DVDomain/Sources/UseCase/Interface/Secret/DeleteSecretUseCase.swift index b57c4f1..55f6281 100644 --- a/Projects/DVDomain/Sources/UseCase/Interface/Secret/DeleteSecretUseCase.swift +++ b/Projects/DVDomain/Sources/UseCase/Interface/Secret/DeleteSecretUseCase.swift @@ -3,7 +3,17 @@ import Foundation public protocol DeleteSecretUseCase: Sendable { + /// Secret을 소프트 삭제한다. deletedAt을 현재 시각으로 세팅하며 실제 데이터는 보존된다. + /// - Parameter id: 삭제할 Secret의 ID + /// - Returns: deletedAt이 설정된 Secret func softDelete(id: UUID) async throws -> Secret + + /// 소프트 삭제된 Secret을 복구한다. deletedAt을 nil로 되돌린다. + /// - Parameter id: 복구할 Secret의 ID + /// - Returns: 복구된 Secret func restore(id: UUID) async throws -> Secret + + /// Secret을 영구 삭제한다. 복구 불가. + /// - Parameter id: 영구 삭제할 Secret의 ID func permanentlyDelete(id: UUID) async throws } diff --git a/Projects/DVDomain/Sources/UseCase/Interface/Secret/FetchSecretUseCase.swift b/Projects/DVDomain/Sources/UseCase/Interface/Secret/FetchSecretUseCase.swift index eaf58d1..9af62c7 100644 --- a/Projects/DVDomain/Sources/UseCase/Interface/Secret/FetchSecretUseCase.swift +++ b/Projects/DVDomain/Sources/UseCase/Interface/Secret/FetchSecretUseCase.swift @@ -3,8 +3,26 @@ import Foundation public protocol FetchSecretUseCase: Sendable { + /// ID로 단일 Secret을 조회한다. + /// - Parameter id: 조회할 Secret의 ID + /// - Returns: 해당 Secret. 존재하지 않으면 nil func fetch(id: UUID) async throws -> Secret? + + /// 쿼리 조건에 맞는 Secret 목록을 조회한다. + /// - Parameter query: 필터·정렬 조건을 담은 SecretQuery + /// - Returns: 조건에 부합하는 Secret 배열 func fetch(query: SecretQuery) async throws -> [Secret] + + /// Secret에 연결된 Project 목록을 조회한다. + /// - Parameter secretID: 조회할 Secret의 ID + /// - Returns: 해당 Secret에 연결된 Project 배열 + func fetchProjects(secretID: UUID) async throws -> [Project] + + /// 생체인증 후 Secret의 암호화된 payload를 복호화해 반환한다. + /// - Parameters: + /// - id: 복호화할 Secret의 ID + /// - type: 복호화 결과로 변환할 payload 타입 + /// - Returns: 복호화된 payload func revealPayload( id: UUID, as type: Payload.Type diff --git a/Projects/DVDomain/Sources/UseCase/Interface/Secret/PatchSecretUseCase.swift b/Projects/DVDomain/Sources/UseCase/Interface/Secret/PatchSecretUseCase.swift index b4201f2..d72ad47 100644 --- a/Projects/DVDomain/Sources/UseCase/Interface/Secret/PatchSecretUseCase.swift +++ b/Projects/DVDomain/Sources/UseCase/Interface/Secret/PatchSecretUseCase.swift @@ -3,14 +3,43 @@ import Foundation public protocol PatchSecretUseCase: Sendable { + /// SecretPatch를 그대로 적용한다. liked 토글·soft delete 등 단순 필드 수정에 사용한다. + /// updatedAt이 미지정이면 현재 시각으로 자동 세팅된다. + /// - Parameters: + /// - id: 수정할 Secret의 ID + /// - patch: 변경할 필드만 담은 SecretPatch + /// - Returns: 수정된 Secret func patch(id: UUID, with patch: SecretPatch) async throws -> Secret - func updatePayload( + + /// Secret의 모든 필드와 Project 연결을 한 번에 수정한다. + /// payload는 내부에서 암호화되며, projectIDs를 기준으로 연결 상태를 재조정한다. + /// - Parameters: + /// - id: 수정할 Secret의 ID + /// - patch: 일반 필드 변경 정보. payload·updatedAt은 이 메서드가 직접 세팅하므로 미지정으로 전달 + /// - payload: 암호화할 Secret 값 + /// - projectIDs: 수정 후 연결되어야 할 Project ID 목록 + /// - Returns: 수정된 Secret + func update( id: UUID, - payload: Payload + patch: SecretPatch, + payload: Payload, + projectIDs: [UUID] ) async throws -> Secret - func updateMetadata( + + /// Secret의 모든 필드·metadata·Project 연결을 한 번에 수정한다. + /// payload와 metadata는 내부에서 암호화/인코딩되며, projectIDs를 기준으로 연결 상태를 재조정한다. + /// - Parameters: + /// - id: 수정할 Secret의 ID + /// - patch: 일반 필드 변경 정보. payload·metadata·updatedAt은 이 메서드가 직접 세팅하므로 미지정으로 전달 + /// - payload: 암호화할 Secret 값 + /// - metadata: 인코딩할 부가 정보 + /// - projectIDs: 수정 후 연결되어야 할 Project ID 목록 + /// - Returns: 수정된 Secret + func update( id: UUID, - metadata: Metadata + patch: SecretPatch, + payload: Payload, + metadata: Metadata, + projectIDs: [UUID] ) async throws -> Secret - func removeMetadata(id: UUID) async throws -> Secret } diff --git a/Projects/DVDomain/Sources/UseCase/Interface/Secret/SecretProjectRelationUseCase.swift b/Projects/DVDomain/Sources/UseCase/Interface/Secret/SecretProjectRelationUseCase.swift new file mode 100644 index 0000000..2cc7ef7 --- /dev/null +++ b/Projects/DVDomain/Sources/UseCase/Interface/Secret/SecretProjectRelationUseCase.swift @@ -0,0 +1,17 @@ +// Copyright © 2026 Devault. All rights reserved + +import Foundation + +public protocol SecretProjectRelationUseCase: Sendable { + /// Secret을 Project에 연결한다. Project 화면 등 단독 연결 조작에 사용한다. + /// - Parameters: + /// - secretID: 연결할 Secret의 ID + /// - projectID: 연결 대상 Project의 ID + func link(secretID: UUID, projectID: UUID) async throws + + /// Secret과 Project의 연결을 해제한다. Project 화면 등 단독 해제 조작에 사용한다. + /// - Parameters: + /// - secretID: 연결 해제할 Secret의 ID + /// - projectID: 연결 해제 대상 Project의 ID + func unlink(secretID: UUID, projectID: UUID) async throws +} diff --git a/Projects/DVPresentation/Sources/ProjectSecretRelationDemoView.swift b/Projects/DVPresentation/Sources/ProjectSecretRelationDemoView.swift new file mode 100644 index 0000000..1c49768 --- /dev/null +++ b/Projects/DVPresentation/Sources/ProjectSecretRelationDemoView.swift @@ -0,0 +1,383 @@ +// Copyright © 2026 Devault. All rights reserved + +import DVDomain +import SwiftUI + +public struct ProjectSecretRelationDemoView: View { + private let createProjectUseCase: any CreateProjectUseCase + private let fetchProjectUseCase: any FetchProjectUseCase + private let createSecretUseCase: any CreateSecretUseCase + private let fetchSecretUseCase: any FetchSecretUseCase + private let secretProjectRelationUseCase: any SecretProjectRelationUseCase + + @State private var projects: [Project] = [] + @State private var projectSecrets: [Secret] = [] + @State private var secretProjects: [Project] = [] + @State private var selectedProject: Project? + @State private var selectedSecret: Secret? + @State private var statusMessage = "Ready" + @State private var isRunning = false + + public init( + createProjectUseCase: any CreateProjectUseCase, + fetchProjectUseCase: any FetchProjectUseCase, + createSecretUseCase: any CreateSecretUseCase, + fetchSecretUseCase: any FetchSecretUseCase, + secretProjectRelationUseCase: any SecretProjectRelationUseCase + ) { + self.createProjectUseCase = createProjectUseCase + self.fetchProjectUseCase = fetchProjectUseCase + self.createSecretUseCase = createSecretUseCase + self.fetchSecretUseCase = fetchSecretUseCase + self.secretProjectRelationUseCase = secretProjectRelationUseCase + } + + public var body: some View { + VStack(alignment: .leading, spacing: 16) { + header + controls + status + content + } + .padding(24) + .frame(minWidth: 900, minHeight: 560) + .task { + await refreshProjects() + } + } +} + +private extension ProjectSecretRelationDemoView { + var header: some View { + VStack(alignment: .leading, spacing: 4) { + Text("Project ↔ Secret Relation Demo") + .font(.title2) + .fontWeight(.semibold) + Text("create 시 link, fetchProjects, fetchSecrets(by project), SecretProjectRelationUseCase link/unlink 확인") + .font(.callout) + .foregroundStyle(.secondary) + } + } + + var controls: some View { + HStack(spacing: 10) { + Button("Create Project + Secret (Linked)") { + Task { await createLinkedDemoData() } + } + .disabled(isRunning) + .keyboardShortcut(.defaultAction) + + Button("Create Secret Only") { + Task { await createUnlinkedSecret() } + } + .disabled(isRunning) + + Button("Link Selected") { + Task { await linkSelected() } + } + .disabled(isRunning || selectedProject == nil || selectedSecret == nil) + + Button("Unlink Selected") { + Task { await unlinkSelected() } + } + .disabled(isRunning || selectedProject == nil || selectedSecret == nil) + + Button("Refresh") { + Task { await refreshAll() } + } + .disabled(isRunning) + } + } + + var status: some View { + Text(statusMessage) + .font(.callout) + .foregroundStyle(isRunning ? .secondary : .primary) + .frame(maxWidth: .infinity, alignment: .leading) + .padding(10) + .background(Color.secondary.opacity(0.08)) + .clipShape(RoundedRectangle(cornerRadius: 8)) + } + + var content: some View { + HStack(alignment: .top, spacing: 20) { + projectList + .frame(minWidth: 220, idealWidth: 260, maxWidth: 300) + Divider() + secretList + .frame(minWidth: 260, idealWidth: 300, maxWidth: 360) + Divider() + relationDetail + .frame(maxWidth: .infinity, alignment: .topLeading) + } + } + + var projectList: some View { + VStack(alignment: .leading, spacing: 8) { + HStack { + Text("Projects") + .font(.headline) + Spacer() + Text("\(projects.count)") + .font(.caption) + .foregroundStyle(.secondary) + } + List(projects, selection: selectedProjectBinding) { project in + VStack(alignment: .leading, spacing: 4) { + Text(project.name) + .font(.callout) + .fontWeight(.medium) + .lineLimit(1) + Text(project.updatedAt.formatted(date: .numeric, time: .shortened)) + .font(.caption) + .foregroundStyle(.secondary) + } + .tag(project.id) + } + } + } + + var secretList: some View { + VStack(alignment: .leading, spacing: 8) { + HStack { + Text(selectedProject != nil ? "Secrets in Project" : "All Secrets") + .font(.headline) + Spacer() + Text("\(projectSecrets.count)") + .font(.caption) + .foregroundStyle(.secondary) + } + List(projectSecrets, selection: selectedSecretBinding) { secret in + VStack(alignment: .leading, spacing: 4) { + Text(secret.name) + .font(.callout) + .fontWeight(.medium) + .lineLimit(1) + Text(secret.secretType.rawValue) + .font(.caption) + .foregroundStyle(.secondary) + } + .tag(secret.id) + } + } + } + + var relationDetail: some View { + VStack(alignment: .leading, spacing: 12) { + Text("Relation Detail") + .font(.headline) + + Group { + resultRow("Selected Project", selectedProject?.name ?? "-") + resultRow("Selected Secret", selectedSecret?.name ?? "-") + } + + Divider() + + Text("Projects linked to selected Secret") + .font(.subheadline) + .fontWeight(.semibold) + + if secretProjects.isEmpty { + Text("No linked projects.") + .font(.callout) + .foregroundStyle(.secondary) + } else { + ForEach(secretProjects) { project in + HStack(spacing: 6) { + Image(systemName: "folder") + .foregroundStyle(.secondary) + Text(project.name) + .font(.callout) + } + } + } + } + } + + var selectedProjectBinding: Binding { + Binding( + get: { selectedProject?.id }, + set: { id in + selectedProject = projects.first { $0.id == id } + selectedSecret = nil + secretProjects = [] + Task { await refreshProjectSecrets() } + } + ) + } + + var selectedSecretBinding: Binding { + Binding( + get: { selectedSecret?.id }, + set: { id in + selectedSecret = projectSecrets.first { $0.id == id } + Task { await refreshSecretProjects() } + } + ) + } + + func resultRow(_ title: String, _ value: String) -> some View { + HStack(alignment: .firstTextBaseline, spacing: 12) { + Text(title) + .foregroundStyle(.secondary) + .frame(width: 140, alignment: .leading) + Text(value) + .lineLimit(1) + .truncationMode(.middle) + .textSelection(.enabled) + } + .font(.callout) + } + + // MARK: - Actions + + func createLinkedDemoData() async { + await run("Creating project + secret with link...") { + let timestamp = Int(Date().timeIntervalSince1970) + let project = try await createProjectUseCase.execute(name: "Demo Project \(timestamp)") + let draft = SecretDraft( + name: "Linked Secret \(timestamp)", + secretType: .apiKeyToken, + subType: .accessToken, + service: "github", + environment: "production", + memo: "Created with projectIDs at create time" + ) + let payload = APIKeyPayload(value: "demo-token-\(timestamp)") + // CreateSecretUseCase가 create + link를 한 번에 처리 + let secret = try await createSecretUseCase.execute( + draft: draft, + payload: payload, + projectIDs: [project.id] + ) + await refreshAll() + selectedProject = projects.first { $0.id == project.id } + await refreshProjectSecrets() + selectedSecret = projectSecrets.first { $0.id == secret.id } + await refreshSecretProjects() + statusMessage = "Created project + secret and linked via CreateSecretUseCase" + } + } + + func createUnlinkedSecret() async { + await run("Creating secret without link...") { + let timestamp = Int(Date().timeIntervalSince1970) + let draft = SecretDraft( + name: "Unlinked Secret \(timestamp)", + secretType: .apiKeyToken, + subType: .apiKey, + service: "standalone", + environment: "development" + ) + let payload = APIKeyPayload(value: "demo-key-\(timestamp)") + // projectIDs: [] 로 link 없이 생성 + _ = try await createSecretUseCase.execute( + draft: draft, + payload: payload, + projectIDs: [] + ) + await refreshProjectSecrets() + statusMessage = "Created secret without project link" + } + } + + func linkSelected() async { + guard let selectedProject, let selectedSecret else { return } + await run("Linking via SecretProjectRelationUseCase...") { + // SecretProjectRelationUseCase — 단독 link 조작 + try await secretProjectRelationUseCase.link( + secretID: selectedSecret.id, + projectID: selectedProject.id + ) + await refreshProjectSecrets() + await refreshSecretProjects() + statusMessage = "Linked \(selectedSecret.name) → \(selectedProject.name)" + } + } + + func unlinkSelected() async { + guard let selectedProject, let selectedSecret else { return } + await run("Unlinking via SecretProjectRelationUseCase...") { + // SecretProjectRelationUseCase — 단독 unlink 조작 + try await secretProjectRelationUseCase.unlink( + secretID: selectedSecret.id, + projectID: selectedProject.id + ) + await refreshProjectSecrets() + await refreshSecretProjects() + statusMessage = "Unlinked \(selectedSecret.name) → \(selectedProject.name)" + } + } + + // MARK: - Fetch helpers + + func refreshAll() async { + await run("Refreshing...") { + try await refreshProjectsContent() + await refreshProjectSecrets() + await refreshSecretProjects() + statusMessage = "Refreshed" + } + } + + func refreshProjects() async { + await run("Fetching projects...") { + try await refreshProjectsContent() + statusMessage = "Fetched \(projects.count) project(s)" + } + } + + func refreshProjectsContent() async throws { + projects = try await fetchProjectUseCase.fetchAll() + if let selectedID = selectedProject?.id { + selectedProject = projects.first { $0.id == selectedID } + } else { + selectedProject = projects.first + } + } + + func refreshProjectSecrets() async { + if let selectedProject { + // FetchSecretUseCase.fetch(query:) — SecretQuery.collection.project 로 Project 기준 조회 + await run("Fetching secrets for project...") { + projectSecrets = try await fetchSecretUseCase.fetch( + query: SecretQuery(collection: .project(id: selectedProject.id)) + ) + if let selectedID = selectedSecret?.id { + selectedSecret = projectSecrets.first { $0.id == selectedID } + } + statusMessage = "Fetched \(projectSecrets.count) secret(s) for project" + } + } else { + // Project 미선택 시 전체 Secret 표시 + await run("Fetching all secrets...") { + projectSecrets = try await fetchSecretUseCase.fetch(query: SecretQuery()) + statusMessage = "Fetched \(projectSecrets.count) secret(s)" + } + } + } + + func refreshSecretProjects() async { + guard let selectedSecret else { + secretProjects = [] + return + } + // FetchSecretUseCase.fetchProjects — Secret 기준 연결된 Project 조회 + await run("Fetching projects for secret...") { + secretProjects = try await fetchSecretUseCase.fetchProjects(secretID: selectedSecret.id) + statusMessage = "Fetched \(secretProjects.count) project(s) for secret" + } + } + + func run(_ message: String, operation: () async throws -> Void) async { + isRunning = true + statusMessage = message + do { + try await operation() + } catch { + statusMessage = "Failed: \(error)" + } + isRunning = false + } +} diff --git a/Projects/DVPresentation/Sources/SecretUseCaseDemoView.swift b/Projects/DVPresentation/Sources/SecretUseCaseDemoView.swift index 2416a31..81a1b06 100644 --- a/Projects/DVPresentation/Sources/SecretUseCaseDemoView.swift +++ b/Projects/DVPresentation/Sources/SecretUseCaseDemoView.swift @@ -127,7 +127,7 @@ private extension SecretUseCaseDemoView { Grid(alignment: .leading, horizontalSpacing: 18, verticalSpacing: 10) { resultRow("ID", selectedSecret.id.uuidString) resultRow("Name", selectedSecret.name) - resultRow("Type", selectedSecret.secretType) + resultRow("Type", selectedSecret.secretType.rawValue) resultRow("Service", selectedSecret.service ?? "-") resultRow("Environment", selectedSecret.environment ?? "-") resultRow("Liked", selectedSecret.liked ? "true" : "false") @@ -173,7 +173,8 @@ private extension SecretUseCaseDemoView { let timestamp = Int(Date().timeIntervalSince1970) let draft = SecretDraft( name: "Demo Secret \(timestamp)", - secretType: "apiKey", + secretType: .apiKeyToken, + subType: .apiKey, service: "github", environment: "development", memo: "Created from temporary Presentation UseCase demo", @@ -185,7 +186,8 @@ private extension SecretUseCaseDemoView { let created = try await createSecretUseCase.execute( draft: draft, payload: payload, - metadata: metadata + metadata: metadata, + projectIDs: [] ) secrets = try await fetchSecretUseCase.fetch(query: SecretQuery()) selectedSecret = try await fetchSecretUseCase.fetch(id: created.id) diff --git a/Projects/Devault/Sources/ContentView.swift b/Projects/Devault/Sources/ContentView.swift index 5e28450..061b9a6 100644 --- a/Projects/Devault/Sources/ContentView.swift +++ b/Projects/Devault/Sources/ContentView.swift @@ -4,24 +4,35 @@ import DVPresentation import SwiftUI struct ContentView: View { - private let repository: SecretRepositoryImpl + private let secretRepository: SecretRepositoryImpl + private let projectRepository: ProjectRepositoryImpl private let cryptoService = SecretCryptoServiceImpl() private let authenticationService = LocalUserAuthenticationServiceImpl() init(storage: LocalStorage) { - self.repository = SecretRepositoryImpl(modelContainer: storage.modelContainer) + self.secretRepository = SecretRepositoryImpl(modelContainer: storage.modelContainer) + self.projectRepository = ProjectRepositoryImpl(modelContainer: storage.modelContainer) } var body: some View { - SecretUseCaseDemoView( + ProjectSecretRelationDemoView( + createProjectUseCase: CreateProjectUseCaseImpl( + repository: projectRepository + ), + fetchProjectUseCase: FetchProjectUseCaseImpl( + repository: projectRepository + ), createSecretUseCase: CreateSecretUseCaseImpl( - repository: repository, + repository: secretRepository, cryptoService: cryptoService ), fetchSecretUseCase: FetchSecretUseCaseImpl( - repository: repository, + repository: secretRepository, cryptoService: cryptoService, authenticationService: authenticationService + ), + secretProjectRelationUseCase: SecretProjectRelationUseCaseImpl( + repository: secretRepository ) ) }