import Foundation import Supabase // MARK: - Supabase Client (Singleton) let supabase = SupabaseClient( supabaseURL: Config.supabaseURL, supabaseKey: Config.supabaseAnonKey, options: SupabaseClientOptions( db: .init( encoder: { let e = JSONEncoder() e.keyEncodingStrategy = .convertToSnakeCase e.dateEncodingStrategy = .iso8601 return e }(), decoder: { let d = JSONDecoder() d.keyDecodingStrategy = .convertFromSnakeCase d.dateDecodingStrategy = .iso8601 return d }() ) ) ) // MARK: - Auth extension SupabaseClient { func signUp(email: String, password: String, username: String, displayName: String) async throws { try await self.auth.signUp( email: email, password: password, data: [ "username": .string(username.lowercased()), "display_name": .string(displayName) ] ) } func signIn(email: String, password: String) async throws { try await self.auth.signIn(email: email, password: password) } /// Login mit Username: holt zuerst die E-Mail, dann normaler Sign-In func signIn(username: String, password: String) async throws { let email: String? = try await self .rpc("get_email_by_username", params: ["p_username": username]) .execute() .value guard let email else { throw AuthError.usernameNotFound } try await self.auth.signIn(email: email, password: password) } func signOut() async throws { try await self.auth.signOut() } /// Account vollständig löschen (DSGVO — löscht alles über DB-Funktion) func deleteAccount() async throws { try await self.rpc("delete_my_account").execute() } var currentUserId: UUID? { try? self.auth.session.user.id } } // MARK: - Profil extension SupabaseClient { func getMyProfile() async throws -> Profile { guard let uid = currentUserId else { throw AuthError.notAuthenticated } return try await self .from("profiles") .select() .eq("id", value: uid) .single() .execute() .value } func getProfile(userId: UUID) async throws -> Profile { try await self .from("profiles") .select() .eq("id", value: userId) .single() .execute() .value } func updateProfile(displayName: String? = nil, bio: String? = nil) async throws { guard let uid = currentUserId else { throw AuthError.notAuthenticated } var update: [String: String] = [:] if let n = displayName { update["display_name"] = n } if let b = bio { update["bio"] = b } guard !update.isEmpty else { return } try await self.from("profiles").update(update).eq("id", value: uid).execute() } func savePushToken(_ token: String) async throws { guard let uid = currentUserId else { return } try await self.from("profiles") .update(["push_token": token]) .eq("id", value: uid) .execute() } func removePushToken() async throws { guard let uid = currentUserId else { return } try await self.from("profiles") .update(["push_token": nil as String?]) .eq("id", value: uid) .execute() } } // MARK: - Posts extension SupabaseClient { /// Feed: Posts der letzten 14h von gefollowten Usern + eigene func getFeed() async throws -> [Post] { guard let uid = currentUserId else { return [] } // Erst die gefolgten User-IDs holen // Supabase gibt Objekte zurück [{following_id:"uuid"}], kein [String] struct FollowRow: Decodable { let followingId: String } let followRows: [FollowRow] = try await self .from("follows") .select("following_id") .eq("follower_id", value: uid) .execute() .value let allIds = followRows.map(\.followingId) + [uid.uuidString] let rows: [FeedPostRow] = try await self .from("feed_posts") .select() .in("author_id", values: allIds) .order("created_at", ascending: false) .limit(150) .execute() .value // Eigene Resonances holen (RLS filtert, SDK gibt nur eigene zurück) let myResonances: [ResonanceRow] = (try? await self .from("resonances") .select("post_id") .eq("user_id", value: uid) .execute() .value) ?? [] let mySet = Set(myResonances.map(\.postId)) return rows.map { row in row.toPost(hasResonated: mySet.contains(row.id)) } } /// Persönliches Tagebuch: alle eigenen Posts, auch gelöschte (soft) func getDiary() async throws -> [Post] { guard let uid = currentUserId else { return [] } let rows: [FeedPostRow] = try await self .from("posts") .select("id, content, mood, is_anonymous, created_at, resonance_count:resonances(count)") .eq("user_id", value: uid) .is("deleted_at", value: nil) .order("created_at", ascending: false) .limit(365) .execute() .value return rows.map { $0.toPost(hasResonated: false) } } func getUserPosts(userId: UUID) async throws -> [Post] { let rows: [FeedPostRow] = try await self .from("feed_posts") .select() .eq("author_id", value: userId) .order("created_at", ascending: false) .limit(50) .execute() .value return rows.map { $0.toPost(hasResonated: false) } } func createPost(content: String, mood: Mood, isAnonymous: Bool) async throws { guard let uid = currentUserId else { throw AuthError.notAuthenticated } try await self.from("posts").insert([ "user_id": uid.uuidString, "content": content, "mood": mood.rawValue, "is_anonymous": isAnonymous ]).execute() } func softDeletePost(id: String) async throws { try await self.from("posts") .update(["deleted_at": ISO8601DateFormatter().string(from: Date())]) .eq("id", value: id) .execute() } } // MARK: - Resonances extension SupabaseClient { func toggleResonance(postId: String, currentlyActive: Bool) async throws { guard let uid = currentUserId else { throw AuthError.notAuthenticated } if currentlyActive { try await self.from("resonances") .delete() .eq("post_id", value: postId) .eq("user_id", value: uid) .execute() } else { try await self.from("resonances") .insert(["post_id": postId, "user_id": uid.uuidString]) .execute() } } } // MARK: - Follows extension SupabaseClient { func follow(userId: UUID) async throws { guard let uid = currentUserId else { throw AuthError.notAuthenticated } try await self.from("follows") .insert(["follower_id": uid.uuidString, "following_id": userId.uuidString]) .execute() } func unfollow(userId: UUID) async throws { guard let uid = currentUserId else { throw AuthError.notAuthenticated } try await self.from("follows") .delete() .eq("follower_id", value: uid) .eq("following_id", value: userId) .execute() } func getStreak(userId: UUID) async throws -> Int { // Nächte mit Posts — berechnet in SQL let rows: [[String: Int]] = (try? await self .rpc("get_streak", params: ["p_user_id": userId.uuidString]) .execute() .value) ?? [] return rows.first?["streak"] ?? 0 } } // MARK: - Reports extension SupabaseClient { func reportPost(postId: String, reason: String, details: String?) async throws { guard let uid = currentUserId else { throw AuthError.notAuthenticated } try await self.from("reports").insert([ "post_id": postId, "reporter_id": uid.uuidString, "reason": reason, "details": details as Any ]).execute() } } // MARK: - Whispers extension SupabaseClient { func sendWhisper(toUserId: UUID, content: String, postId: String?) async throws { guard let uid = currentUserId else { throw AuthError.notAuthenticated } try await self.from("whispers").insert([ "from_user_id": uid.uuidString, "to_user_id": toUserId.uuidString, "content": content, "post_id": postId as Any ]).execute() } func getMyWhispers() async throws -> [Whisper] { guard let uid = currentUserId else { return [] } return try await self .from("whispers") .select("*, from_profile:profiles!from_user_id(username, display_name, avatar_url)") .eq("to_user_id", value: uid) .order("created_at", ascending: false) .limit(50) .execute() .value } func markWhisperRead(id: UUID) async throws { try await self.from("whispers") .update(["read_at": ISO8601DateFormatter().string(from: Date())]) .eq("id", value: id) .execute() } } // MARK: - Errors enum AuthError: LocalizedError { case notAuthenticated case usernameNotFound var errorDescription: String? { switch self { case .notAuthenticated: return "Nicht angemeldet" case .usernameNotFound: return "Benutzername nicht gefunden" } } } // MARK: - Row types (Supabase responses) struct FeedPostRow: Decodable { let id: String let content: String let mood: String? let isAnonymous: Bool let createdAt: Date let resonanceCount: Int // Autor (nil bei anonymen Posts die nicht von mir sind) let authorId: String? let authorUsername: String? let authorDisplayName: String? let authorAvatarUrl: String? func toPost(hasResonated: Bool) -> Post { let author: User? = authorId.map { User( id: $0, username: authorUsername ?? "?", displayName: authorDisplayName ?? "?", bio: nil, avatarURL: authorAvatarUrl.flatMap(URL.init), followerCount: 0, followingCount: 0, postCount: 0, isFollowing: false ) } return Post( id: id, author: author ?? User.anonymousPlaceholder, content: content, mood: mood.flatMap(Mood.init(rawValue:)), createdAt: createdAt, resonanceCount: resonanceCount, hasResonated: hasResonated, commentCount: 0, isAnonymous: isAnonymous, nightOf: createdAt ) } } struct ResonanceRow: Decodable { let postId: String } struct Profile: Decodable { let id: UUID let username: String let displayName: String let bio: String? let avatarUrl: String? let isPro: Bool let isAdmin: Bool let createdAt: Date } struct Whisper: Identifiable, Decodable { let id: UUID let fromUserId: UUID let content: String let readAt: Date? let createdAt: Date }