Files
denshooter 5bc81d5b3b 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>
2026-04-23 23:31:38 +02:00

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
}