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>
417 lines
13 KiB
Swift
417 lines
13 KiB
Swift
//
|
|
// PollService.swift
|
|
// SportsTime
|
|
//
|
|
// CloudKit service for trip polls and voting
|
|
//
|
|
|
|
import Foundation
|
|
import CloudKit
|
|
import SwiftData
|
|
|
|
// MARK: - Poll Errors
|
|
|
|
enum PollError: Error, LocalizedError {
|
|
case notSignedIn
|
|
case pollNotFound
|
|
case alreadyVoted
|
|
case notPollOwner
|
|
case notVoteOwner
|
|
case networkUnavailable
|
|
case encodingError
|
|
case unknown(Error)
|
|
|
|
var errorDescription: String? {
|
|
switch self {
|
|
case .notSignedIn:
|
|
return "Please sign in to iCloud to use polls."
|
|
case .pollNotFound:
|
|
return "Poll not found. It may have been deleted."
|
|
case .alreadyVoted:
|
|
return "You have already voted on this poll."
|
|
case .notPollOwner:
|
|
return "Only the poll owner can perform this action."
|
|
case .notVoteOwner:
|
|
return "Only the vote owner can update this vote."
|
|
case .networkUnavailable:
|
|
return "Unable to connect. Please check your internet connection."
|
|
case .encodingError:
|
|
return "Failed to save poll data."
|
|
case .unknown(let error):
|
|
return "An error occurred: \(error.localizedDescription)"
|
|
}
|
|
}
|
|
}
|
|
|
|
// MARK: - Poll Service
|
|
|
|
actor PollService {
|
|
static let shared = PollService()
|
|
|
|
private let container: CKContainer
|
|
private let publicDatabase: CKDatabase
|
|
|
|
private var currentUserRecordID: String?
|
|
private var pollSubscriptionIDs: Set<CKSubscription.ID> = []
|
|
/// Guard against TOCTOU races in vote submission
|
|
private var votesInProgress: Set<String> = []
|
|
|
|
private init() {
|
|
self.container = CloudKitContainerConfig.makeContainer()
|
|
self.publicDatabase = container.publicCloudDatabase
|
|
}
|
|
|
|
// MARK: - User Identity
|
|
|
|
func getCurrentUserRecordID() async throws -> String {
|
|
if let cached = currentUserRecordID {
|
|
return cached
|
|
}
|
|
|
|
let recordID = try await container.userRecordID()
|
|
currentUserRecordID = recordID.recordName
|
|
return recordID.recordName
|
|
}
|
|
|
|
func isSignedIn() async -> Bool {
|
|
do {
|
|
_ = try await getCurrentUserRecordID()
|
|
return true
|
|
} catch {
|
|
return false
|
|
}
|
|
}
|
|
|
|
// MARK: - Poll CRUD
|
|
|
|
func createPoll(_ poll: TripPoll) async throws -> TripPoll {
|
|
let ckPoll = CKTripPoll(poll: poll)
|
|
|
|
do {
|
|
try await publicDatabase.save(ckPoll.record)
|
|
return poll
|
|
} catch let error as CKError {
|
|
throw mapCloudKitError(error)
|
|
} catch {
|
|
throw PollError.unknown(error)
|
|
}
|
|
}
|
|
|
|
func fetchPoll(byShareCode shareCode: String) async throws -> TripPoll {
|
|
let predicate = NSPredicate(format: "%K == %@", CKTripPoll.shareCodeKey, shareCode.uppercased())
|
|
let query = CKQuery(recordType: CKRecordType.tripPoll, predicate: predicate)
|
|
|
|
do {
|
|
let (results, _) = try await publicDatabase.records(matching: query)
|
|
|
|
guard let result = results.first,
|
|
case .success(let record) = result.1,
|
|
let poll = CKTripPoll(record: record).toPoll()
|
|
else {
|
|
throw PollError.pollNotFound
|
|
}
|
|
|
|
return poll
|
|
} catch let error as CKError {
|
|
throw mapCloudKitError(error)
|
|
} catch let error as PollError {
|
|
throw error
|
|
} catch {
|
|
throw PollError.unknown(error)
|
|
}
|
|
}
|
|
|
|
func fetchPoll(byId pollId: UUID) async throws -> TripPoll {
|
|
let recordID = CKRecord.ID(recordName: pollId.uuidString)
|
|
|
|
do {
|
|
let record = try await publicDatabase.record(for: recordID)
|
|
guard let poll = CKTripPoll(record: record).toPoll() else {
|
|
throw PollError.pollNotFound
|
|
}
|
|
return poll
|
|
} catch let error as CKError {
|
|
if error.code == .unknownItem {
|
|
throw PollError.pollNotFound
|
|
}
|
|
throw mapCloudKitError(error)
|
|
} catch let error as PollError {
|
|
throw error
|
|
} catch {
|
|
throw PollError.unknown(error)
|
|
}
|
|
}
|
|
|
|
func fetchMyPolls() async throws -> [TripPoll] {
|
|
let userId = try await getCurrentUserRecordID()
|
|
let predicate = NSPredicate(format: "%K == %@", CKTripPoll.ownerIdKey, userId)
|
|
let query = CKQuery(recordType: CKRecordType.tripPoll, predicate: predicate)
|
|
query.sortDescriptors = [NSSortDescriptor(key: CKTripPoll.createdAtKey, ascending: false)]
|
|
|
|
do {
|
|
let (results, _) = try await publicDatabase.records(matching: query)
|
|
|
|
return results.compactMap { result in
|
|
guard case .success(let record) = result.1 else { return nil }
|
|
return CKTripPoll(record: record).toPoll()
|
|
}
|
|
} catch let error as CKError {
|
|
throw mapCloudKitError(error)
|
|
} catch {
|
|
throw PollError.unknown(error)
|
|
}
|
|
}
|
|
|
|
func updatePoll(_ poll: TripPoll, resetVotes: Bool = false) async throws -> TripPoll {
|
|
let userId = try await getCurrentUserRecordID()
|
|
guard poll.ownerId == userId else {
|
|
throw PollError.notPollOwner
|
|
}
|
|
|
|
var updatedPoll = poll
|
|
updatedPoll.modifiedAt = Date()
|
|
|
|
let ckPoll = CKTripPoll(poll: updatedPoll)
|
|
|
|
do {
|
|
try await publicDatabase.save(ckPoll.record)
|
|
|
|
if resetVotes {
|
|
try await deleteVotes(forPollId: poll.id)
|
|
}
|
|
|
|
return updatedPoll
|
|
} catch let error as CKError {
|
|
throw mapCloudKitError(error)
|
|
} catch {
|
|
throw PollError.unknown(error)
|
|
}
|
|
}
|
|
|
|
func deletePoll(_ pollId: UUID) async throws {
|
|
// Verify ownership before deleting
|
|
let poll = try await fetchPoll(byId: pollId)
|
|
let userId = try await getCurrentUserRecordID()
|
|
guard poll.ownerId == userId else {
|
|
throw PollError.notPollOwner
|
|
}
|
|
|
|
let recordID = CKRecord.ID(recordName: pollId.uuidString)
|
|
|
|
do {
|
|
// Delete all votes first
|
|
try await deleteVotes(forPollId: pollId)
|
|
// Then delete the poll
|
|
try await publicDatabase.deleteRecord(withID: recordID)
|
|
} catch let error as CKError {
|
|
throw mapCloudKitError(error)
|
|
} catch let error as PollError {
|
|
throw error
|
|
} catch {
|
|
throw PollError.unknown(error)
|
|
}
|
|
}
|
|
|
|
// MARK: - Voting
|
|
|
|
func submitVote(_ vote: PollVote) async throws -> PollVote {
|
|
// Guard against concurrent submissions for the same poll+voter
|
|
let voteKey = "\(vote.pollId.uuidString)_\(vote.voterId)"
|
|
guard !votesInProgress.contains(voteKey) else {
|
|
throw PollError.alreadyVoted
|
|
}
|
|
votesInProgress.insert(voteKey)
|
|
defer { votesInProgress.remove(voteKey) }
|
|
|
|
// Check if user already voted
|
|
let existingVote = try await fetchMyVote(forPollId: vote.pollId)
|
|
if existingVote != nil {
|
|
throw PollError.alreadyVoted
|
|
}
|
|
|
|
let ckVote = CKPollVote(vote: vote)
|
|
|
|
do {
|
|
try await publicDatabase.save(ckVote.record)
|
|
return vote
|
|
} catch let error as CKError {
|
|
throw mapCloudKitError(error)
|
|
} catch {
|
|
throw PollError.unknown(error)
|
|
}
|
|
}
|
|
|
|
func updateVote(_ vote: PollVote) async throws -> PollVote {
|
|
let userId = try await getCurrentUserRecordID()
|
|
guard vote.voterId == userId else {
|
|
throw PollError.notVoteOwner
|
|
}
|
|
|
|
// Fetch the existing record to get the server's changeTag
|
|
let recordID = CKRecord.ID(recordName: vote.id.uuidString)
|
|
let existingRecord: CKRecord
|
|
do {
|
|
existingRecord = try await publicDatabase.record(for: recordID)
|
|
} catch let error as CKError {
|
|
throw mapCloudKitError(error)
|
|
} catch {
|
|
throw PollError.unknown(error)
|
|
}
|
|
|
|
guard let existingVoterId = existingRecord[CKPollVote.voterIdKey] as? String else {
|
|
throw PollError.encodingError
|
|
}
|
|
guard existingVoterId == userId else {
|
|
throw PollError.notVoteOwner
|
|
}
|
|
|
|
// Update the fields on the fetched record
|
|
let now = Date()
|
|
existingRecord[CKPollVote.rankingsKey] = vote.rankings
|
|
existingRecord[CKPollVote.modifiedAtKey] = now
|
|
|
|
do {
|
|
try await publicDatabase.save(existingRecord)
|
|
var updatedVote = vote
|
|
updatedVote.modifiedAt = now
|
|
return updatedVote
|
|
} catch let error as CKError {
|
|
throw mapCloudKitError(error)
|
|
} catch {
|
|
throw PollError.unknown(error)
|
|
}
|
|
}
|
|
|
|
func fetchVotes(forPollId pollId: UUID) async throws -> [PollVote] {
|
|
let predicate = NSPredicate(format: "%K == %@", CKPollVote.pollIdKey, pollId.uuidString)
|
|
let query = CKQuery(recordType: CKRecordType.pollVote, predicate: predicate)
|
|
|
|
do {
|
|
let (results, _) = try await publicDatabase.records(matching: query)
|
|
|
|
return results.compactMap { result in
|
|
guard case .success(let record) = result.1 else { return nil }
|
|
return CKPollVote(record: record).toVote()
|
|
}
|
|
} catch let error as CKError {
|
|
throw mapCloudKitError(error)
|
|
} catch {
|
|
throw PollError.unknown(error)
|
|
}
|
|
}
|
|
|
|
func fetchMyVote(forPollId pollId: UUID) async throws -> PollVote? {
|
|
let userId = try await getCurrentUserRecordID()
|
|
let predicate = NSPredicate(
|
|
format: "%K == %@ AND %K == %@",
|
|
CKPollVote.pollIdKey, pollId.uuidString,
|
|
CKPollVote.voterIdKey, userId
|
|
)
|
|
let query = CKQuery(recordType: CKRecordType.pollVote, predicate: predicate)
|
|
|
|
do {
|
|
let (results, _) = try await publicDatabase.records(matching: query)
|
|
|
|
guard let result = results.first,
|
|
case .success(let record) = result.1
|
|
else {
|
|
return nil
|
|
}
|
|
|
|
return CKPollVote(record: record).toVote()
|
|
} catch let error as CKError {
|
|
throw mapCloudKitError(error)
|
|
} catch {
|
|
throw PollError.unknown(error)
|
|
}
|
|
}
|
|
|
|
private func deleteVotes(forPollId pollId: UUID) async throws {
|
|
let votes = try await fetchVotes(forPollId: pollId)
|
|
let recordIDs = votes.map { CKRecord.ID(recordName: $0.id.uuidString) }
|
|
|
|
if !recordIDs.isEmpty {
|
|
let result = try await publicDatabase.modifyRecords(saving: [], deleting: recordIDs)
|
|
// Ignore individual delete errors - votes may already be deleted
|
|
_ = result
|
|
}
|
|
}
|
|
|
|
// MARK: - Subscriptions
|
|
|
|
func subscribeToVoteUpdates(forPollId pollId: UUID) async throws {
|
|
let subscriptionId = "poll-votes-\(pollId.uuidString)"
|
|
let predicate = NSPredicate(format: "%K == %@", CKPollVote.pollIdKey, pollId.uuidString)
|
|
let subscription = CKQuerySubscription(
|
|
recordType: CKRecordType.pollVote,
|
|
predicate: predicate,
|
|
subscriptionID: subscriptionId,
|
|
options: [.firesOnRecordCreation, .firesOnRecordUpdate, .firesOnRecordDeletion]
|
|
)
|
|
|
|
let notification = CKSubscription.NotificationInfo()
|
|
notification.shouldSendContentAvailable = true
|
|
subscription.notificationInfo = notification
|
|
|
|
do {
|
|
try await publicDatabase.save(subscription)
|
|
pollSubscriptionIDs.insert(subscription.subscriptionID)
|
|
} catch let error as CKError {
|
|
// Subscription already exists is OK
|
|
if error.code == .serverRejectedRequest {
|
|
pollSubscriptionIDs.insert(subscriptionId)
|
|
} else {
|
|
throw mapCloudKitError(error)
|
|
}
|
|
} catch {
|
|
throw PollError.unknown(error)
|
|
}
|
|
}
|
|
|
|
func unsubscribeFromVoteUpdates(forPollId pollId: UUID? = nil) async throws {
|
|
let subscriptionIds: [CKSubscription.ID]
|
|
if let pollId {
|
|
subscriptionIds = ["poll-votes-\(pollId.uuidString)"]
|
|
} else {
|
|
subscriptionIds = Array(pollSubscriptionIDs)
|
|
}
|
|
|
|
guard !subscriptionIds.isEmpty else { return }
|
|
|
|
for subscriptionID in subscriptionIds {
|
|
do {
|
|
try await publicDatabase.deleteSubscription(withID: subscriptionID)
|
|
} catch {
|
|
// Ignore errors - subscription may not exist
|
|
}
|
|
pollSubscriptionIDs.remove(subscriptionID)
|
|
}
|
|
}
|
|
|
|
// MARK: - Results
|
|
|
|
func fetchPollResults(forPollId pollId: UUID) async throws -> PollResults {
|
|
async let pollTask = fetchPoll(byId: pollId)
|
|
async let votesTask = fetchVotes(forPollId: pollId)
|
|
|
|
let (poll, votes) = try await (pollTask, votesTask)
|
|
|
|
return PollResults(poll: poll, votes: votes)
|
|
}
|
|
|
|
// MARK: - Error Mapping
|
|
|
|
private func mapCloudKitError(_ error: CKError) -> PollError {
|
|
switch error.code {
|
|
case .notAuthenticated:
|
|
return .notSignedIn
|
|
case .networkUnavailable, .networkFailure:
|
|
return .networkUnavailable
|
|
case .unknownItem:
|
|
return .pollNotFound
|
|
default:
|
|
return .unknown(error)
|
|
}
|
|
}
|
|
}
|