5bc81d5b3b
iOS SwiftUI app with Supabase auth/realtime, Node.js backend, Docker/Supabase self-hosted infrastructure, and APNs scheduler. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
380 lines
12 KiB
Swift
380 lines
12 KiB
Swift
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
|
|
}
|