From f1cfac911f06bf0607a6e72051ebb42c677ced25 Mon Sep 17 00:00:00 2001 From: dlguszoo Date: Tue, 30 Jun 2026 16:01:09 +0900 Subject: [PATCH 01/24] =?UTF-8?q?[#25]=20feat:=20Project=20entity=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 --- .../DVDomain/Sources/Entity/Project.swift | 22 +++++++++++++++++++ 1 file changed, 22 insertions(+) create mode 100644 Projects/DVDomain/Sources/Entity/Project.swift 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 + } +} From 055831899f4872a14b241478a7f4e6563ceba970 Mon Sep 17 00:00:00 2001 From: dlguszoo Date: Wed, 1 Jul 2026 14:07:01 +0900 Subject: [PATCH 02/24] =?UTF-8?q?[#25]=20chore:=20PatchField=20=ED=8C=8C?= =?UTF-8?q?=EC=9D=BC=EB=B6=84=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Projects/DVDomain/Sources/Repository/PatchField.swift | 7 +++++++ Projects/DVDomain/Sources/Repository/SecretPatch.swift | 5 ----- 2 files changed, 7 insertions(+), 5 deletions(-) create mode 100644 Projects/DVDomain/Sources/Repository/PatchField.swift 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/SecretPatch.swift b/Projects/DVDomain/Sources/Repository/SecretPatch.swift index 406bb8a..bf24131 100644 --- a/Projects/DVDomain/Sources/Repository/SecretPatch.swift +++ b/Projects/DVDomain/Sources/Repository/SecretPatch.swift @@ -45,8 +45,3 @@ public struct SecretPatch: Equatable, Sendable { self.updatedAt = updatedAt } } - -public enum PatchField: Equatable, Sendable { - case unchanged - case set(Value) -} From 02c483e0a20ed3207b7fb5d494434a28a608742f Mon Sep 17 00:00:00 2001 From: dlguszoo Date: Wed, 1 Jul 2026 14:08:11 +0900 Subject: [PATCH 03/24] =?UTF-8?q?[#25]=20feat:=20ProjectRepository=20Inter?= =?UTF-8?q?face,=20Error,=20Patch=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Error/ProjectRepositoryError.swift | 13 +++++++++++++ .../Interface/ProjectRepository.swift | 11 +++++++++++ .../Sources/Repository/ProjectPatch.swift | 17 +++++++++++++++++ 3 files changed, 41 insertions(+) create mode 100644 Projects/DVDomain/Sources/Repository/Error/ProjectRepositoryError.swift create mode 100644 Projects/DVDomain/Sources/Repository/Interface/ProjectRepository.swift create mode 100644 Projects/DVDomain/Sources/Repository/ProjectPatch.swift 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/Interface/ProjectRepository.swift b/Projects/DVDomain/Sources/Repository/Interface/ProjectRepository.swift new file mode 100644 index 0000000..a48dc4a --- /dev/null +++ b/Projects/DVDomain/Sources/Repository/Interface/ProjectRepository.swift @@ -0,0 +1,11 @@ +// Copyright © 2026 Devault. All rights reserved + +import Foundation + +public protocol ProjectRepository: Sendable { + func create(_ project: Project) async throws -> Project + func fetch(id: UUID) async throws -> Project? + func fetchAll() async throws -> [Project] + func patch(id: UUID, with patch: ProjectPatch) async throws -> Project + func delete(id: UUID) async throws +} 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 + } +} From 96d336f46f6807ef27a050b4c89d7fbe9fbc320b Mon Sep 17 00:00:00 2001 From: dlguszoo Date: Wed, 1 Jul 2026 14:08:37 +0900 Subject: [PATCH 04/24] =?UTF-8?q?[#25]=20fix:=20SecretDraft=20=EC=A3=BC?= =?UTF-8?q?=EC=84=9D=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Projects/DVDomain/Sources/UseCase/Draft/SecretDraft.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Projects/DVDomain/Sources/UseCase/Draft/SecretDraft.swift b/Projects/DVDomain/Sources/UseCase/Draft/SecretDraft.swift index 0682ad9..4e57cec 100644 --- a/Projects/DVDomain/Sources/UseCase/Draft/SecretDraft.swift +++ b/Projects/DVDomain/Sources/UseCase/Draft/SecretDraft.swift @@ -2,7 +2,7 @@ import Foundation -/// 아직 저장되지 않은 Secret 초안 정보를 표현합니다. +/// 아직 저장되지 않은 Secret 초안 정보를 표현합니다. 암호화 결과 등의 생성 책임을 UseCase 안으로 모으는 역할. public struct SecretDraft: Equatable, Sendable { public var name: String public var secretType: String From 78e4b6d0fa8c17874ec0e76df82081627c183d59 Mon Sep 17 00:00:00 2001 From: dlguszoo Date: Wed, 1 Jul 2026 14:27:59 +0900 Subject: [PATCH 05/24] =?UTF-8?q?[#25]=20feat:=20Project=20CRUD=20?= =?UTF-8?q?=EA=B4=80=EB=A0=A8=20UseCase=20Interface=20=EC=A0=95=EC=9D=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../UseCase/Interface/Project/CreateProjectUseCase.swift | 5 +++++ .../UseCase/Interface/Project/DeleteProjectUseCase.swift | 7 +++++++ .../UseCase/Interface/Project/FetchProjectUseCase.swift | 8 ++++++++ .../UseCase/Interface/Project/RenameProjectUseCase.swift | 7 +++++++ 4 files changed, 27 insertions(+) create mode 100644 Projects/DVDomain/Sources/UseCase/Interface/Project/CreateProjectUseCase.swift create mode 100644 Projects/DVDomain/Sources/UseCase/Interface/Project/DeleteProjectUseCase.swift create mode 100644 Projects/DVDomain/Sources/UseCase/Interface/Project/FetchProjectUseCase.swift create mode 100644 Projects/DVDomain/Sources/UseCase/Interface/Project/RenameProjectUseCase.swift 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..eca76d0 --- /dev/null +++ b/Projects/DVDomain/Sources/UseCase/Interface/Project/CreateProjectUseCase.swift @@ -0,0 +1,5 @@ +// Copyright © 2026 Devault. All rights reserved + +public protocol CreateProjectUseCase: Sendable { + 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..e356b5e --- /dev/null +++ b/Projects/DVDomain/Sources/UseCase/Interface/Project/DeleteProjectUseCase.swift @@ -0,0 +1,7 @@ +// Copyright © 2026 Devault. All rights reserved + +import Foundation + +public protocol DeleteProjectUseCase: Sendable { + 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..8db995e --- /dev/null +++ b/Projects/DVDomain/Sources/UseCase/Interface/Project/FetchProjectUseCase.swift @@ -0,0 +1,8 @@ +// Copyright © 2026 Devault. All rights reserved + +import Foundation + +public protocol FetchProjectUseCase: Sendable { + func fetch(id: UUID) async throws -> 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..a1f245e --- /dev/null +++ b/Projects/DVDomain/Sources/UseCase/Interface/Project/RenameProjectUseCase.swift @@ -0,0 +1,7 @@ +// Copyright © 2026 Devault. All rights reserved + +import Foundation + +public protocol RenameProjectUseCase: Sendable { + func rename(id: UUID, name: String) async throws -> Project +} From c23059b3bf87eb8e5cbdda8227bb62a48dd0745e Mon Sep 17 00:00:00 2001 From: dlguszoo Date: Wed, 1 Jul 2026 14:28:42 +0900 Subject: [PATCH 06/24] =?UTF-8?q?[#25]=20feat:=20ProjectUseCase=20Error=20?= =?UTF-8?q?=EC=A0=95=EC=9D=98,=20=ED=94=84=EB=A1=9C=EC=A0=9D=ED=8A=B8=20?= =?UTF-8?q?=EC=9D=B4=EB=A6=84=20validation=20Helper=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../UseCase/Error/ProjectUseCaseError.swift | 27 +++++++++++++++++++ .../Impl/Project/ProjectUseCaseHelper.swift | 14 ++++++++++ 2 files changed, 41 insertions(+) create mode 100644 Projects/DVDomain/Sources/UseCase/Error/ProjectUseCaseError.swift create mode 100644 Projects/DVDomain/Sources/UseCase/Impl/Project/ProjectUseCaseHelper.swift 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/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 + } +} From cde44ab1c50212c9aacdce93f351a4ae95f2f24a Mon Sep 17 00:00:00 2001 From: dlguszoo Date: Wed, 1 Jul 2026 14:28:57 +0900 Subject: [PATCH 07/24] =?UTF-8?q?[#25]=20feat:=20Project=20UseCase=20Impl?= =?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 --- .../Project/CreateProjectUseCaseImpl.swift | 35 +++++++++++++++++++ .../Project/DeleteProjectUseCaseImpl.swift | 19 ++++++++++ .../Project/FetchProjectUseCaseImpl.swift | 27 ++++++++++++++ .../Project/RenameProjectUseCaseImpl.swift | 29 +++++++++++++++ 4 files changed, 110 insertions(+) create mode 100644 Projects/DVDomain/Sources/UseCase/Impl/Project/CreateProjectUseCaseImpl.swift create mode 100644 Projects/DVDomain/Sources/UseCase/Impl/Project/DeleteProjectUseCaseImpl.swift create mode 100644 Projects/DVDomain/Sources/UseCase/Impl/Project/FetchProjectUseCaseImpl.swift create mode 100644 Projects/DVDomain/Sources/UseCase/Impl/Project/RenameProjectUseCaseImpl.swift 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/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) + } + } +} From cb49a73b5441c81678a765404bec472b5d8600f3 Mon Sep 17 00:00:00 2001 From: dlguszoo Date: Wed, 1 Jul 2026 14:38:50 +0900 Subject: [PATCH 08/24] =?UTF-8?q?[#25]=20feat:=20Project=20swiftData=20?= =?UTF-8?q?=EB=AA=A8=EB=8D=B8=EA=B3=BC=20entity=20=EB=A7=A4=ED=95=91?= =?UTF-8?q?=ED=95=A8=EC=88=98=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Sources/Storage/Local/Models/Project.swift | 12 ++++++++++++ 1 file changed, 12 insertions(+) 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 + ) + } +} From b7254f37762c1f8476a77550dfab614439c49af1 Mon Sep 17 00:00:00 2001 From: dlguszoo Date: Wed, 1 Jul 2026 14:54:51 +0900 Subject: [PATCH 09/24] =?UTF-8?q?[#25]=20feat:=20ProjectRepositoryImpl=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 --- .../Project/ProjectRepositoryImpl.swift | 149 ++++++++++++++++++ 1 file changed, 149 insertions(+) create mode 100644 Projects/DVData/Sources/RepositoryImpl/Project/ProjectRepositoryImpl.swift 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() + } +} From 58f8f19c5d334fc302d097227f75cce354f8bad3 Mon Sep 17 00:00:00 2001 From: dlguszoo Date: Wed, 1 Jul 2026 15:06:50 +0900 Subject: [PATCH 10/24] =?UTF-8?q?[#25]=20feat:=20SecretRepository=EC=97=90?= =?UTF-8?q?=20project=EC=99=80=20link=EA=B4=80=EB=A0=A8=20=EB=A9=94?= =?UTF-8?q?=EC=84=9C=EB=93=9C=20=EC=A0=95=EC=9D=98=20=EB=B0=8F=20error=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Sources/Repository/Error/SecretRepositoryError.swift | 3 +++ .../Sources/Repository/Interface/SecretRepository.swift | 4 ++++ 2 files changed, 7 insertions(+) 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/SecretRepository.swift b/Projects/DVDomain/Sources/Repository/Interface/SecretRepository.swift index f3a5107..e334819 100644 --- a/Projects/DVDomain/Sources/Repository/Interface/SecretRepository.swift +++ b/Projects/DVDomain/Sources/Repository/Interface/SecretRepository.swift @@ -8,4 +8,8 @@ public protocol SecretRepository: Sendable { func fetch(_ query: SecretQuery) async throws -> [Secret] func patch(id: UUID, with patch: SecretPatch) async throws -> Secret func delete(id: UUID) async throws + + func fetchProjects(secretID: UUID) async throws -> [Project] + func linkProject(secretID: UUID, projectID: UUID) async throws + func unlinkProject(secretID: UUID, projectID: UUID) async throws } From 21848ba3081411898791887272a805cea945ac1e Mon Sep 17 00:00:00 2001 From: dlguszoo Date: Wed, 1 Jul 2026 15:15:12 +0900 Subject: [PATCH 11/24] =?UTF-8?q?[#25]=20feat:=20SecretRepository=20link?= =?UTF-8?q?=20=EA=B4=80=EB=A0=A8=20=EB=A9=94=EC=84=9C=EB=93=9C=20=EA=B5=AC?= =?UTF-8?q?=EC=B2=B4=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Secret/SecretRepositoryImpl.swift | 99 +++++++++++++++++++ 1 file changed, 99 insertions(+) diff --git a/Projects/DVData/Sources/RepositoryImpl/Secret/SecretRepositoryImpl.swift b/Projects/DVData/Sources/RepositoryImpl/Secret/SecretRepositoryImpl.swift index 41bebce..c59777f 100644 --- a/Projects/DVData/Sources/RepositoryImpl/Secret/SecretRepositoryImpl.swift +++ b/Projects/DVData/Sources/RepositoryImpl/Secret/SecretRepositoryImpl.swift @@ -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,6 +203,30 @@ 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 { From 412a290b8b9ada9dc95056d2c5d2e5f54e75d5e4 Mon Sep 17 00:00:00 2001 From: dlguszoo Date: Wed, 1 Jul 2026 15:30:24 +0900 Subject: [PATCH 12/24] =?UTF-8?q?[#25]=20fix:=20pr=ED=85=9C=ED=94=8C?= =?UTF-8?q?=EB=A6=BF=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/PULL_REQUEST_TEMPLATE.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md index c80563b..a9e7a35 100644 --- a/.github/PULL_REQUEST_TEMPLATE.md +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -1,6 +1,6 @@ From 1abefb1876eb8c63fc12bd39b33e8bd0973fb479 Mon Sep 17 00:00:00 2001 From: dlguszoo Date: Wed, 1 Jul 2026 16:30:53 +0900 Subject: [PATCH 13/24] =?UTF-8?q?[#25]=20feat:=20SecretProjectRelationUseC?= =?UTF-8?q?ase=20=EC=A0=95=EC=9D=98=20=EB=B0=8F=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../SecretProjectRelationUseCaseImpl.swift | 27 +++++++++++++++++++ .../Secret/SecretProjectRelationUseCase.swift | 8 ++++++ 2 files changed, 35 insertions(+) create mode 100644 Projects/DVDomain/Sources/UseCase/Impl/Secret/SecretProjectRelationUseCaseImpl.swift create mode 100644 Projects/DVDomain/Sources/UseCase/Interface/Secret/SecretProjectRelationUseCase.swift 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/Interface/Secret/SecretProjectRelationUseCase.swift b/Projects/DVDomain/Sources/UseCase/Interface/Secret/SecretProjectRelationUseCase.swift new file mode 100644 index 0000000..77c9e17 --- /dev/null +++ b/Projects/DVDomain/Sources/UseCase/Interface/Secret/SecretProjectRelationUseCase.swift @@ -0,0 +1,8 @@ +// Copyright © 2026 Devault. All rights reserved + +import Foundation + +public protocol SecretProjectRelationUseCase: Sendable { + func link(secretID: UUID, projectID: UUID) async throws + func unlink(secretID: UUID, projectID: UUID) async throws +} From 969799483b87ba1bf19fbc65e29f2790f5a0cf57 Mon Sep 17 00:00:00 2001 From: dlguszoo Date: Wed, 1 Jul 2026 16:32:20 +0900 Subject: [PATCH 14/24] =?UTF-8?q?[#25]=20feat:=20=EC=8B=9C=ED=81=AC?= =?UTF-8?q?=EB=A6=BF=EC=97=90=20=EC=86=8D=ED=95=B4=20=EC=9E=88=EB=8A=94=20?= =?UTF-8?q?=ED=94=84=EB=A1=9C=EC=A0=9D=ED=8A=B8=20fetch=20=EB=A9=94?= =?UTF-8?q?=EC=84=9C=EB=93=9C=20=EC=A0=95=EC=9D=98=20=EB=B0=8F=20=EA=B5=AC?= =?UTF-8?q?=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../UseCase/Impl/Secret/FetchSecretUseCaseImpl.swift | 8 ++++++++ .../UseCase/Interface/Secret/FetchSecretUseCase.swift | 1 + 2 files changed, 9 insertions(+) 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/Interface/Secret/FetchSecretUseCase.swift b/Projects/DVDomain/Sources/UseCase/Interface/Secret/FetchSecretUseCase.swift index eaf58d1..f9c64b8 100644 --- a/Projects/DVDomain/Sources/UseCase/Interface/Secret/FetchSecretUseCase.swift +++ b/Projects/DVDomain/Sources/UseCase/Interface/Secret/FetchSecretUseCase.swift @@ -5,6 +5,7 @@ import Foundation public protocol FetchSecretUseCase: Sendable { func fetch(id: UUID) async throws -> Secret? func fetch(query: SecretQuery) async throws -> [Secret] + func fetchProjects(secretID: UUID) async throws -> [Project] func revealPayload( id: UUID, as type: Payload.Type From b26aed105f058f6a5b975a1361f0022f482a3c22 Mon Sep 17 00:00:00 2001 From: dlguszoo Date: Wed, 1 Jul 2026 16:32:45 +0900 Subject: [PATCH 15/24] =?UTF-8?q?[#25]=20fix:=20=EC=8B=9C=ED=81=AC?= =?UTF-8?q?=EB=A6=BF=20patch=20=ED=86=B5=ED=95=A9=20=EB=A9=94=EC=84=9C?= =?UTF-8?q?=EB=93=9C=EB=A1=9C=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Impl/Secret/PatchSecretUseCaseImpl.swift | 60 +++++++++++-------- .../Interface/Secret/PatchSecretUseCase.swift | 14 +++-- 2 files changed, 43 insertions(+), 31 deletions(-) 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/Interface/Secret/PatchSecretUseCase.swift b/Projects/DVDomain/Sources/UseCase/Interface/Secret/PatchSecretUseCase.swift index b4201f2..a9ac34f 100644 --- a/Projects/DVDomain/Sources/UseCase/Interface/Secret/PatchSecretUseCase.swift +++ b/Projects/DVDomain/Sources/UseCase/Interface/Secret/PatchSecretUseCase.swift @@ -4,13 +4,17 @@ import Foundation public protocol PatchSecretUseCase: Sendable { func patch(id: UUID, with patch: SecretPatch) async throws -> Secret - func updatePayload( + func update( id: UUID, - payload: Payload + patch: SecretPatch, + payload: Payload, + projectIDs: [UUID] ) async throws -> Secret - func updateMetadata( + func update( id: UUID, - metadata: Metadata + patch: SecretPatch, + payload: Payload, + metadata: Metadata, + projectIDs: [UUID] ) async throws -> Secret - func removeMetadata(id: UUID) async throws -> Secret } From b172529bb1e6b02e140ab72ac24591d982679532 Mon Sep 17 00:00:00 2001 From: dlguszoo Date: Wed, 1 Jul 2026 16:33:18 +0900 Subject: [PATCH 16/24] =?UTF-8?q?[#25]=20fix:=20=EC=8B=9C=ED=81=AC?= =?UTF-8?q?=EB=A6=BF=20create=20=ED=86=B5=ED=95=A9=20=EB=A9=94=EC=84=9C?= =?UTF-8?q?=EB=93=9C=EB=A1=9C=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Impl/Secret/CreateSecretUseCaseImpl.swift | 26 ++++++++++++++++--- .../Secret/CreateSecretUseCase.swift | 8 ++++-- 2 files changed, 28 insertions(+), 6 deletions(-) diff --git a/Projects/DVDomain/Sources/UseCase/Impl/Secret/CreateSecretUseCaseImpl.swift b/Projects/DVDomain/Sources/UseCase/Impl/Secret/CreateSecretUseCaseImpl.swift index c8b5f46..5a51500 100644 --- a/Projects/DVDomain/Sources/UseCase/Impl/Secret/CreateSecretUseCaseImpl.swift +++ b/Projects/DVDomain/Sources/UseCase/Impl/Secret/CreateSecretUseCaseImpl.swift @@ -22,7 +22,8 @@ public struct CreateSecretUseCaseImpl: CreateSecretUseCase { public func execute( draft: SecretDraft, - payload: Payload + payload: Payload, + projectIDs: [UUID] ) async throws -> Secret { do { try SecretUseCaseHelper.validateDraft(draft) @@ -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,7 +52,8 @@ 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) @@ -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/Interface/Secret/CreateSecretUseCase.swift b/Projects/DVDomain/Sources/UseCase/Interface/Secret/CreateSecretUseCase.swift index cc6357e..df70238 100644 --- a/Projects/DVDomain/Sources/UseCase/Interface/Secret/CreateSecretUseCase.swift +++ b/Projects/DVDomain/Sources/UseCase/Interface/Secret/CreateSecretUseCase.swift @@ -1,14 +1,18 @@ // Copyright © 2026 Devault. All rights reserved +import Foundation + public protocol CreateSecretUseCase: Sendable { func execute( draft: SecretDraft, - payload: Payload + payload: Payload, + projectIDs: [UUID] ) async throws -> Secret func execute( draft: SecretDraft, payload: Payload, - metadata: Metadata + metadata: Metadata, + projectIDs: [UUID] ) async throws -> Secret } From 70d24d91b86b73f03df108cb4cf4c0c0cdc1f1e5 Mon Sep 17 00:00:00 2001 From: dlguszoo Date: Wed, 1 Jul 2026 16:36:33 +0900 Subject: [PATCH 17/24] =?UTF-8?q?[#25]=20add:=20usecase/service/repository?= =?UTF-8?q?=20interface=EC=97=90=20=EA=B0=81=20=EB=A9=94=EC=84=9C=EB=93=9C?= =?UTF-8?q?=EB=B3=84=20=EC=A3=BC=EC=84=9D=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Interface/ProjectRepository.swift | 19 ++++++++++ .../Interface/SecretRepository.swift | 35 ++++++++++++++++++- .../Interface/SecretCryptoService.swift | 16 +++++++++ .../Interface/UserAuthenticationService.swift | 2 ++ .../Project/CreateProjectUseCase.swift | 5 +++ .../Project/DeleteProjectUseCase.swift | 2 ++ .../Project/FetchProjectUseCase.swift | 6 ++++ .../Project/RenameProjectUseCase.swift | 5 +++ .../Secret/CreateSecretUseCase.swift | 15 ++++++++ .../Secret/DeleteSecretUseCase.swift | 10 ++++++ .../Interface/Secret/FetchSecretUseCase.swift | 17 +++++++++ .../Interface/Secret/PatchSecretUseCase.swift | 25 +++++++++++++ .../Secret/SecretProjectRelationUseCase.swift | 9 +++++ 13 files changed, 165 insertions(+), 1 deletion(-) diff --git a/Projects/DVDomain/Sources/Repository/Interface/ProjectRepository.swift b/Projects/DVDomain/Sources/Repository/Interface/ProjectRepository.swift index a48dc4a..6a0f6b0 100644 --- a/Projects/DVDomain/Sources/Repository/Interface/ProjectRepository.swift +++ b/Projects/DVDomain/Sources/Repository/Interface/ProjectRepository.swift @@ -3,9 +3,28 @@ 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 e334819..9ab0a58 100644 --- a/Projects/DVDomain/Sources/Repository/Interface/SecretRepository.swift +++ b/Projects/DVDomain/Sources/Repository/Interface/SecretRepository.swift @@ -3,13 +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/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/Interface/Project/CreateProjectUseCase.swift b/Projects/DVDomain/Sources/UseCase/Interface/Project/CreateProjectUseCase.swift index eca76d0..bb1a8c7 100644 --- a/Projects/DVDomain/Sources/UseCase/Interface/Project/CreateProjectUseCase.swift +++ b/Projects/DVDomain/Sources/UseCase/Interface/Project/CreateProjectUseCase.swift @@ -1,5 +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 index e356b5e..addceeb 100644 --- a/Projects/DVDomain/Sources/UseCase/Interface/Project/DeleteProjectUseCase.swift +++ b/Projects/DVDomain/Sources/UseCase/Interface/Project/DeleteProjectUseCase.swift @@ -3,5 +3,7 @@ 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 index 8db995e..f7cb8ff 100644 --- a/Projects/DVDomain/Sources/UseCase/Interface/Project/FetchProjectUseCase.swift +++ b/Projects/DVDomain/Sources/UseCase/Interface/Project/FetchProjectUseCase.swift @@ -3,6 +3,12 @@ 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 index a1f245e..336255f 100644 --- a/Projects/DVDomain/Sources/UseCase/Interface/Project/RenameProjectUseCase.swift +++ b/Projects/DVDomain/Sources/UseCase/Interface/Project/RenameProjectUseCase.swift @@ -3,5 +3,10 @@ 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 df70238..c9d58b7 100644 --- a/Projects/DVDomain/Sources/UseCase/Interface/Secret/CreateSecretUseCase.swift +++ b/Projects/DVDomain/Sources/UseCase/Interface/Secret/CreateSecretUseCase.swift @@ -3,12 +3,27 @@ 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, 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, 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 f9c64b8..9af62c7 100644 --- a/Projects/DVDomain/Sources/UseCase/Interface/Secret/FetchSecretUseCase.swift +++ b/Projects/DVDomain/Sources/UseCase/Interface/Secret/FetchSecretUseCase.swift @@ -3,9 +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 a9ac34f..d72ad47 100644 --- a/Projects/DVDomain/Sources/UseCase/Interface/Secret/PatchSecretUseCase.swift +++ b/Projects/DVDomain/Sources/UseCase/Interface/Secret/PatchSecretUseCase.swift @@ -3,13 +3,38 @@ 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 + + /// Secret의 모든 필드와 Project 연결을 한 번에 수정한다. + /// payload는 내부에서 암호화되며, projectIDs를 기준으로 연결 상태를 재조정한다. + /// - Parameters: + /// - id: 수정할 Secret의 ID + /// - patch: 일반 필드 변경 정보. payload·updatedAt은 이 메서드가 직접 세팅하므로 미지정으로 전달 + /// - payload: 암호화할 Secret 값 + /// - projectIDs: 수정 후 연결되어야 할 Project ID 목록 + /// - Returns: 수정된 Secret func update( id: UUID, patch: SecretPatch, payload: Payload, projectIDs: [UUID] ) async throws -> Secret + + /// 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, patch: SecretPatch, diff --git a/Projects/DVDomain/Sources/UseCase/Interface/Secret/SecretProjectRelationUseCase.swift b/Projects/DVDomain/Sources/UseCase/Interface/Secret/SecretProjectRelationUseCase.swift index 77c9e17..2cc7ef7 100644 --- a/Projects/DVDomain/Sources/UseCase/Interface/Secret/SecretProjectRelationUseCase.swift +++ b/Projects/DVDomain/Sources/UseCase/Interface/Secret/SecretProjectRelationUseCase.swift @@ -3,6 +3,15 @@ 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 } From 1883af3634e4f98eb073ca9f160031acecae1a3b Mon Sep 17 00:00:00 2001 From: dlguszoo Date: Wed, 1 Jul 2026 17:06:03 +0900 Subject: [PATCH 18/24] =?UTF-8?q?[#25]=20feat:=20SecretType,=20SubType=20?= =?UTF-8?q?=EC=A0=95=EC=9D=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../DVDomain/Sources/Entity/SecretType.swift | 42 +++++++++++++++++++ 1 file changed, 42 insertions(+) create mode 100644 Projects/DVDomain/Sources/Entity/SecretType.swift diff --git a/Projects/DVDomain/Sources/Entity/SecretType.swift b/Projects/DVDomain/Sources/Entity/SecretType.swift new file mode 100644 index 0000000..3b9a801 --- /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 + } + } +} From 49bf713dd171c3dbda054c9d946a2cfcd888dfdf Mon Sep 17 00:00:00 2001 From: dlguszoo Date: Wed, 1 Jul 2026 17:06:32 +0900 Subject: [PATCH 19/24] =?UTF-8?q?[#25]=20fix:=20Secret,=20subType=20?= =?UTF-8?q?=EC=A0=95=EC=9D=98=EC=97=90=20=EB=94=B0=EB=A5=B8=20String=20?= =?UTF-8?q?=ED=95=98=EB=93=9C=20=EC=BD=94=EB=94=A9=20=EB=B6=80=EB=B6=84=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 --- .../Secret/SecretRepositoryImpl.swift | 8 ++++---- .../Sources/Storage/Local/Models/Secret.swift | 4 ++-- Projects/DVDomain/Sources/Entity/Secret.swift | 8 ++++---- .../DVDomain/Sources/Repository/SecretPatch.swift | 8 ++++---- .../Sources/UseCase/Draft/SecretDraft.swift | 8 ++++---- .../Impl/Secret/CreateSecretUseCaseImpl.swift | 4 ++-- .../UseCase/Impl/Secret/SecretUseCaseHelper.swift | 15 +++++++-------- 7 files changed, 27 insertions(+), 28 deletions(-) diff --git a/Projects/DVData/Sources/RepositoryImpl/Secret/SecretRepositoryImpl.swift b/Projects/DVData/Sources/RepositoryImpl/Secret/SecretRepositoryImpl.swift index c59777f..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, @@ -233,10 +233,10 @@ extension SecretRepositoryImpl { 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/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/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/Repository/SecretPatch.swift b/Projects/DVDomain/Sources/Repository/SecretPatch.swift index bf24131..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, diff --git a/Projects/DVDomain/Sources/UseCase/Draft/SecretDraft.swift b/Projects/DVDomain/Sources/UseCase/Draft/SecretDraft.swift index 4e57cec..3d650cc 100644 --- a/Projects/DVDomain/Sources/UseCase/Draft/SecretDraft.swift +++ b/Projects/DVDomain/Sources/UseCase/Draft/SecretDraft.swift @@ -5,8 +5,8 @@ import Foundation /// 아직 저장되지 않은 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/Impl/Secret/CreateSecretUseCaseImpl.swift b/Projects/DVDomain/Sources/UseCase/Impl/Secret/CreateSecretUseCaseImpl.swift index 5a51500..4e023a5 100644 --- a/Projects/DVDomain/Sources/UseCase/Impl/Secret/CreateSecretUseCaseImpl.swift +++ b/Projects/DVDomain/Sources/UseCase/Impl/Secret/CreateSecretUseCaseImpl.swift @@ -26,7 +26,7 @@ public struct CreateSecretUseCaseImpl: CreateSecretUseCase { 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( @@ -56,7 +56,7 @@ public struct CreateSecretUseCaseImpl: CreateSecretUseCase { 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) 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이 없으면 전달받은 시각으로 채웁니다. From 9095cda51ae464acabf282bda7073d9f9b9673ee Mon Sep 17 00:00:00 2001 From: dlguszoo Date: Wed, 1 Jul 2026 17:19:52 +0900 Subject: [PATCH 20/24] =?UTF-8?q?[#25]=20feat:=20Secret=20type=20=EB=B0=8F?= =?UTF-8?q?=20subtype=EC=97=90=20=EB=94=B0=EB=A5=B8=20secretContent=20?= =?UTF-8?q?=EB=A7=A4=ED=95=91?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../DVDomain/Sources/Entity/SecretType.swift | 18 ++++---- .../SecretTypeContentMapping.swift | 46 +++++++++++++++++++ 2 files changed, 55 insertions(+), 9 deletions(-) create mode 100644 Projects/DVDomain/Sources/SecretContent/SecretTypeContentMapping.swift diff --git a/Projects/DVDomain/Sources/Entity/SecretType.swift b/Projects/DVDomain/Sources/Entity/SecretType.swift index 3b9a801..abfb86f 100644 --- a/Projects/DVDomain/Sources/Entity/SecretType.swift +++ b/Projects/DVDomain/Sources/Entity/SecretType.swift @@ -10,12 +10,12 @@ public enum SecretType: String, Codable, CaseIterable, Sendable { 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 .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] + case .etc: return [.licenseKey, .custom] } } } @@ -33,10 +33,10 @@ public enum SecretSubType: String, Codable, CaseIterable, Sendable { 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 + 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/SecretContent/SecretTypeContentMapping.swift b/Projects/DVDomain/Sources/SecretContent/SecretTypeContentMapping.swift new file mode 100644 index 0000000..2cbe8d1 --- /dev/null +++ b/Projects/DVDomain/Sources/SecretContent/SecretTypeContentMapping.swift @@ -0,0 +1,46 @@ +// Copyright © 2026 Devault. All rights reserved + +public extension SecretType { + /// subType이 없는 타입의 payload 타입. subType이 있는 타입은 SecretSubType.payloadType을 사용한다. + var payloadType: (any SecretPayloadData.Type)? { + switch self { + case .database: return DatabasePayload.self + case .environmentVariableSet: return EnvSetPayload.self + default: return nil + } + } + + /// subType이 없는 타입의 metadata 타입. subType이 있는 타입은 SecretSubType.metadataType을 사용한다. + var metadataType: (any SecretMetadataContent.Type)? { + switch self { + case .database: return DatabaseMetadata.self + default: return nil + } + } +} + +public extension SecretSubType { + var payloadType: any SecretPayloadData.Type { + switch self { + case .apiKey, .accessToken, .webhookSecret: return APIKeyPayload.self + case .oauthClient: return OAuthClientPayload.self + case .serviceAccount: return ServiceAccountPayload.self + case .sshKey: return SSHKeyPayload.self + case .sslTlsCertificate: return SSLCertPayload.self + case .licenseKey: return LicenseKeyPayload.self + case .custom: return CustomPayload.self + } + } + + var metadataType: (any SecretMetadataContent.Type)? { + switch self { + case .apiKey, .accessToken, .webhookSecret: return APIKeyMetadata.self + case .oauthClient: return OAuthClientMetadata.self + case .serviceAccount: return ServiceAccountMetadata.self + case .sshKey: return SSHKeyMetadata.self + case .sslTlsCertificate: return SSLCertMetadata.self + case .licenseKey: return LicenseKeyMetadata.self + case .custom: return nil + } + } +} From 1047867f1593df290fb4b3fe716dcea1bd162262 Mon Sep 17 00:00:00 2001 From: dlguszoo Date: Wed, 1 Jul 2026 17:25:30 +0900 Subject: [PATCH 21/24] =?UTF-8?q?[#25]=20delete:=20presentation=20layer?= =?UTF-8?q?=EC=97=90=EC=84=9C=20=EB=A7=A4=ED=95=91=20=EC=B2=98=EB=A6=AC=20?= =?UTF-8?q?=EA=B2=B0=EC=A0=95=EC=97=90=20=EB=94=B0=EB=A5=B8=20=ED=8C=8C?= =?UTF-8?q?=EC=9D=BC=20=EC=82=AD=EC=A0=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../SecretTypeContentMapping.swift | 46 ------------------- 1 file changed, 46 deletions(-) delete mode 100644 Projects/DVDomain/Sources/SecretContent/SecretTypeContentMapping.swift diff --git a/Projects/DVDomain/Sources/SecretContent/SecretTypeContentMapping.swift b/Projects/DVDomain/Sources/SecretContent/SecretTypeContentMapping.swift deleted file mode 100644 index 2cbe8d1..0000000 --- a/Projects/DVDomain/Sources/SecretContent/SecretTypeContentMapping.swift +++ /dev/null @@ -1,46 +0,0 @@ -// Copyright © 2026 Devault. All rights reserved - -public extension SecretType { - /// subType이 없는 타입의 payload 타입. subType이 있는 타입은 SecretSubType.payloadType을 사용한다. - var payloadType: (any SecretPayloadData.Type)? { - switch self { - case .database: return DatabasePayload.self - case .environmentVariableSet: return EnvSetPayload.self - default: return nil - } - } - - /// subType이 없는 타입의 metadata 타입. subType이 있는 타입은 SecretSubType.metadataType을 사용한다. - var metadataType: (any SecretMetadataContent.Type)? { - switch self { - case .database: return DatabaseMetadata.self - default: return nil - } - } -} - -public extension SecretSubType { - var payloadType: any SecretPayloadData.Type { - switch self { - case .apiKey, .accessToken, .webhookSecret: return APIKeyPayload.self - case .oauthClient: return OAuthClientPayload.self - case .serviceAccount: return ServiceAccountPayload.self - case .sshKey: return SSHKeyPayload.self - case .sslTlsCertificate: return SSLCertPayload.self - case .licenseKey: return LicenseKeyPayload.self - case .custom: return CustomPayload.self - } - } - - var metadataType: (any SecretMetadataContent.Type)? { - switch self { - case .apiKey, .accessToken, .webhookSecret: return APIKeyMetadata.self - case .oauthClient: return OAuthClientMetadata.self - case .serviceAccount: return ServiceAccountMetadata.self - case .sshKey: return SSHKeyMetadata.self - case .sslTlsCertificate: return SSLCertMetadata.self - case .licenseKey: return LicenseKeyMetadata.self - case .custom: return nil - } - } -} From 1467da83c8dd3802d8a537822a58cff40f26d1f9 Mon Sep 17 00:00:00 2001 From: dlguszoo Date: Wed, 1 Jul 2026 17:33:45 +0900 Subject: [PATCH 22/24] =?UTF-8?q?[#25]=20fix:=20type=20=EC=A0=95=EC=9D=98?= =?UTF-8?q?=EC=97=90=20=EB=94=B0=EB=A5=B8=20=ED=95=98=EB=93=9C=20=EC=BD=94?= =?UTF-8?q?=EB=94=A9=20=EC=A0=9C=EA=B1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../RepositoryImpl/Secret/InMemorySecretQueryFilter.swift | 6 +++--- .../DVPresentation/Sources/SecretUseCaseDemoView.swift | 8 +++++--- 2 files changed, 8 insertions(+), 6 deletions(-) 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/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) From 9f889d80ed89ffa241635660dc7559d6f68a9d5b Mon Sep 17 00:00:00 2001 From: dlguszoo Date: Wed, 1 Jul 2026 17:34:20 +0900 Subject: [PATCH 23/24] =?UTF-8?q?[#25]=20test:=20ProjectSecretRelationDemo?= =?UTF-8?q?View=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../ProjectSecretRelationDemoView.swift | 383 ++++++++++++++++++ Projects/Devault/Sources/ContentView.swift | 21 +- 2 files changed, 399 insertions(+), 5 deletions(-) create mode 100644 Projects/DVPresentation/Sources/ProjectSecretRelationDemoView.swift 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/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 ) ) } From f12befbe00f3c79380b0e60ddb345d9e5d14484e Mon Sep 17 00:00:00 2001 From: dlguszoo Date: Wed, 1 Jul 2026 18:32:49 +0900 Subject: [PATCH 24/24] =?UTF-8?q?[#25]=20fix:=20pr=20=EC=BB=A8=EB=B2=A4?= =?UTF-8?q?=EC=85=98=20=EB=82=B4=EC=9A=A9=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/PULL_REQUEST_TEMPLATE.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md index a9e7a35..7077b83 100644 --- a/.github/PULL_REQUEST_TEMPLATE.md +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -1,6 +1,6 @@