Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
24 commits
Select commit Hold shift + click to select a range
f1cfac9
[#25] feat: Project entity 구현
dlguszoo Jun 30, 2026
0558318
[#25] chore: PatchField 파일분리
dlguszoo Jul 1, 2026
02c483e
[#25] feat: ProjectRepository Interface, Error, Patch 구현
dlguszoo Jul 1, 2026
96d336f
[#25] fix: SecretDraft 주석 수정
dlguszoo Jul 1, 2026
78e4b6d
[#25] feat: Project CRUD 관련 UseCase Interface 정의
dlguszoo Jul 1, 2026
c23059b
[#25] feat: ProjectUseCase Error 정의, 프로젝트 이름 validation Helper 구현
dlguszoo Jul 1, 2026
cde44ab
[#25] feat: Project UseCase Impl 구현
dlguszoo Jul 1, 2026
cb49a73
[#25] feat: Project swiftData 모델과 entity 매핑함수 구현
dlguszoo Jul 1, 2026
b7254f3
[#25] feat: ProjectRepositoryImpl 구현
dlguszoo Jul 1, 2026
58f8f19
[#25] feat: SecretRepository에 project와 link관련 메서드 정의 및 error 추가
dlguszoo Jul 1, 2026
21848ba
[#25] feat: SecretRepository link 관련 메서드 구체 구현
dlguszoo Jul 1, 2026
412a290
[#25] fix: pr템플릿 수정
dlguszoo Jul 1, 2026
1abefb1
[#25] feat: SecretProjectRelationUseCase 정의 및 구현
dlguszoo Jul 1, 2026
9697994
[#25] feat: 시크릿에 속해 있는 프로젝트 fetch 메서드 정의 및 구현
dlguszoo Jul 1, 2026
b26aed1
[#25] fix: 시크릿 patch 통합 메서드로 구현
dlguszoo Jul 1, 2026
b172529
[#25] fix: 시크릿 create 통합 메서드로 수정
dlguszoo Jul 1, 2026
70d24d9
[#25] add: usecase/service/repository interface에 각 메서드별 주석 추가
dlguszoo Jul 1, 2026
1883af3
[#25] feat: SecretType, SubType 정의
dlguszoo Jul 1, 2026
49bf713
[#25] fix: Secret, subType 정의에 따른 String 하드 코딩 부분 제거
dlguszoo Jul 1, 2026
9095cda
[#25] feat: Secret type 및 subtype에 따른 secretContent 매핑
dlguszoo Jul 1, 2026
1047867
[#25] delete: presentation layer에서 매핑 처리 결정에 따른 파일 삭제
dlguszoo Jul 1, 2026
1467da8
[#25] fix: type 정의에 따른 하드 코딩 제거
dlguszoo Jul 1, 2026
9f889d8
[#25] test: ProjectSecretRelationDemoView 추가
dlguszoo Jul 1, 2026
f12befb
[#25] fix: pr 컨벤션 내용 수정
dlguszoo Jul 1, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions .github/PULL_REQUEST_TEMPLATE.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
<!--
🙏 PR 제목 컨벤션 (Gitmoji + 타입 + 이슈 번호 + 작업 요약)
예시: ✨ Feat: #167 예약 취소 구현
🙏 PR 제목 컨벤션 (타입/#이슈 번호 - 작업 요약)
예시: Feature/#167 - 예약 취소 구현
※ PR 생성 시 Assignees 및 Labels 설정도 잊지 마세요!
-->

Expand Down
Original file line number Diff line number Diff line change
@@ -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<SwiftDataModel.Project>(
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<SwiftDataModel.Project>(
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<SwiftDataModel.Project>()
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()
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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 {
Expand All @@ -128,16 +203,40 @@ extension SecretRepositoryImpl {
return try modelContext.fetch(descriptor).first
}

private func fetchLocalProject(id: UUID) throws -> SwiftDataModel.Project? {
var descriptor = FetchDescriptor<SwiftDataModel.Project>(
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<SwiftDataModel.SecretProjectLink>(
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
Expand Down
12 changes: 12 additions & 0 deletions Projects/DVData/Sources/Storage/Local/Models/Project.swift
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
// Copyright © 2026 Devault. All rights reserved

import DVDomain
import Foundation
import SwiftData

Expand Down Expand Up @@ -32,3 +33,14 @@ extension SwiftDataModel {
}
}
}

extension SwiftDataModel.Project {
func toDomain() -> DVDomain.Project {
DVDomain.Project(
id: id,
name: name,
createdAt: createdAt,
updatedAt: updatedAt
)
}
}
4 changes: 2 additions & 2 deletions Projects/DVData/Sources/Storage/Local/Models/Secret.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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:)),
Comment on lines +83 to +84

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win

잘못된 rawValue를 조용히 .etc/nil로 숨기지 마세요.

저장된 secretType/subType이 enum으로 복원되지 않으면 저장소 손상으로 처리해야 합니다. 지금처럼 fallback하면 잘못된 타입이 정상 데이터처럼 도메인으로 유입됩니다.

🐛 제안 수정
         guard let payload else {
             throw SecretRepositoryError.corruptedStorage
         }
+        guard let domainSecretType = DVDomain.SecretType(rawValue: secretType) else {
+            throw SecretRepositoryError.corruptedStorage
+        }
+        let domainSubType: DVDomain.SecretSubType?
+        if let subType {
+            guard let parsedSubType = DVDomain.SecretSubType(rawValue: subType) else {
+                throw SecretRepositoryError.corruptedStorage
+            }
+            domainSubType = parsedSubType
+        } else {
+            domainSubType = nil
+        }
 
         return DVDomain.Secret(
             id: id,
             name: name,
-            secretType: DVDomain.SecretType(rawValue: secretType) ?? .etc,
-            subType: subType.flatMap(DVDomain.SecretSubType.init(rawValue:)),
+            secretType: domainSecretType,
+            subType: domainSubType,
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
secretType: DVDomain.SecretType(rawValue: secretType) ?? .etc,
subType: subType.flatMap(DVDomain.SecretSubType.init(rawValue:)),
guard let payload else {
throw SecretRepositoryError.corruptedStorage
}
guard let domainSecretType = DVDomain.SecretType(rawValue: secretType) else {
throw SecretRepositoryError.corruptedStorage
}
let domainSubType: DVDomain.SecretSubType?
if let subType {
guard let parsedSubType = DVDomain.SecretSubType(rawValue: subType) else {
throw SecretRepositoryError.corruptedStorage
}
domainSubType = parsedSubType
} else {
domainSubType = nil
}
return DVDomain.Secret(
id: id,
name: name,
secretType: domainSecretType,
subType: domainSubType,
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@Projects/DVData/Sources/Storage/Local/Models/Secret.swift` around lines 83 -
84, `Secret`의 복원 로직에서 잘못된 `rawValue`를 `DVDomain.SecretType(rawValue:) ?? .etc`와
`DVDomain.SecretSubType.init(rawValue:)`의 fallback으로 숨기지 말고,
`secretType`/`subType`가 enum으로 변환되지 않으면 저장소 손상으로 간주하도록 `Secret` 초기화 또는 매핑 경로를
수정하세요. `DVDomain.SecretType`과 `DVDomain.SecretSubType` 복원 실패 시에는 기본값을 넣지 말고 실패를
전파하거나 별도 오류를 발생시켜, 잘못된 데이터가 정상 도메인 값으로 유입되지 않게 하세요.

service: service,
environment: environment,
expiresAt: expiresAt,
Expand Down
22 changes: 22 additions & 0 deletions Projects/DVDomain/Sources/Entity/Project.swift
Original file line number Diff line number Diff line change
@@ -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
}
}
8 changes: 4 additions & 4 deletions Projects/DVDomain/Sources/Entity/Secret.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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?
Expand All @@ -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,
Expand Down
Loading