1134 lines
32 KiB
Plaintext
1134 lines
32 KiB
Plaintext
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<Map<String, dynamic>> _callFunction(
|
|
String functionName, {
|
|
HttpMethod method = HttpMethod.post,
|
|
Map<String, dynamic>? 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<String, dynamic>) {
|
|
return data;
|
|
}
|
|
if (data is Map) {
|
|
return Map<String, dynamic>.from(data);
|
|
}
|
|
if (data is String && data.isNotEmpty) {
|
|
final parsed = jsonDecode(data);
|
|
if (parsed is Map<String, dynamic>) {
|
|
return parsed;
|
|
}
|
|
}
|
|
throw Exception('Unexpected response from $functionName');
|
|
}
|
|
|
|
|
|
List<Map<String, dynamic>> _normalizeListResponse(dynamic response) {
|
|
try {
|
|
// Supabase Flutter SDK returns data directly, not wrapped in PostgrestResponse
|
|
if (response == null) return [];
|
|
|
|
// Handle different response types
|
|
List<dynamic> rawData;
|
|
if (response is List<dynamic>) {
|
|
rawData = response;
|
|
} else if (response is Map<String, dynamic>) {
|
|
// If it's a single object, wrap it in a list
|
|
return [response];
|
|
} else {
|
|
return [];
|
|
}
|
|
|
|
return rawData
|
|
.where((item) => item is Map<String, dynamic>)
|
|
.map((item) => Map<String, dynamic>.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<List<Category>> 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<List<Category>> 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<bool> 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<bool> 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<void> setUserCategorySettings({
|
|
required List<Category> categories,
|
|
required Set<String> 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<Set<String>> _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<Profile> 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<Map<String, dynamic>> 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<List<Post>> 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<Post> 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<String, dynamic> data;
|
|
if (response is Map<String, dynamic>) {
|
|
data = response;
|
|
} else if (response is List && response.isNotEmpty) {
|
|
data = Map<String, dynamic>.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<Profile> updateProfile({
|
|
String? handle,
|
|
String? displayName,
|
|
String? bio,
|
|
String? location,
|
|
String? website,
|
|
List<String>? 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<ProfilePrivacySettings> 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<ProfilePrivacySettings> 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<List<Post>> 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<dynamic> rawData = response as List<dynamic>;
|
|
return rawData
|
|
.map((row) => row['post'] as Map<String, dynamic>?)
|
|
.where((post) => post != null)
|
|
.map((post) {
|
|
final payload = Map<String, dynamic>.from(post!);
|
|
payload['user_liked'] = true;
|
|
return Post.fromJson(payload);
|
|
})
|
|
.toList();
|
|
}
|
|
|
|
/// Fetch posts the user has saved
|
|
Future<List<Post>> 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<dynamic> rawData = response as List<dynamic>;
|
|
return rawData
|
|
.map((row) => row['post'] as Map<String, dynamic>?)
|
|
.where((post) => post != null)
|
|
.map((post) {
|
|
final payload = Map<String, dynamic>.from(post!);
|
|
payload['user_saved'] = true;
|
|
return Post.fromJson(payload);
|
|
})
|
|
.toList();
|
|
}
|
|
|
|
/// Fetch posts by an author that have chain responses
|
|
Future<List<Post>> 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<void> 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<void> 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<void> 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<void> 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<Post> 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<Comment> 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<ToneCheckResult> 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<String, dynamic>) {
|
|
return ToneCheckResult.fromJson(data);
|
|
}
|
|
if (data is Map) {
|
|
return ToneCheckResult.fromJson(Map<String, dynamic>.from(data));
|
|
}
|
|
if (data is String && data.isNotEmpty) {
|
|
final parsed = jsonDecode(data);
|
|
if (parsed is Map<String, dynamic>) {
|
|
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<void> 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<void> 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<void> 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<void> 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<void> 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<List<Post>> 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<List<Post>> 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<List<Post>> 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<void> 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<void> 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<List<Post>> 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<void> 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<void> 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<Map<String, dynamic>> 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<void> 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<SearchResults> 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<String, dynamic>) {
|
|
return SearchResults.fromJson(response);
|
|
} else if (response is String && response.isNotEmpty) {
|
|
final parsed = jsonDecode(response);
|
|
if (parsed is Map<String, dynamic>) {
|
|
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<List<AppNotification>> 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<void> markNotificationsAsRead(List<String> 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<void> 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<int> 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;
|
|
}
|
|
}
|
|
}
|