Files
Sportstime/SportsTime/Core/Services/StadiumIdentityService.swift
Trey t c94e373e33 fix: comprehensive codebase hardening — crashes, silent failures, performance, and security
Fixes ~95 issues from deep audit across 12 categories in 82 files:

- Crash prevention: double-resume in PhotoMetadataExtractor, force unwraps in
  DateRangePicker, array bounds checks in polls/achievements, ProGate hit-test
  bypass, Dictionary(uniqueKeysWithValues:) → uniquingKeysWith in 4 files
- Silent failure elimination: all 34 try? sites replaced with do/try/catch +
  logging (SavedTrip, TripDetailView, CanonicalSyncService, BootstrapService,
  CanonicalModels, CKModels, SportsTimeApp, and more)
- Performance: cached DateFormatters (7 files), O(1) team lookups via
  AppDataProvider, achievement definition dictionary, AnimatedBackground
  consolidated from 19 Tasks to 1, task cancellation in SharePreviewView
- Concurrency: UIKit drawing → MainActor.run, background fetch timeout guard,
  @MainActor on ThemeManager/AppearanceManager, SyncLogger read/write race fix
- Planning engine: game end time in travel feasibility, state-aware city
  normalization, exact city matching, DrivingConstraints parameter propagation
- IAP: unknown subscription states → expired, unverified transaction logging,
  entitlements updated before paywall dismiss, restore visible to all users
- Security: API key to Info.plist lookup, filename sanitization in PDF export,
  honest User-Agent, removed stale "Feels" analytics super properties
- Navigation: consolidated competing navigationDestination, boolean → value-based
- Testing: 8 sleep() → waitForExistence, duplicates extracted, Swift 6 compat
- Service bugs: infinite retry cap, duplicate achievement prevention, TOCTOU vote
  fix, PollVote.odg → voterId rename, deterministic placeholder IDs, parallel
  MKDirections, Sendable-safe POI struct

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-27 17:03:09 -06:00

277 lines
8.8 KiB
Swift

//
// StadiumIdentityService.swift
// SportsTime
//
// Service for resolving stadium identities across renames and aliases.
// Wraps CanonicalStadium lookups from SwiftData.
//
import Foundation
import SwiftData
// MARK: - Stadium Identity Service
/// Resolves stadium identities to canonical IDs, handling renames and aliases.
/// Example: "SBC Park", "AT&T Park", and "Oracle Park" all resolve to the same canonical ID.
actor StadiumIdentityService {
// MARK: - Singleton
static let shared = StadiumIdentityService()
// MARK: - Properties
private var modelContainer: ModelContainer?
// Cache for performance
private var uuidToCanonicalId: [UUID: String] = [:]
private var canonicalIdToUUID: [String: UUID] = [:]
private var nameToCanonicalId: [String: String] = [:]
// MARK: - Initialization
private init() {}
// MARK: - Configuration
/// Configure the service with a model container
func configure(with container: ModelContainer) {
self.modelContainer = container
invalidateCache()
}
// MARK: - Public Methods
/// Get the canonical ID for a stadium UUID
/// Returns the same canonicalId for stadiums that are the same physical location
func canonicalId(for stadiumUUID: UUID) async throws -> String? {
// Check cache first
if let cached = uuidToCanonicalId[stadiumUUID] {
return cached
}
guard let container = modelContainer else {
return nil
}
let context = ModelContext(container)
let descriptor = FetchDescriptor<CanonicalStadium>(
predicate: #Predicate<CanonicalStadium> { stadium in
stadium.uuid == stadiumUUID
}
)
guard let stadium = try context.fetch(descriptor).first else {
return nil
}
// Cache the result
uuidToCanonicalId[stadiumUUID] = stadium.canonicalId
canonicalIdToUUID[stadium.canonicalId] = stadium.uuid
return stadium.canonicalId
}
/// Get the canonical ID for a stadium name (searches aliases too)
func canonicalId(forName name: String) async throws -> String? {
let lowercasedName = name.lowercased()
// Check cache first
if let cached = nameToCanonicalId[lowercasedName] {
return cached
}
guard let container = modelContainer else {
return nil
}
let context = ModelContext(container)
// First check stadium aliases
let aliasDescriptor = FetchDescriptor<StadiumAlias>(
predicate: #Predicate<StadiumAlias> { alias in
alias.aliasName == lowercasedName
}
)
if let alias = try context.fetch(aliasDescriptor).first {
nameToCanonicalId[lowercasedName] = alias.stadiumCanonicalId
return alias.stadiumCanonicalId
}
// Fall back to direct stadium name match with predicate filter
let searchName = lowercasedName
let stadiumDescriptor = FetchDescriptor<CanonicalStadium>(
predicate: #Predicate<CanonicalStadium> { $0.name.localizedStandardContains(searchName) }
)
let stadiums = try context.fetch(stadiumDescriptor)
// Verify case-insensitive exact match from narrowed results
if let stadium = stadiums.first(where: { $0.name.lowercased() == lowercasedName }) {
nameToCanonicalId[lowercasedName] = stadium.canonicalId
return stadium.canonicalId
}
return nil
}
/// Check if two stadium UUIDs represent the same physical stadium
func isSameStadium(_ id1: UUID, _ id2: UUID) async throws -> Bool {
guard let canonicalId1 = try await canonicalId(for: id1),
let canonicalId2 = try await canonicalId(for: id2) else {
// If we can't resolve, fall back to direct comparison
return id1 == id2
}
return canonicalId1 == canonicalId2
}
/// Get the current UUID for a canonical stadium ID
func currentUUID(forCanonicalId canonicalId: String) async throws -> UUID? {
// Check cache first
if let cached = canonicalIdToUUID[canonicalId] {
return cached
}
guard let container = modelContainer else {
return nil
}
let context = ModelContext(container)
let descriptor = FetchDescriptor<CanonicalStadium>(
predicate: #Predicate<CanonicalStadium> { stadium in
stadium.canonicalId == canonicalId && stadium.deprecatedAt == nil
}
)
guard let stadium = try context.fetch(descriptor).first else {
return nil
}
// Cache the result
canonicalIdToUUID[canonicalId] = stadium.uuid
uuidToCanonicalId[stadium.uuid] = stadium.canonicalId
return stadium.uuid
}
/// Get the current name for a canonical stadium ID
func currentName(forCanonicalId canonicalId: String) async throws -> String? {
guard let container = modelContainer else {
return nil
}
let context = ModelContext(container)
let descriptor = FetchDescriptor<CanonicalStadium>(
predicate: #Predicate<CanonicalStadium> { stadium in
stadium.canonicalId == canonicalId && stadium.deprecatedAt == nil
}
)
guard let stadium = try context.fetch(descriptor).first else {
return nil
}
return stadium.name
}
/// Get all historical names for a stadium
func allNames(forCanonicalId canonicalId: String) async throws -> [String] {
guard let container = modelContainer else {
return []
}
let context = ModelContext(container)
// Get aliases
let aliasDescriptor = FetchDescriptor<StadiumAlias>(
predicate: #Predicate<StadiumAlias> { alias in
alias.stadiumCanonicalId == canonicalId
}
)
let aliases = try context.fetch(aliasDescriptor)
var names = aliases.map { $0.aliasName }
// Add current name
if let currentName = try await currentName(forCanonicalId: canonicalId) {
if !names.contains(currentName.lowercased()) {
names.append(currentName)
}
}
return names
}
/// Find stadium by approximate location (for photo import)
func findStadium(near latitude: Double, longitude: Double, radiusMeters: Double = 5000) async throws -> CanonicalStadium? {
guard let container = modelContainer else {
return nil
}
let context = ModelContext(container)
// Fetch all active stadiums and filter by distance
let descriptor = FetchDescriptor<CanonicalStadium>(
predicate: #Predicate<CanonicalStadium> { stadium in
stadium.deprecatedAt == nil
}
)
let stadiums = try context.fetch(descriptor)
// Calculate approximate degree ranges for the radius
// At equator: 1 degree 111km, so radiusMeters / 111000 gives degrees
let degreeDelta = radiusMeters / 111000.0
let nearbyStadiums = stadiums.filter { stadium in
abs(stadium.latitude - latitude) <= degreeDelta &&
abs(stadium.longitude - longitude) <= degreeDelta * 1.5 // Account for longitude compression at higher latitudes
}
// If multiple stadiums nearby, find the closest
guard !nearbyStadiums.isEmpty else {
return nil
}
if nearbyStadiums.count == 1 {
return nearbyStadiums.first
}
// Calculate actual distances for the nearby stadiums
return nearbyStadiums.min { s1, s2 in
let d1 = haversineDistance(lat1: latitude, lon1: longitude, lat2: s1.latitude, lon2: s1.longitude)
let d2 = haversineDistance(lat1: latitude, lon1: longitude, lat2: s2.latitude, lon2: s2.longitude)
return d1 < d2
}
}
// MARK: - Cache Management
/// Invalidate all caches (call after sync)
func invalidateCache() {
uuidToCanonicalId.removeAll()
canonicalIdToUUID.removeAll()
nameToCanonicalId.removeAll()
}
// MARK: - Private Helpers
/// Calculate distance between two coordinates using Haversine formula
nonisolated private func haversineDistance(lat1: Double, lon1: Double, lat2: Double, lon2: Double) -> Double {
let R = 6371000.0 // Earth's radius in meters
let phi1 = lat1 * .pi / 180
let phi2 = lat2 * .pi / 180
let deltaPhi = (lat2 - lat1) * .pi / 180
let deltaLambda = (lon2 - lon1) * .pi / 180
let a = sin(deltaPhi / 2) * sin(deltaPhi / 2) +
cos(phi1) * cos(phi2) * sin(deltaLambda / 2) * sin(deltaLambda / 2)
let c = 2 * atan2(sqrt(a), sqrt(1 - a))
return R * c
}
}