Commit e597cdf5b7
Unsigned
Layout: unified · split
DomainDig/DomainViewModel+Audit.swift added +114
| @@ -0,0 +1,114 @@ | ||
| 1 | import Foundation | |
| 2 | import SwiftUI | |
| 3 | ||
| 4 | /// Audit review surface of `DomainViewModel`: reading audit sessions and their | |
| 5 | /// timeline, and mutating a session's status, notes, checklist, and findings. | |
| 6 | /// | |
| 7 | /// `startAudit(for:)` deliberately stays on the main type — it drives a live | |
| 8 | /// inspection to seed the session, so it belongs with the inspection pipeline | |
| 9 | /// until that is extracted. Everything here operates purely on `auditSessions` | |
| 10 | /// and persists through `persistAuditSessions()`. | |
| 11 | extension DomainViewModel { | |
| 12 | func audits(for domain: String) -> [AuditSession] { | |
| 13 | auditSessions | |
| 14 | .filter { $0.domain.caseInsensitiveCompare(domain) == .orderedSame } | |
| 15 | .sorted { $0.createdAt > $1.createdAt } | |
| 16 | } | |
| 17 | ||
| 18 | func auditSession(withID id: UUID) -> AuditSession? { | |
| 19 | auditSessions.first(where: { $0.id == id }) | |
| 20 | } | |
| 21 | ||
| 22 | func auditTimeline(for domain: String) -> [AuditTimelinePoint] { | |
| 23 | let sessions = audits(for: domain).sorted { $0.createdAt > $1.createdAt } | |
| 24 | return sessions.map { session in | |
| 25 | let repeatedIssues = sessions | |
| 26 | .filter { $0.id != session.id } | |
| 27 | .flatMap(\.findings) | |
| 28 | .map { $0.title.lowercased() } | |
| 29 | let repeatedIssueCount = session.findings.filter { | |
| 30 | repeatedIssues.contains($0.title.lowercased()) | |
| 31 | }.count | |
| 32 | ||
| 33 | return AuditTimelinePoint( | |
| 34 | id: session.id, | |
| 35 | sessionID: session.id, | |
| 36 | domain: session.domain, | |
| 37 | createdAt: session.createdAt, | |
| 38 | status: session.status, | |
| 39 | findingCount: session.findings.count, | |
| 40 | openHighSeverityCount: session.findings.filter { $0.severity == .high && $0.status != .resolved }.count, | |
| 41 | repeatedIssueCount: repeatedIssueCount | |
| 42 | ) | |
| 43 | } | |
| 44 | } | |
| 45 | ||
| 46 | func updateAuditStatus(_ status: AuditStatus, sessionID: UUID) { | |
| 47 | guard let index = auditSessions.firstIndex(where: { $0.id == sessionID }) else { return } | |
| 48 | auditSessions[index].status = status | |
| 49 | persistAuditSessions() | |
| 50 | } | |
| 51 | ||
| 52 | func updateAuditNotes(_ notes: String, sessionID: UUID) { | |
| 53 | guard let index = auditSessions.firstIndex(where: { $0.id == sessionID }) else { return } | |
| 54 | auditSessions[index].notes = notes.trimmingCharacters(in: .whitespacesAndNewlines) | |
| 55 | persistAuditSessions() | |
| 56 | } | |
| 57 | ||
| 58 | func toggleAuditChecklistItem(sessionID: UUID, itemID: UUID) { | |
| 59 | guard let sessionIndex = auditSessions.firstIndex(where: { $0.id == sessionID }), | |
| 60 | let itemIndex = auditSessions[sessionIndex].checklist.firstIndex(where: { $0.id == itemID }) else { | |
| 61 | return | |
| 62 | } | |
| 63 | ||
| 64 | auditSessions[sessionIndex].checklist[itemIndex].isComplete.toggle() | |
| 65 | auditSessions[sessionIndex].checklist[itemIndex].completedAt = auditSessions[sessionIndex].checklist[itemIndex].isComplete ? Date() : nil | |
| 66 | persistAuditSessions() | |
| 67 | } | |
| 68 | ||
| 69 | func addAuditFinding( | |
| 70 | sessionID: UUID, | |
| 71 | title: String, | |
| 72 | severity: AuditFindingSeverity, | |
| 73 | summary: String, | |
| 74 | evidenceReferences: [String], | |
| 75 | notes: String, | |
| 76 | checklistAreas: [AuditChecklistArea] | |
| 77 | ) { | |
| 78 | guard let index = auditSessions.firstIndex(where: { $0.id == sessionID }) else { return } | |
| 79 | let finding = AuditFinding( | |
| 80 | title: title, | |
| 81 | severity: severity, | |
| 82 | summary: summary, | |
| 83 | evidenceReferences: evidenceReferences, | |
| 84 | notes: notes, | |
| 85 | status: .open, | |
| 86 | checklistAreas: checklistAreas | |
| 87 | ) | |
| 88 | auditSessions[index].findings.insert(finding, at: 0) | |
| 89 | persistAuditSessions() | |
| 90 | } | |
| 91 | ||
| 92 | func updateAuditFinding(_ finding: AuditFinding, sessionID: UUID) { | |
| 93 | guard let sessionIndex = auditSessions.firstIndex(where: { $0.id == sessionID }), | |
| 94 | let findingIndex = auditSessions[sessionIndex].findings.firstIndex(where: { $0.id == finding.id }) else { | |
| 95 | return | |
| 96 | } | |
| 97 | ||
| 98 | var updatedFinding = finding | |
| 99 | updatedFinding.updatedAt = Date() | |
| 100 | auditSessions[sessionIndex].findings[findingIndex] = updatedFinding | |
| 101 | persistAuditSessions() | |
| 102 | } | |
| 103 | ||
| 104 | func removeAuditFindings(at offsets: IndexSet, sessionID: UUID) { | |
| 105 | guard let sessionIndex = auditSessions.firstIndex(where: { $0.id == sessionID }) else { return } | |
| 106 | auditSessions[sessionIndex].findings.remove(atOffsets: offsets) | |
| 107 | persistAuditSessions() | |
| 108 | } | |
| 109 | ||
| 110 | func exportAuditData(sessionID: UUID, format: AuditExportFormat) -> Data? { | |
| 111 | guard let session = auditSession(withID: sessionID) else { return nil } | |
| 112 | return try? AuditExporter.data(for: session, format: format) | |
| 113 | } | |
| 114 | } | |
DomainDig/DomainViewModel.swift +1 −104
| @@ -1546,40 +1546,6 @@ final class DomainViewModel { | ||
| 1546 | 1546 | return try? DomainReportExporter.data(for: currentReport, format: format) |
| 1547 | 1547 | } |
| 1548 | 1548 | |
| 1549 | func audits(for domain: String) -> [AuditSession] { | |
| 1550 | auditSessions | |
| 1551 | .filter { $0.domain.caseInsensitiveCompare(domain) == .orderedSame } | |
| 1552 | .sorted { $0.createdAt > $1.createdAt } | |
| 1553 | } | |
| 1554 | ||
| 1555 | func auditSession(withID id: UUID) -> AuditSession? { | |
| 1556 | auditSessions.first(where: { $0.id == id }) | |
| 1557 | } | |
| 1558 | ||
| 1559 | func auditTimeline(for domain: String) -> [AuditTimelinePoint] { | |
| 1560 | let sessions = audits(for: domain).sorted { $0.createdAt > $1.createdAt } | |
| 1561 | return sessions.map { session in | |
| 1562 | let repeatedIssues = sessions | |
| 1563 | .filter { $0.id != session.id } | |
| 1564 | .flatMap(\.findings) | |
| 1565 | .map { $0.title.lowercased() } | |
| 1566 | let repeatedIssueCount = session.findings.filter { | |
| 1567 | repeatedIssues.contains($0.title.lowercased()) | |
| 1568 | }.count | |
| 1569 | ||
| 1570 | return AuditTimelinePoint( | |
| 1571 | id: session.id, | |
| 1572 | sessionID: session.id, | |
| 1573 | domain: session.domain, | |
| 1574 | createdAt: session.createdAt, | |
| 1575 | status: session.status, | |
| 1576 | findingCount: session.findings.count, | |
| 1577 | openHighSeverityCount: session.findings.filter { $0.severity == .high && $0.status != .resolved }.count, | |
| 1578 | repeatedIssueCount: repeatedIssueCount | |
| 1579 | ) | |
| 1580 | } | |
| 1581 | } | |
| 1582 | ||
| 1583 | 1549 | @discardableResult |
| 1584 | 1550 | func startAudit(for domain: String, reviewer: String? = nil) async -> AuditSession? { |
| 1585 | 1551 | let normalizedDomain = domain |
| @@ -1625,75 +1591,6 @@ final class DomainViewModel { | ||
| 1625 | 1591 | return session |
| 1626 | 1592 | } |
| 1627 | 1593 | |
| 1628 | func updateAuditStatus(_ status: AuditStatus, sessionID: UUID) { | |
| 1629 | guard let index = auditSessions.firstIndex(where: { $0.id == sessionID }) else { return } | |
| 1630 | auditSessions[index].status = status | |
| 1631 | persistAuditSessions() | |
| 1632 | } | |
| 1633 | ||
| 1634 | func updateAuditNotes(_ notes: String, sessionID: UUID) { | |
| 1635 | guard let index = auditSessions.firstIndex(where: { $0.id == sessionID }) else { return } | |
| 1636 | auditSessions[index].notes = notes.trimmingCharacters(in: .whitespacesAndNewlines) | |
| 1637 | persistAuditSessions() | |
| 1638 | } | |
| 1639 | ||
| 1640 | func toggleAuditChecklistItem(sessionID: UUID, itemID: UUID) { | |
| 1641 | guard let sessionIndex = auditSessions.firstIndex(where: { $0.id == sessionID }), | |
| 1642 | let itemIndex = auditSessions[sessionIndex].checklist.firstIndex(where: { $0.id == itemID }) else { | |
| 1643 | return | |
| 1644 | } | |
| 1645 | ||
| 1646 | auditSessions[sessionIndex].checklist[itemIndex].isComplete.toggle() | |
| 1647 | auditSessions[sessionIndex].checklist[itemIndex].completedAt = auditSessions[sessionIndex].checklist[itemIndex].isComplete ? Date() : nil | |
| 1648 | persistAuditSessions() | |
| 1649 | } | |
| 1650 | ||
| 1651 | func addAuditFinding( | |
| 1652 | sessionID: UUID, | |
| 1653 | title: String, | |
| 1654 | severity: AuditFindingSeverity, | |
| 1655 | summary: String, | |
| 1656 | evidenceReferences: [String], | |
| 1657 | notes: String, | |
| 1658 | checklistAreas: [AuditChecklistArea] | |
| 1659 | ) { | |
| 1660 | guard let index = auditSessions.firstIndex(where: { $0.id == sessionID }) else { return } | |
| 1661 | let finding = AuditFinding( | |
| 1662 | title: title, | |
| 1663 | severity: severity, | |
| 1664 | summary: summary, | |
| 1665 | evidenceReferences: evidenceReferences, | |
| 1666 | notes: notes, | |
| 1667 | status: .open, | |
| 1668 | checklistAreas: checklistAreas | |
| 1669 | ) | |
| 1670 | auditSessions[index].findings.insert(finding, at: 0) | |
| 1671 | persistAuditSessions() | |
| 1672 | } | |
| 1673 | ||
| 1674 | func updateAuditFinding(_ finding: AuditFinding, sessionID: UUID) { | |
| 1675 | guard let sessionIndex = auditSessions.firstIndex(where: { $0.id == sessionID }), | |
| 1676 | let findingIndex = auditSessions[sessionIndex].findings.firstIndex(where: { $0.id == finding.id }) else { | |
| 1677 | return | |
| 1678 | } | |
| 1679 | ||
| 1680 | var updatedFinding = finding | |
| 1681 | updatedFinding.updatedAt = Date() | |
| 1682 | auditSessions[sessionIndex].findings[findingIndex] = updatedFinding | |
| 1683 | persistAuditSessions() | |
| 1684 | } | |
| 1685 | ||
| 1686 | func removeAuditFindings(at offsets: IndexSet, sessionID: UUID) { | |
| 1687 | guard let sessionIndex = auditSessions.firstIndex(where: { $0.id == sessionID }) else { return } | |
| 1688 | auditSessions[sessionIndex].findings.remove(atOffsets: offsets) | |
| 1689 | persistAuditSessions() | |
| 1690 | } | |
| 1691 | ||
| 1692 | func exportAuditData(sessionID: UUID, format: AuditExportFormat) -> Data? { | |
| 1693 | guard let session = auditSession(withID: sessionID) else { return nil } | |
| 1694 | return try? AuditExporter.data(for: session, format: format) | |
| 1695 | } | |
| 1696 | ||
| 1697 | 1594 | func loadOwnershipHistory() async { |
| 1698 | 1595 | guard !searchedDomain.isEmpty else { return } |
| 1699 | 1596 | guard DataAccessService.hasAccess(to: .ownershipHistory) else { |
| @@ -2605,7 +2502,7 @@ final class DomainViewModel { | ||
| 2605 | 2502 | DomainDebugLog.signpostEnd("DomainViewModel.persistHistory", start: persistStartedAt, extra: "count=\(history.count)") |
| 2606 | 2503 | } |
| 2607 | 2504 | |
| 2608 | private func persistAuditSessions() { | |
| 2505 | func persistAuditSessions() { | |
| 2609 | 2506 | DomainDataPortabilityService.saveAuditSessions(auditSessions) |
| 2610 | 2507 | refreshDataLifecycleSummary() |
| 2611 | 2508 | } |