Closer/iphone/Closer/Services/FirestoreService.swift

247 lines
8.6 KiB
Swift

import Foundation
import FirebaseFirestore
import FirebaseAuth
import FirebaseFunctions
// MARK: - Firestore Service
final class FirestoreService: @unchecked Sendable {
static let shared = FirestoreService()
let db = Firestore.firestore()
let functions = Functions.functions()
private init() {}
// MARK: - Collection References
func usersCollection() -> CollectionReference {
db.collection("users")
}
func userDocument(_ userId: String) -> DocumentReference {
usersCollection().document(userId)
}
func couplesCollection() -> CollectionReference {
db.collection("couples")
}
func coupleDocument(_ coupleId: String) -> DocumentReference {
couplesCollection().document(coupleId)
}
// TODO(iOS-E2EE): The iOS MVP skips E2EE, so any couple created directly
// from iOS must be written with encryptionVersion = 0 (PLAINTEXT).
// Cross-platform couples must be created via acceptInviteCallable (Android
// path) which writes encryptionVersion = 2. Do NOT create a mixed v0/v2
// couple from iOS until iOS implements Tink-compatible E2EE parity.
// See Android: app/src/main/java/app/closer/crypto/EncryptionVersion.kt
func invitesCollection() -> CollectionReference {
db.collection("invites")
}
func inviteDocument(_ code: String) -> DocumentReference {
invitesCollection().document(code)
}
// MARK: - Subcollections
func dailyQuestionRef(coupleId: String, date: String) -> DocumentReference {
coupleDocument(coupleId)
.collection("daily_question")
.document(date)
}
func dailyAnswerRef(coupleId: String, date: String, userId: String) -> DocumentReference {
dailyQuestionRef(coupleId: coupleId, date: date)
.collection("answers")
.document(userId)
}
func questionThreadsRef(coupleId: String) -> CollectionReference {
coupleDocument(coupleId).collection("question_threads")
}
func dateSwipesRef(coupleId: String) -> CollectionReference {
coupleDocument(coupleId).collection("date_swipes")
}
func dateMatchesRef(coupleId: String) -> CollectionReference {
coupleDocument(coupleId).collection("date_matches")
}
func bucketListRef(coupleId: String) -> CollectionReference {
coupleDocument(coupleId).collection("bucket_list")
}
func capsulesRef(coupleId: String) -> CollectionReference {
coupleDocument(coupleId).collection("capsules")
}
func sessionsRef(coupleId: String) -> CollectionReference {
coupleDocument(coupleId).collection("sessions")
}
func entitlementDocument(_ userId: String) -> DocumentReference {
userDocument(userId)
.collection("entitlements")
.document("premium")
}
func fcmTokensRef(_ userId: String) -> CollectionReference {
userDocument(userId).collection("fcmTokens")
}
// MARK: - Helpers
func userId() throws -> String {
guard let uid = Auth.auth().currentUser?.uid else {
throw FirestoreError.notAuthenticated
}
return uid
}
func setDocument<T: Encodable>(_ value: T, at document: DocumentReference, merge: Bool = true) async throws {
if merge {
try await document.setData(value.asDictionary(), merge: true)
} else {
try await document.setData(value.asDictionary())
}
}
func getDocument<T: Decodable>(at document: DocumentReference) async throws -> T? {
let snapshot = try await document.getDocument()
guard snapshot.exists else { return nil }
return try snapshot.data(as: T.self)
}
func getDocuments<T: Decodable>(in collection: CollectionReference) async throws -> [T] {
let snapshot = try await collection.getDocuments()
return try snapshot.documents.compactMap { try $0.data(as: T.self) }
}
func queryDocuments<T: Decodable>(
in collection: CollectionReference,
where field: String,
isEqualTo value: Any
) async throws -> [T] {
let snapshot = try await collection.whereField(field, isEqualTo: value).getDocuments()
return try snapshot.documents.compactMap { try $0.data(as: T.self) }
}
}
// MARK: - Callable Functions
extension FirestoreService {
func acceptInviteCallable(code: String, recoveryPhrase: String? = nil) async throws -> String {
var data: [String: Any] = ["code": code]
if let phrase = recoveryPhrase {
data["recoveryPhrase"] = phrase
}
let result = try await functions.httpsCallable("acceptInviteCallable").call(data)
guard let coupleId = (result.data as? [String: Any])?["coupleId"] as? String else {
throw FirestoreError.invalidResponse
}
return coupleId
}
func createInviteCallable(inviteCode: String? = nil) async throws -> (code: String, expiresAt: Date) {
var data: [String: Any] = [:]
// iOS MVP skips E2EE; the server writes null for the wrapped couple key.
// When iOS E2EE parity lands, pass wrappedCoupleKey, kdfSalt, kdfParams, recoveryPhrase here.
if let code = inviteCode {
data["preferredCode"] = code
}
let result = try await functions.httpsCallable("createInviteCallable").call(data)
guard let payload = result.data as? [String: Any],
let code = payload["code"] as? String,
let expiresAtTimestamp = payload["expiresAt"] as? Timestamp else {
throw FirestoreError.invalidResponse
}
return (code, expiresAtTimestamp.dateValue())
}
func leaveCoupleCallable() async throws {
let result = try await functions.httpsCallable("leaveCoupleCallable").call()
guard let success = (result.data as? [String: Any])?["success"] as? Bool, success else {
throw FirestoreError.invalidResponse
}
}
func syncEntitlementCallable() async throws -> Entitlement {
let result = try await functions.httpsCallable("syncEntitlement").call()
guard let data = result.data as? [String: Any] else {
throw FirestoreError.invalidResponse
}
return Entitlement(
id: UUID().uuidString,
userId: try userId(),
source: "sync",
productId: "closer_premium",
isActive: data["premium"] as? Bool ?? false,
expiresAt: (data["expiresAt"] as? Timestamp)?.dateValue(),
updatedAt: Date()
)
}
func sendGentleReminderCallable() async throws {
try await functions.httpsCallable("sendGentleReminderCallable").call()
}
func deleteUserCallable() async throws {
let result = try await functions.httpsCallable("deleteUserCallable").call()
guard let success = (result.data as? [String: Any])?["success"] as? Bool, success else {
throw FirestoreError.invalidResponse
}
}
func updateUserCallable(displayName: String, bio: String?) async throws {
var data: [String: Any] = ["displayName": displayName]
if let bio = bio {
data["bio"] = bio
}
let result = try await functions.httpsCallable("updateUserCallable").call(data)
guard let success = (result.data as? [String: Any])?["success"] as? Bool, success else {
throw FirestoreError.invalidResponse
}
}
func exportDataCallable() async throws {
let result = try await functions.httpsCallable("exportDataCallable").call()
guard let success = (result.data as? [String: Any])?["success"] as? Bool, success else {
throw FirestoreError.invalidResponse
}
}
}
// MARK: - Errors
enum FirestoreError: LocalizedError {
case notAuthenticated
case invalidResponse
case documentNotFound
case permissionDenied
var errorDescription: String? {
switch self {
case .notAuthenticated: return "User is not signed in."
case .invalidResponse: return "Invalid server response."
case .documentNotFound: return "Document not found."
case .permissionDenied: return "Permission denied."
}
}
}
// MARK: - Encodable Helpers
extension Encodable {
func asDictionary() throws -> [String: Any] {
let data = try JSONEncoder().encode(self)
guard let dict = try JSONSerialization.jsonObject(with: data) as? [String: Any] else {
throw EncodingError.invalidValue(self, .init(codingPath: [], debugDescription: "Not a dictionary"))
}
return dict
}
}