Commit e597cdf5b7

e597cdf5b7084634e300af39b622c8552c9cc5e8

parent: 99e4623af1

Unsigned

cmc <hello@cleberg.net> · 2026-07-25 04:45 UTC

refactor: extract audit surface into DomainViewModel+Audit (v5 step 4, 1/n)

First slice of the v5.0.0 god-file decomposition. DomainViewModel.swift is a
single ~4.9k-line class body; this begins splitting it into focused
`DomainViewModel+<Concern>.swift` extensions, one cohesive concern at a time,
with each move behavior-preserving and verified by build + the test net.

- Moves the audit read/CRUD/export surface (audits/auditSession/auditTimeline,
  updateAuditStatus/Notes, toggleAuditChecklistItem, add/update/remove
  AuditFinding, exportAuditData) into DomainViewModel+Audit.swift as an
  extension. Pure move — no logic changes.
- `startAudit(for:)` intentionally stays on the main type: it drives a live
  inspection to seed the session, so it belongs with the inspection pipeline
  until that is extracted. `persistAuditSessions()` is promoted from private to
  internal so both files can call it (its only cross-file dependency).

No project.pbxproj change is needed — DomainDig/ is a file-system-synchronized
group, so the new file is picked up automatically.

DomainViewModel.swift: 4864 -> 4761 lines. App builds clean; unit suite 58/58.

Layout: unified · split

DomainDig/DomainViewModel+Audit.swift added +114
@@ -0,0 +1,114 @@
1import Foundation
2import 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()`.
11extension 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 {
15461546 return try? DomainReportExporter.data(for: currentReport, format: format)
15471547 }
15481548
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
15831549 @discardableResult
15841550 func startAudit(for domain: String, reviewer: String? = nil) async -> AuditSession? {
15851551 let normalizedDomain = domain
@@ -1625,75 +1591,6 @@ final class DomainViewModel {
16251591 return session
16261592 }
16271593
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
16971594 func loadOwnershipHistory() async {
16981595 guard !searchedDomain.isEmpty else { return }
16991596 guard DataAccessService.hasAccess(to: .ownershipHistory) else {
@@ -2605,7 +2502,7 @@ final class DomainViewModel {
26052502 DomainDebugLog.signpostEnd("DomainViewModel.persistHistory", start: persistStartedAt, extra: "count=\(history.count)")
26062503 }
26072504
2608 private func persistAuditSessions() {
2505 func persistAuditSessions() {
26092506 DomainDataPortabilityService.saveAuditSessions(auditSessions)
26102507 refreshDataLifecycleSummary()
26112508 }