diff --git a/go-backend/directus-docker-compose.yml b/go-backend/directus-docker-compose.yml deleted file mode 100644 index 474c9e4..0000000 --- a/go-backend/directus-docker-compose.yml +++ /dev/null @@ -1,26 +0,0 @@ -version: '3' -services: - directus: - image: directus/directus:latest - ports: - - 8055:8055 - volumes: - - ./uploads:/directus/uploads - - ./extensions:/directus/extensions - network_mode: "host" - environment: - KEY: "sj_auth_key_replace_me_securely" - SECRET: "sj_auth_secret_replace_me_securely" - - # Connect directly to the main 'postgres' database (Sojorn's DB) - DB_CLIENT: "pg" - DB_HOST: "127.0.0.1" - DB_PORT: "5432" - DB_DATABASE: "postgres" - DB_USER: "postgres" - DB_PASSWORD: "${DIRECTUS_DB_PASSWORD}" - - ADMIN_EMAIL: "admin@sojorn.com" - ADMIN_PASSWORD: "${DIRECTUS_ADMIN_PASSWORD}" - - PUBLIC_URL: "https://sojorn.net/cms" diff --git a/sojorn_app/lib/services/api_service.dart.backup b/sojorn_app/lib/services/api_service.dart.backup deleted file mode 100644 index a669dc4..0000000 --- a/sojorn_app/lib/services/api_service.dart.backup +++ /dev/null @@ -1,1133 +0,0 @@ -import 'dart:async'; -import 'dart:convert'; -import 'package:http/http.dart' as http; -import 'package:supabase_flutter/supabase_flutter.dart'; -import '../config/supabase_config.dart'; -import '../models/category.dart'; -import '../models/profile.dart'; -import '../models/profile_privacy_settings.dart'; -import '../models/post.dart'; -import '../models/comment.dart'; -import '../models/tone_analysis.dart' hide ToneAnalysis; -import '../models/search_results.dart'; -import '../models/notification.dart'; -import '../models/beacon.dart'; - -/// API service for calling sojorn Edge Functions -class ApiService { - final SupabaseClient _supabase; - - ApiService(this._supabase); - - /// Helper to make Edge Function calls - /// Trust the Supabase SDK to handle auth, tokens, and headers automatically - Future> _callFunction( - String functionName, { - HttpMethod method = HttpMethod.post, - Map? queryParams, - Object? body, - }) async { - final response = await _supabase.functions.invoke( - functionName, - method: method, - queryParameters: queryParams, - body: body, - ); - - // Parse response data - final data = response.data; - if (data is Map) { - return data; - } - if (data is Map) { - return Map.from(data); - } - if (data is String && data.isNotEmpty) { - final parsed = jsonDecode(data); - if (parsed is Map) { - return parsed; - } - } - throw Exception('Unexpected response from $functionName'); - } - - - List> _normalizeListResponse(dynamic response) { - try { - // Supabase Flutter SDK returns data directly, not wrapped in PostgrestResponse - if (response == null) return []; - - // Handle different response types - List rawData; - if (response is List) { - rawData = response; - } else if (response is Map) { - // If it's a single object, wrap it in a list - return [response]; - } else { - return []; - } - - return rawData - .where((item) => item is Map) - .map((item) => Map.from(item as Map)) - .toList(); - } catch (e) { - print('_normalizeListResponse error: $e'); - return []; - } - } - - static const String _postSelect = ''' - id, - body, - author_id, - category_id, - tone_label, - cis_score, - status, - created_at, - edited_at, - deleted_at, - is_edited, - allow_chain, - chain_parent_id, - image_url, - chain_parent:posts ( - id, - body, - created_at, - author:profiles!posts_author_id_fkey ( - id, - handle, - display_name - ) - ), - metrics:post_metrics!post_metrics_post_id_fkey ( - like_count, - save_count, - view_count - ), - author:profiles!posts_author_id_fkey ( - id, - handle, - display_name, - trust_state ( - user_id, - harmony_score, - tier, - posts_today - ) - ) - '''; - - // ============================================================================ - // CATEGORIES - // ============================================================================ - - /// Fetch all categories - Future> getCategories() async { - final response = await _supabase - .from('categories') - .select() - .order('name', ascending: true); - return (response as List).map((json) => Category.fromJson(json)).toList(); - } - - /// Fetch categories the user has enabled - Future> getEnabledCategories() async { - final categories = await getCategories(); - final enabledIds = await _getEnabledCategoryIds(); - - if (enabledIds.isEmpty) { - return categories.where((category) => !category.defaultOff).toList(); - } - - return categories - .where((category) => enabledIds.contains(category.id)) - .toList(); - } - - /// Check if the current user has a profile - Future hasProfile() async { - final user = _supabase.auth.currentUser; - if (user == null) { - return false; - } - - try { - final response = await _supabase - .from('profiles') - .select('id') - .eq('id', user.id) - .limit(1); - - return (response as List).isNotEmpty; - } catch (e) { - // If we get an error (like RLS blocking or JWT issues), assume no profile - // This allows the signup flow to work even if RLS is strict - print('hasProfile error (treating as false): $e'); - return false; - } - } - - /// Check if the user has completed category selection - Future hasCategorySelection() async { - try { - final enabledIds = await _getEnabledCategoryIds(); - return enabledIds.isNotEmpty; - } catch (e) { - // If we get an error, assume no category selection - print('hasCategorySelection error (treating as false): $e'); - return false; - } - } - - /// Persist user category settings for all categories - Future setUserCategorySettings({ - required List categories, - required Set enabledCategoryIds, - }) async { - final user = _supabase.auth.currentUser; - if (user == null) { - throw Exception('Not authenticated'); - } - - final payload = categories - .map( - (category) => { - 'user_id': user.id, - 'category_id': category.id, - 'enabled': enabledCategoryIds.contains(category.id), - }, - ) - .toList(); - - await _supabase.from('user_category_settings').upsert(payload); - } - - Future> _getEnabledCategoryIds() async { - try { - final response = await _supabase - .from('user_category_settings') - .select('category_id') - .eq('enabled', true); - - return (response as List) - .map((row) => row['category_id'] as String) - .toSet(); - } catch (e) { - // If query fails, return empty set - print('_getEnabledCategoryIds error: $e'); - return {}; - } - } - - // ============================================================================ - // USER MANAGEMENT - // ============================================================================ - - /// Create profile (after Supabase Auth signup) - Future createProfile({ - required String handle, - required String displayName, - String? bio, - }) async { - final response = await _callFunction( - 'signup', - method: 'POST', - body: { - 'handle': handle, - 'display_name': displayName, - if (bio != null) 'bio': bio, - }, - ); - - if (_isSuccessStatus(response)) { - final data = jsonDecode(response.body); - return Profile.fromJson(data['profile']); - } else { - throw Exception(_extractError(response, 'Failed to create profile')); - } - } - - /// Get profile by handle - Future> getProfile({String? handle}) async { - final response = await _callFunction( - 'profile', - queryParams: handle != null ? {'handle': handle} : null, - ); - - if (response.statusCode == 200) { - final data = jsonDecode(response.body); - return { - 'profile': Profile.fromJson(data['profile']), - 'stats': ProfileStats.fromJson(data['stats']), - 'is_following': data['is_following'] as bool? ?? false, - }; - } else { - throw Exception(_extractError(response, 'Failed to get profile')); - } - } - - /// Fetch posts authored by the given profile - Future> getProfilePosts({ - required String authorId, - int limit = 20, - int offset = 0, - }) async { - final safeOffset = offset < 0 ? 0 : offset; - final safeLimit = limit.clamp(1, 100).toInt(); - final rangeEnd = safeOffset + safeLimit - 1; - - try { - final response = await _supabase - .from('posts') - .select(''' - id, - body, - author_id, - category_id, - tone_label, - cis_score, - status, - created_at, - edited_at, - deleted_at, - allow_chain, - chain_parent_id, - image_url, - chain_parent:posts ( - id, - body, - created_at, - author:profiles!posts_author_id_fkey ( - id, - handle, - display_name - ) - ), - metrics:post_metrics!post_metrics_post_id_fkey ( - like_count, - save_count, - view_count - ), - author:profiles!posts_author_id_fkey ( - id, - handle, - display_name, - trust_state ( - user_id, - harmony_score, - tier, - posts_today - ) - ) - ''') - .eq('author_id', authorId) - .order('created_at', ascending: false) - .range(safeOffset, rangeEnd); - - final rows = _normalizeListResponse(response); - return rows.map(Post.fromJson).toList(); - } catch (error) { - final message = error is PostgrestException - ? error.message - : 'Failed to load profile posts'; - print('Error fetching profile posts: $error'); - throw Exception('Failed to load profile posts: $message'); - } - } - - /// Fetch a single post by id - Future getPostById(String postId) async { - try { - final response = await _supabase - .from('posts') - .select(_postSelect) - .eq('id', postId) - .single(); - - // Handle both Map and List response formats - final Map data; - if (response is Map) { - data = response; - } else if (response is List && response.isNotEmpty) { - data = Map.from(response[0] as Map); - } else { - data = {}; - } - - return Post.fromJson(data); - } catch (error) { - final message = error is PostgrestException - ? error.message - : 'Failed to load post'; - throw Exception('Failed to load post: $message'); - } - } - - /// Update own profile - Future updateProfile({ - String? handle, - String? displayName, - String? bio, - String? location, - String? website, - List? interests, - String? avatarUrl, - String? coverUrl, - }) async { - final response = await _callFunction( - 'profile', - method: 'PATCH', - body: { - if (handle != null) 'handle': handle, - if (displayName != null) 'display_name': displayName, - if (bio != null) 'bio': bio, - if (location != null) 'location': location, - if (website != null) 'website': website, - if (interests != null) 'interests': interests, - if (avatarUrl != null) 'avatar_url': avatarUrl, - if (coverUrl != null) 'cover_url': coverUrl, - }, - ); - - if (response.statusCode == 200) { - final data = jsonDecode(response.body); - return Profile.fromJson(data['profile']); - } else { - throw Exception(_extractError(response, 'Failed to update profile')); - } - } - - /// Fetch or initialize privacy settings for the current user - Future getPrivacySettings() async { - final userId = _supabase.auth.currentUser?.id; - if (userId == null) { - throw Exception('Not authenticated'); - } - - final response = await _supabase - .from('profile_privacy_settings') - .select() - .eq('user_id', userId) - .limit(1); - - final rows = _normalizeListResponse(response); - if (rows.isNotEmpty) { - return ProfilePrivacySettings.fromJson(rows.first); - } - - final now = DateTime.now().toIso8601String(); - final defaults = ProfilePrivacySettings.defaults(userId); - final inserted = await _supabase - .from('profile_privacy_settings') - .insert({ - ...defaults.toJson(), - 'created_at': now, - 'updated_at': now, - }) - .select() - .single(); - - return ProfilePrivacySettings.fromJson(inserted); - } - - /// Update privacy settings for the current user - Future updatePrivacySettings( - ProfilePrivacySettings settings, - ) async { - final userId = _supabase.auth.currentUser?.id; - if (userId == null) { - throw Exception('Not authenticated'); - } - - final payload = settings.toJson() - ..['user_id'] = userId - ..['updated_at'] = DateTime.now().toIso8601String(); - - final response = await _supabase - .from('profile_privacy_settings') - .upsert(payload) - .select() - .single(); - - return ProfilePrivacySettings.fromJson(response); - } - - /// Fetch posts the user has appreciated - Future> getAppreciatedPosts({ - required String userId, - int limit = 20, - int offset = 0, - }) async { - final safeLimit = limit.clamp(1, 100).toInt(); - final safeOffset = offset < 0 ? 0 : offset; - final rangeEnd = safeOffset + safeLimit - 1; - - final response = await _supabase - .from('post_likes') - .select('post:posts ($_postSelect)') - .eq('user_id', userId) - .order('created_at', ascending: false) - .range(safeOffset, rangeEnd); - - // Handle embedded response format - final List rawData = response as List; - return rawData - .map((row) => row['post'] as Map?) - .where((post) => post != null) - .map((post) { - final payload = Map.from(post!); - payload['user_liked'] = true; - return Post.fromJson(payload); - }) - .toList(); - } - - /// Fetch posts the user has saved - Future> getSavedPosts({ - required String userId, - int limit = 20, - int offset = 0, - }) async { - final safeLimit = limit.clamp(1, 100).toInt(); - final safeOffset = offset < 0 ? 0 : offset; - final rangeEnd = safeOffset + safeLimit - 1; - - final response = await _supabase - .from('post_saves') - .select('post:posts ($_postSelect)') - .eq('user_id', userId) - .order('created_at', ascending: false) - .range(safeOffset, rangeEnd); - - // Handle embedded response format - final List rawData = response as List; - return rawData - .map((row) => row['post'] as Map?) - .where((post) => post != null) - .map((post) { - final payload = Map.from(post!); - payload['user_saved'] = true; - return Post.fromJson(payload); - }) - .toList(); - } - - /// Fetch posts by an author that have chain responses - Future> getChainedPostsForAuthor({ - required String authorId, - int limit = 20, - int offset = 0, - }) async { - final safeLimit = limit.clamp(1, 100).toInt(); - final safeOffset = offset < 0 ? 0 : offset; - final rangeEnd = safeOffset + safeLimit - 1; - - final response = await _supabase - .from('posts') - .select('$_postSelect, chain_children:posts!inner(id)') - .eq('author_id', authorId) - .order('created_at', ascending: false) - .range(safeOffset, rangeEnd); - - final rows = _normalizeListResponse(response); - return rows.map(Post.fromJson).toList(); - } - - /// Follow a user - Future followUser(String userId) async { - final response = await _callFunction( - 'follow', - method: 'POST', - body: {'user_id': userId}, - ); - - if (response.statusCode != 200) { - throw Exception( - jsonDecode(response.body)['error'] ?? 'Failed to follow user', - ); - } - } - - /// Unfollow a user - Future unfollowUser(String userId) async { - final response = await _callFunction( - 'follow', - method: 'DELETE', - body: {'user_id': userId}, - ); - - if (response.statusCode != 200) { - throw Exception( - jsonDecode(response.body)['error'] ?? 'Failed to unfollow user', - ); - } - } - - /// Block a user - Future blockUser(String userId) async { - final response = await _callFunction( - 'block', - method: 'POST', - body: {'user_id': userId}, - ); - - if (response.statusCode != 200) { - throw Exception( - jsonDecode(response.body)['error'] ?? 'Failed to block user', - ); - } - } - - /// Unblock a user - Future unblockUser(String userId) async { - final response = await _callFunction( - 'block', - method: 'DELETE', - body: {'user_id': userId}, - ); - - if (response.statusCode != 200) { - throw Exception( - jsonDecode(response.body)['error'] ?? 'Failed to unblock user', - ); - } - } - - // ============================================================================ - // CONTENT PUBLISHING - // ============================================================================ - - /// Publish a post - /// - /// Supports both regular posts and beacons through unified API. - /// For beacons, set [isBeacon] to true and provide [beaconType], [lat], and [long]. - Future publishPost({ - required String categoryId, - required String body, - String bodyFormat = 'plain', - bool allowChain = true, - String? chainParentId, - String? imageUrl, - // Beacon-specific parameters - bool isBeacon = false, - BeaconType? beaconType, - double? lat, - double? long, - }) async { - final response = await _callFunction( - 'publish-post', - method: 'POST', - body: { - 'category_id': categoryId, - 'body': body, - 'body_format': bodyFormat, - 'allow_chain': allowChain, - if (chainParentId != null) 'chain_parent_id': chainParentId, - if (imageUrl != null) 'image_url': imageUrl, - // Beacon fields - if (isBeacon) 'is_beacon': true, - if (beaconType != null) 'beacon_type': beaconType.value, - if (lat != null) 'beacon_lat': lat, - if (long != null) 'beacon_long': long, - }, - ); - - if (_isSuccessStatus(response)) { - final data = jsonDecode(response.body); - return Post.fromJson(data['post']); - } else { - throw Exception(_extractError(response, 'Failed to publish post')); - } - } - - /// Publish a comment (mutual-follow only) - Future publishComment({ - required String postId, - required String body, - }) async { - final response = await _callFunction( - 'publish-comment', - method: 'POST', - body: {'post_id': postId, 'body': body}, - ); - - if (_isSuccessStatus(response)) { - final data = jsonDecode(response.body); - return Comment.fromJson(data['comment']); - } else { - throw Exception(_extractError(response, 'Failed to publish comment')); - } - } - - // ============================================================================ - // TONE ANALYSIS - // ============================================================================ - - /// Analyze the tone of content using AI moderation - /// - /// Uses intent-based detection to allow authentic expression while - /// rejecting slurs and hostile attacks. - /// - /// [text] The text to analyze - /// [imageUrl] Optional image URL for multi-modal analysis - /// - /// Returns a [ToneCheckResult]. If the analysis fails (API error, - /// timeout, etc.), returns a default "neutral" result to allow the post. - Future checkTone( - String text, { - String? imageUrl, - }) async { - try { - final response = await _supabase.functions.invoke( - 'tone-check', - method: HttpMethod.post, - body: { - 'text': text, - if (imageUrl != null) 'imageUrl': imageUrl, - }, - ); - - final data = response.data; - if (data is Map) { - return ToneCheckResult.fromJson(data); - } - if (data is Map) { - return ToneCheckResult.fromJson(Map.from(data)); - } - if (data is String && data.isNotEmpty) { - final parsed = jsonDecode(data); - if (parsed is Map) { - return ToneCheckResult.fromJson(parsed); - } - } - - // Fallback to neutral if response format is invalid - return ToneCheckResult( - tone: ToneCategory.neutral, - cis: 0.8, - flags: [], - reason: 'Analysis complete', - ); - } catch (e) { - print('Tone analysis error: $e'); - // FALLBACK: Allow post with neutral rating when AI is unavailable - // This prevents blocking users when OpenAI has issues - return ToneCheckResult( - tone: ToneCategory.neutral, - cis: 0.8, - flags: [], - reason: 'Content approved (moderation temporarily unavailable)', - ); - } - } - - // ============================================================================ - // ENGAGEMENT - // ============================================================================ - - /// Appreciate a post - Future appreciatePost(String postId) async { - final response = await _callFunction( - 'appreciate', - method: 'POST', - body: {'post_id': postId}, - ); - - if (response.statusCode != 200) { - throw Exception(_extractError(response, 'Failed to appreciate post')); - } - } - - /// Remove appreciation - Future unappreciatePost(String postId) async { - final response = await _callFunction( - 'appreciate', - method: 'DELETE', - body: {'post_id': postId}, - ); - - if (response.statusCode != 200) { - throw Exception(_extractError(response, 'Failed to remove appreciation')); - } - } - - /// Save a post - Future savePost(String postId) async { - final response = await _callFunction( - 'save', - method: 'POST', - body: {'post_id': postId}, - ); - - if (response.statusCode != 200) { - throw Exception(_extractError(response, 'Failed to save post')); - } - } - - /// Unsave a post - Future unsavePost(String postId) async { - final response = await _callFunction( - 'save', - method: 'DELETE', - body: {'post_id': postId}, - ); - - if (response.statusCode != 200) { - throw Exception(_extractError(response, 'Failed to unsave post')); - } - } - - /// Report content or user - Future reportContent({ - required String targetType, - required String targetId, - required String reason, - }) async { - final response = await _callFunction( - 'report', - method: 'POST', - body: { - 'target_type': targetType, - 'target_id': targetId, - 'reason': reason, - }, - ); - - if (!_isSuccessStatus(response)) { - throw Exception(_extractError(response, 'Failed to submit report')); - } - } - - // ============================================================================ - // FEEDS - // ============================================================================ - - /// Get personal feed (chronological from follows) - Future> getPersonalFeed({int limit = 50, int offset = 0}) async { - final response = await _callFunction( - 'feed-personal', - method: 'GET', - queryParams: {'limit': limit.toString(), 'offset': offset.toString()}, - ); - - if (_isSuccessStatus(response)) { - final data = jsonDecode(response.body); - return (data['posts'] as List) - .map((json) => Post.fromJson(json)) - .toList(); - } else { - throw Exception(_extractError(response, 'Failed to get personal feed')); - } - } - - /// Get sojorn feed (algorithmic FYP) - Future> getsojornFeed({int limit = 50, int offset = 0}) async { - final response = await _callFunction( - 'feed-sojorn', - method: 'GET', - queryParams: {'limit': limit.toString(), 'offset': offset.toString()}, - ); - - if (_isSuccessStatus(response)) { - final data = jsonDecode(response.body); - return (data['posts'] as List) - .map((json) => Post.fromJson(json)) - .toList(); - } else { - throw Exception(_extractError(response, 'Failed to get sojorn feed')); - } - } - - /// Get trending posts by category - Future> getTrending({ - String category = 'general', - int limit = 20, - }) async { - final response = await _callFunction( - 'trending', - queryParams: {'category': category, 'limit': limit.toString()}, - ); - - if (response.statusCode == 200) { - final data = jsonDecode(response.body); - return (data['posts'] as List) - .map((json) => Post.fromJson(json)) - .toList(); - } else { - throw Exception(_extractError(response, 'Failed to get trending')); - } - } - - // ============================================================================ - // POST MANAGEMENT (EDIT/DELETE) - // ============================================================================ - - /// Edit a post (within 2-minute window) - /// Requires passing moderation check - Future editPost({ - required String postId, - required String content, - }) async { - final response = await _callFunction( - 'manage-post', - method: 'POST', - body: { - 'action': 'edit', - 'post_id': postId, - 'content': content, - }, - ); - - if (!_isSuccessStatus(response)) { - throw Exception(_extractError(response, 'Failed to edit post')); - } - } - - /// Soft delete a post - Future deletePost(String postId) async { - final response = await _callFunction( - 'manage-post', - method: 'POST', - body: { - 'action': 'delete', - 'post_id': postId, - }, - ); - - if (!_isSuccessStatus(response)) { - throw Exception(_extractError(response, 'Failed to delete post')); - } - } - - // ============================================================================ - // CHAIN RESPONSES - // ============================================================================ - - /// Fetch chain responses for a post (chronological) - Future> getChainPosts({ - required String parentPostId, - int limit = 50, - }) async { - final safeLimit = limit.clamp(1, 100).toInt(); - - final response = await _supabase - .from('posts') - .select(''' - id, - body, - author_id, - category_id, - tone_label, - cis_score, - status, - created_at, - edited_at, - deleted_at, - allow_chain, - chain_parent_id, - image_url, - chain_parent:posts ( - id, - body, - created_at, - author:profiles!posts_author_id_fkey ( - id, - handle, - display_name - ) - ), - metrics:post_metrics!post_metrics_post_id_fkey ( - like_count, - save_count, - view_count - ), - author:profiles!posts_author_id_fkey ( - id, - handle, - display_name, - trust_state ( - user_id, - harmony_score, - tier, - posts_today - ) - ) - ''') - .eq('chain_parent_id', parentPostId) - .order('created_at', ascending: true) - .limit(safeLimit); - - final rows = _normalizeListResponse(response); - return rows.map(Post.fromJson).toList(); - } - - /// Deactivate user account - Future deactivateAccount() async { - final response = await _callFunction( - 'deactivate-account', - method: 'POST', - ); - - if (response.statusCode != 200) { - throw Exception(_extractError(response, 'Failed to deactivate account')); - } - } - - /// Reactivate a deactivated account - Future reactivateAccount() async { - final response = await _callFunction( - 'deactivate-account/reactivate', - method: 'POST', - ); - - if (response.statusCode != 200) { - throw Exception(_extractError(response, 'Failed to reactivate account')); - } - } - - /// Request permanent account deletion (30-day waiting period) - Future> requestAccountDeletion() async { - final response = await _callFunction( - 'delete-account', - method: 'POST', - ); - - if (response.statusCode == 200) { - final data = jsonDecode(response.body); - return data; - } else { - throw Exception(_extractError(response, 'Failed to request account deletion')); - } - } - - /// Cancel pending account deletion request - Future cancelAccountDeletion() async { - final response = await _callFunction( - 'delete-account/cancel', - method: 'POST', - ); - - if (response.statusCode != 200) { - throw Exception(_extractError(response, 'Failed to cancel account deletion')); - } - } - - // ============================================================================ - // SEARCH - // ============================================================================ - - /// Search for users and hashtags simultaneously - /// Uses the database RPC function for efficient searching - Future search(String query) async { - if (query.trim().isEmpty) { - return SearchResults(users: [], tags: [], posts: []); - } - - try { - final response = await _supabase - .rpc('search_sojorn', params: {'p_query': query.trim()}); - - if (response is Map) { - return SearchResults.fromJson(response); - } else if (response is String && response.isNotEmpty) { - final parsed = jsonDecode(response); - if (parsed is Map) { - return SearchResults.fromJson(parsed); - } - } - - // Fallback for empty results - return SearchResults(users: [], tags: [], posts: []); - } catch (e) { - print('Search error: $e'); - // Return empty results on error - return SearchResults(users: [], tags: [], posts: []); - } - } - - // ============================================================================ - // NOTIFICATIONS - // ============================================================================ - - /// Get notifications for the current user - /// [limit] Maximum number of notifications to fetch (default: 20) - /// [offset] Number of notifications to skip for pagination (default: 0) - /// [unreadOnly] If true, only fetch unread notifications (default: false) - Future> getNotifications({ - int limit = 20, - int offset = 0, - bool unreadOnly = false, - }) async { - final response = await _callFunction( - 'notifications', - method: 'GET', - queryParams: { - 'limit': limit.toString(), - 'offset': offset.toString(), - if (unreadOnly) 'unread_only': 'true', - }, - ); - - if (_isSuccessStatus(response)) { - final data = jsonDecode(response.body); - if (data is List) { - return data.map((json) => AppNotification.fromJson(json)).toList(); - } - return []; - } else { - throw Exception(_extractError(response, 'Failed to get notifications')); - } - } - - /// Mark specific notifications as read - /// [notificationIds] List of notification IDs to mark as read - Future markNotificationsAsRead(List notificationIds) async { - final response = await _callFunction( - 'notifications', - method: 'PATCH', - body: {'notification_ids': notificationIds}, - ); - - if (!_isSuccessStatus(response)) { - throw Exception(_extractError(response, 'Failed to mark notifications as read')); - } - } - - /// Mark all notifications as read - Future markAllNotificationsAsRead() async { - final response = await _callFunction( - 'notifications', - method: 'PATCH', - body: {'mark_all_read': true}, - ); - - if (!_isSuccessStatus(response)) { - throw Exception(_extractError(response, 'Failed to mark all notifications as read')); - } - } - - /// Get the count of unread notifications - Future getUnreadNotificationCount() async { - final userId = _supabase.auth.currentUser?.id; - if (userId == null) { - return 0; - } - - try { - final response = await _supabase - .rpc('get_unread_notification_count', params: {'p_user_id': userId}); - - if (response is int) { - return response; - } - return 0; - } catch (e) { - print('Error getting unread notification count: $e'); - return 0; - } - } -}