Initial commit: nightly iOS app + Supabase backend
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>
This commit is contained in:
@@ -0,0 +1,379 @@
|
||||
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
|
||||
}
|
||||
Reference in New Issue
Block a user