386 lines
12 KiB
Swift
386 lines
12 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 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)
|
|
}
|
|
}
|
|
}
|