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 | return try? DomainReportExporter.data(for: currentReport, format: format) | 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 | @discardableResult | 1549 | @discardableResult |
| 1584 | func startAudit(for domain: String, reviewer: String? = nil) async -> AuditSession? { | 1550 | func startAudit(for domain: String, reviewer: String? = nil) async -> AuditSession? { |
| 1585 | let normalizedDomain = domain | 1551 | let normalizedDomain = domain |
| @@ -1625,75 +1591,6 @@ final class DomainViewModel { | |||
| 1625 | return session | 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 | func loadOwnershipHistory() async { | 1594 | func loadOwnershipHistory() async { |
| 1698 | guard !searchedDomain.isEmpty else { return } | 1595 | guard !searchedDomain.isEmpty else { return } |
| 1699 | guard DataAccessService.hasAccess(to: .ownershipHistory) else { | 1596 | guard DataAccessService.hasAccess(to: .ownershipHistory) else { |
| @@ -2605,7 +2502,7 @@ final class DomainViewModel { | |||
| 2605 | DomainDebugLog.signpostEnd("DomainViewModel.persistHistory", start: persistStartedAt, extra: "count=\(history.count)") | 2502 | DomainDebugLog.signpostEnd("DomainViewModel.persistHistory", start: persistStartedAt, extra: "count=\(history.count)") |
| 2606 | } | 2503 | } |
| 2607 | 2504 | ||
| 2608 | private func persistAuditSessions() { | 2505 | func persistAuditSessions() { |
| 2609 | DomainDataPortabilityService.saveAuditSessions(auditSessions) | 2506 | DomainDataPortabilityService.saveAuditSessions(auditSessions) |
| 2610 | refreshDataLifecycleSummary() | 2507 | refreshDataLifecycleSummary() |
| 2611 | } | 2508 | } |