247 lines
8.6 KiB
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
|
|
}
|
|
} |