// // 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 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 .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 pollSubscriptionID: CKSubscription.ID? private init() { // Respect target entitlements so Debug and production stay isolated. self.container = CKContainer.default() 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 { // 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.odg == userId else { throw PollError.notPollOwner // Using this error for now - voter can only update own vote } // 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) } // 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 predicate = NSPredicate(format: "%K == %@", CKPollVote.pollIdKey, pollId.uuidString) let subscription = CKQuerySubscription( recordType: CKRecordType.pollVote, predicate: predicate, subscriptionID: "poll-votes-\(pollId.uuidString)", options: [.firesOnRecordCreation, .firesOnRecordUpdate, .firesOnRecordDeletion] ) let notification = CKSubscription.NotificationInfo() notification.shouldSendContentAvailable = true subscription.notificationInfo = notification do { try await publicDatabase.save(subscription) pollSubscriptionID = subscription.subscriptionID } catch let error as CKError { // Subscription already exists is OK if error.code != .serverRejectedRequest { throw mapCloudKitError(error) } } catch { throw PollError.unknown(error) } } func unsubscribeFromVoteUpdates() async throws { guard let subscriptionID = pollSubscriptionID else { return } do { try await publicDatabase.deleteSubscription(withID: subscriptionID) pollSubscriptionID = nil } catch { // Ignore errors - subscription may not exist } } // 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) } } }