BusinessCard/BusinessCard/Services/SharedCardCloudKitService.swift

120 lines
4.0 KiB
Swift

import CloudKit
import Foundation
/// Service for uploading and managing shared cards in CloudKit public database.
struct SharedCardCloudKitService: SharedCardProviding {
private let container: CKContainer
private let database: CKDatabase
private let ttlDays: Int
init(
containerID: String = AppIdentifiers.cloudKitContainerIdentifier,
ttlDays: Int = 7
) {
self.container = CKContainer(identifier: containerID)
self.database = container.publicCloudDatabase
self.ttlDays = ttlDays
}
@MainActor
func uploadSharedCard(_ card: BusinessCard) async throws -> SharedCardUploadResult {
let vCardData = card.vCardFilePayload
let expiresAt = Date.now.addingTimeInterval(TimeInterval(ttlDays * 24 * 60 * 60))
let recordID = CKRecord.ID(recordName: UUID().uuidString)
var photoAssetFileURL: URL?
let sharedCard = SharedCardRecord(
recordID: recordID,
displayName: card.vCardName.isEmpty ? card.fullName : card.vCardName,
role: card.role,
company: card.company,
vCardData: vCardData,
expiresAt: expiresAt
)
do {
photoAssetFileURL = try makePhotoAssetFile(from: card.photoData)
} catch {
throw SharedCardError.uploadFailed(error)
}
defer {
if let photoAssetFileURL {
try? FileManager.default.removeItem(at: photoAssetFileURL)
}
}
let record = sharedCard.toCKRecord(photoAssetFileURL: photoAssetFileURL)
do {
_ = try await database.save(record)
} catch {
throw SharedCardError.uploadFailed(error)
}
guard let appClipURL = AppIdentifiers.appClipURL(recordName: recordID.recordName) else {
throw SharedCardError.invalidURL
}
return SharedCardUploadResult(
recordName: recordID.recordName,
appClipURL: appClipURL,
expiresAt: expiresAt
)
}
func cleanupExpiredCards() async {
let predicate = NSPredicate(format: "expiresAt < %@", Date.now as NSDate)
let query = CKQuery(recordType: SharedCardRecord.recordType, predicate: predicate)
do {
let (results, _) = try await database.records(matching: query)
let recordIDs = results.compactMap { result -> CKRecord.ID? in
guard case .success = result.1 else { return nil }
return result.0
}
guard !recordIDs.isEmpty else { return }
let operation = CKModifyRecordsOperation(recordsToSave: nil, recordIDsToDelete: recordIDs)
operation.qualityOfService = .utility
database.add(operation)
} catch {
// Cleanup is best-effort; log but don't throw
}
}
private func makePhotoAssetFile(from photoData: Data?) throws -> URL? {
guard let photoData, !photoData.isEmpty else { return nil }
let fileURL = URL.temporaryDirectory.appending(path: "\(UUID().uuidString).jpg")
try photoData.write(to: fileURL, options: .atomic)
return fileURL
}
}
// MARK: - Error Types
/// Errors that can occur during shared card operations.
enum SharedCardError: Error, LocalizedError {
case invalidURL
case uploadFailed(Error)
case fetchFailed(Error)
case recordNotFound
case recordExpired
var errorDescription: String? {
switch self {
case .invalidURL:
return String(localized: "Failed to create share URL")
case .uploadFailed(let error):
return String(localized: "Upload failed: \(error.localizedDescription)")
case .fetchFailed(let error):
return String(localized: "Could not load card: \(error.localizedDescription)")
case .recordNotFound:
return String(localized: "Card not found")
case .recordExpired:
return String(localized: "This card has expired")
}
}
}