532 lines
15 KiB
Dart
532 lines
15 KiB
Dart
//import 'package:supabase_flutter/supabase_flutter.dart';
|
|
import '../main.dart';
|
|
|
|
/// Data model for a collection.
|
|
class Collection {
|
|
final String id;
|
|
final String name;
|
|
final String? description;
|
|
final String ownerId;
|
|
final DateTime createdAt;
|
|
final String role; // 'owner' or 'member'
|
|
final int itemCount;
|
|
final int memberCount;
|
|
|
|
Collection({
|
|
required this.id,
|
|
required this.name,
|
|
this.description,
|
|
required this.ownerId,
|
|
required this.createdAt,
|
|
required this.role,
|
|
this.itemCount = 0,
|
|
this.memberCount = 1,
|
|
});
|
|
|
|
bool get isOwner => role == 'owner';
|
|
bool get isMember => role == 'member';
|
|
bool get isViewer => role == 'viewer';
|
|
bool get canModifyCars => isOwner || isMember;
|
|
}
|
|
|
|
/// Member of a collection.
|
|
class CollectionMember {
|
|
final String id;
|
|
final String userId;
|
|
final String email;
|
|
final String role;
|
|
final DateTime joinedAt;
|
|
|
|
CollectionMember({
|
|
required this.id,
|
|
required this.userId,
|
|
required this.email,
|
|
required this.role,
|
|
required this.joinedAt,
|
|
});
|
|
|
|
bool get isOwner => role == 'owner';
|
|
bool get isViewer => role == 'viewer';
|
|
}
|
|
|
|
/// Service for managing collections and membership.
|
|
class CollectionService {
|
|
CollectionService._();
|
|
|
|
static String _requireUserId() {
|
|
final userId = supabase.auth.currentUser?.id;
|
|
if (userId == null) {
|
|
throw Exception('You must be signed in to perform this action.');
|
|
}
|
|
return userId;
|
|
}
|
|
|
|
/// Ensures the current user has at least one collection membership.
|
|
/// Creates a default collection on first login.
|
|
static Future<String?> ensureDefaultCollection() async {
|
|
final userId = supabase.auth.currentUser?.id;
|
|
if (userId == null) return null;
|
|
|
|
final existing = await supabase
|
|
.from('collection_members')
|
|
.select('collection_id')
|
|
.eq('user_id', userId)
|
|
.limit(1);
|
|
|
|
if (existing.isNotEmpty) {
|
|
return existing.first['collection_id'] as String;
|
|
}
|
|
|
|
final created = await create(
|
|
name: 'My Garage',
|
|
description: 'Your default collection',
|
|
);
|
|
|
|
return created.id;
|
|
}
|
|
|
|
/// Fetch all collections the current user is a member of,
|
|
/// including item count and member count.
|
|
static Future<List<Collection>> getMyCollections() async {
|
|
final userId = _requireUserId();
|
|
|
|
// Get memberships with collection data.
|
|
final memberships = await supabase
|
|
.from('collection_members')
|
|
.select('role, collections(id, name, description, owner_id, created_at)')
|
|
.eq('user_id', userId);
|
|
|
|
// If the user has no memberships, return early.
|
|
if (memberships.isEmpty) {
|
|
return <Collection>[];
|
|
}
|
|
|
|
// Collect all distinct collection IDs from memberships.
|
|
final collectionIds = <String>{};
|
|
for (final m in memberships) {
|
|
final c = m['collections'] as Map<String, dynamic>;
|
|
final collectionId = c['id'] as String;
|
|
collectionIds.add(collectionId);
|
|
}
|
|
|
|
final collectionIdList = collectionIds.toList();
|
|
|
|
final itemCounts = await getCollectionItemCounts(collectionIdList);
|
|
|
|
final memberCounts = <String, int>{};
|
|
await Future.wait(collectionIdList.map((collectionId) async {
|
|
try {
|
|
final rows = await supabase.rpc('get_collection_members', params: {
|
|
'p_collection_id': collectionId,
|
|
});
|
|
memberCounts[collectionId] = (rows as List).length;
|
|
} catch (_) {
|
|
}
|
|
}));
|
|
|
|
final unresolvedIds = collectionIdList
|
|
.where((id) => !memberCounts.containsKey(id))
|
|
.toList(growable: false);
|
|
if (unresolvedIds.isNotEmpty) {
|
|
final members = await supabase
|
|
.from('collection_members')
|
|
.select('id, collection_id')
|
|
.inFilter('collection_id', unresolvedIds);
|
|
|
|
for (final member in members) {
|
|
final collectionId = member['collection_id'] as String;
|
|
memberCounts[collectionId] = (memberCounts[collectionId] ?? 0) + 1;
|
|
}
|
|
}
|
|
|
|
final collections = <Collection>[];
|
|
|
|
for (final m in memberships) {
|
|
final c = m['collections'] as Map<String, dynamic>;
|
|
final collectionId = c['id'] as String;
|
|
|
|
collections.add(Collection(
|
|
id: collectionId,
|
|
name: c['name'] as String,
|
|
description: c['description'] as String?,
|
|
ownerId: c['owner_id'] as String,
|
|
createdAt: DateTime.parse(c['created_at'] as String),
|
|
role: m['role'] as String,
|
|
itemCount: itemCounts[collectionId] ?? 0,
|
|
memberCount: memberCounts[collectionId] ?? 1,
|
|
));
|
|
}
|
|
|
|
// Sort: owned first, then by name.
|
|
collections.sort((a, b) {
|
|
if (a.isOwner && !b.isOwner) return -1;
|
|
if (!a.isOwner && b.isOwner) return 1;
|
|
return a.name.compareTo(b.name);
|
|
});
|
|
|
|
return collections;
|
|
}
|
|
|
|
static Future<Map<String, int>> getCollectionItemCounts(
|
|
List<String> collectionIds) async {
|
|
if (collectionIds.isEmpty) return <String, int>{};
|
|
|
|
final itemCounts = <String, int>{};
|
|
try {
|
|
final rows = await supabase.rpc('get_collection_counts', params: {
|
|
'p_collection_ids': collectionIds,
|
|
});
|
|
|
|
for (final row in rows as List) {
|
|
final collectionId = row['collection_id'] as String?;
|
|
if (collectionId == null) continue;
|
|
itemCounts[collectionId] = (row['total_count'] as num?)?.toInt() ?? 0;
|
|
}
|
|
} catch (_) {}
|
|
|
|
final unresolvedIds = collectionIds
|
|
.where((collectionId) => !itemCounts.containsKey(collectionId))
|
|
.toList(growable: false);
|
|
|
|
if (unresolvedIds.isNotEmpty) {
|
|
await Future.wait(unresolvedIds.map((collectionId) async {
|
|
itemCounts[collectionId] = await _countCarsPaginated(collectionId);
|
|
}));
|
|
}
|
|
|
|
return itemCounts;
|
|
}
|
|
|
|
static Future<({int total, int recent})> getCollectionStats(
|
|
String collectionId) async {
|
|
try {
|
|
final rows = await supabase.rpc('get_collection_counts', params: {
|
|
'p_collection_ids': [collectionId],
|
|
});
|
|
|
|
if (rows is List && rows.isNotEmpty) {
|
|
final first = rows.first as Map<String, dynamic>;
|
|
return (
|
|
total: (first['total_count'] as num?)?.toInt() ?? 0,
|
|
recent: (first['recent_count'] as num?)?.toInt() ?? 0,
|
|
);
|
|
}
|
|
} catch (_) {}
|
|
|
|
final weekAgoIso = DateTime.now()
|
|
.subtract(const Duration(days: 7))
|
|
.toUtc()
|
|
.toIso8601String();
|
|
final total = await _countCarsPaginated(collectionId);
|
|
final recent = await _countCarsPaginated(collectionId, sinceIso: weekAgoIso);
|
|
return (total: total, recent: recent);
|
|
}
|
|
|
|
static Future<int> _countCarsPaginated(String collectionId,
|
|
{String? sinceIso}) async {
|
|
const pageSize = 1000;
|
|
var from = 0;
|
|
var total = 0;
|
|
|
|
while (true) {
|
|
dynamic query = supabase
|
|
.from('hotwheels')
|
|
.select('id')
|
|
.eq('collection_id', collectionId)
|
|
.range(from, from + pageSize - 1);
|
|
|
|
if (sinceIso != null) {
|
|
query = query.gte('created_at', sinceIso);
|
|
}
|
|
|
|
final rows = await query;
|
|
final count = (rows as List).length;
|
|
total += count;
|
|
|
|
if (count < pageSize) break;
|
|
from += pageSize;
|
|
}
|
|
|
|
return total;
|
|
}
|
|
|
|
/// Create a new collection. The caller is automatically added as owner.
|
|
static Future<Collection> create({
|
|
required String name,
|
|
String? description,
|
|
}) async {
|
|
final userId = _requireUserId();
|
|
|
|
final row = await supabase
|
|
.from('collections')
|
|
.insert({
|
|
'name': name,
|
|
'owner_id': userId,
|
|
if (description != null && description.isNotEmpty)
|
|
'description': description,
|
|
})
|
|
.select()
|
|
.single();
|
|
|
|
// Add owner as a member.
|
|
await supabase.from('collection_members').insert({
|
|
'collection_id': row['id'],
|
|
'user_id': userId,
|
|
'role': 'owner',
|
|
});
|
|
|
|
return Collection(
|
|
id: row['id'] as String,
|
|
name: row['name'] as String,
|
|
description: row['description'] as String?,
|
|
ownerId: userId,
|
|
createdAt: DateTime.parse(row['created_at'] as String),
|
|
role: 'owner',
|
|
itemCount: 0,
|
|
memberCount: 1,
|
|
);
|
|
}
|
|
|
|
/// Update a collection's name/description. Owner only.
|
|
static Future<void> update({
|
|
required String collectionId,
|
|
required String name,
|
|
String? description,
|
|
}) async {
|
|
final userId = _requireUserId();
|
|
|
|
final collection = await supabase
|
|
.from('collections')
|
|
.select('owner_id')
|
|
.eq('id', collectionId)
|
|
.maybeSingle();
|
|
|
|
if (collection == null) {
|
|
throw Exception('Collection not found.');
|
|
}
|
|
if (collection['owner_id'] != userId) {
|
|
throw Exception('Only the collection owner can perform this action.');
|
|
}
|
|
|
|
await supabase.from('collections').update({
|
|
'name': name,
|
|
if (description != null && description.isNotEmpty)
|
|
'description': description,
|
|
}).eq('id', collectionId);
|
|
}
|
|
|
|
/// Delete a collection. Owner only. Cascade deletes members & items.
|
|
static Future<void> delete(String collectionId) async {
|
|
final userId = _requireUserId();
|
|
|
|
final collection = await supabase
|
|
.from('collections')
|
|
.select('owner_id')
|
|
.eq('id', collectionId)
|
|
.maybeSingle();
|
|
|
|
if (collection == null) {
|
|
throw Exception('Collection not found.');
|
|
}
|
|
if (collection['owner_id'] != userId) {
|
|
throw Exception('Only the collection owner can perform this action.');
|
|
}
|
|
|
|
await supabase.from('collections').delete().eq('id', collectionId);
|
|
}
|
|
|
|
/// Get members of a collection (with emails via RPC).
|
|
static Future<List<CollectionMember>> getMembers(
|
|
String collectionId) async {
|
|
final rows = await supabase.rpc('get_collection_members', params: {
|
|
'p_collection_id': collectionId,
|
|
});
|
|
|
|
final members = <CollectionMember>[];
|
|
for (final r in rows) {
|
|
members.add(CollectionMember(
|
|
id: r['id'] as String,
|
|
userId: r['user_id'] as String,
|
|
email: r['email'] as String? ?? 'unknown',
|
|
role: r['role'] as String,
|
|
joinedAt: DateTime.parse(r['created_at'] as String),
|
|
));
|
|
}
|
|
|
|
return members;
|
|
}
|
|
|
|
/// Invite a user by email. Looks up auth.users via an RPC function,
|
|
/// then inserts a collection_members row.
|
|
static Future<void> inviteByEmail({
|
|
required String collectionId,
|
|
required String email,
|
|
String role = 'member',
|
|
}) async {
|
|
final normalizedRole = role.trim().toLowerCase();
|
|
if (normalizedRole != 'member' && normalizedRole != 'viewer') {
|
|
throw Exception('Unsupported role "$role".');
|
|
}
|
|
|
|
final currentUserId = _requireUserId();
|
|
|
|
final collection = await supabase
|
|
.from('collections')
|
|
.select('owner_id')
|
|
.eq('id', collectionId)
|
|
.maybeSingle();
|
|
|
|
if (collection == null) {
|
|
throw Exception('Collection not found.');
|
|
}
|
|
if (collection['owner_id'] != currentUserId) {
|
|
throw Exception('Only the collection owner can invite members.');
|
|
}
|
|
|
|
// Call an RPC to look up the user ID by email.
|
|
final result = await supabase.rpc('get_user_id_by_email', params: {
|
|
'lookup_email': email.trim().toLowerCase(),
|
|
});
|
|
|
|
if (result == null || (result is List && result.isEmpty)) {
|
|
throw Exception(
|
|
'No user found with that email. They must create an account first.');
|
|
}
|
|
|
|
final userId = result is List ? result.first['id'] as String : result as String;
|
|
|
|
if (userId == currentUserId) {
|
|
throw Exception('You are already in this collection.');
|
|
}
|
|
|
|
// Check if already a member.
|
|
final existing = await supabase
|
|
.from('collection_members')
|
|
.select('id')
|
|
.eq('collection_id', collectionId)
|
|
.eq('user_id', userId)
|
|
.maybeSingle();
|
|
|
|
if (existing != null) {
|
|
throw Exception('This user is already a member of this collection.');
|
|
}
|
|
|
|
try {
|
|
await supabase.from('collection_members').insert({
|
|
'collection_id': collectionId,
|
|
'user_id': userId,
|
|
'role': normalizedRole,
|
|
});
|
|
} catch (e) {
|
|
final message = e.toString();
|
|
if (normalizedRole == 'viewer' &&
|
|
message.contains('collection_members_role_check')) {
|
|
throw Exception(
|
|
'Viewer role is currently unavailable. Please contact the app administrator.',
|
|
);
|
|
}
|
|
rethrow;
|
|
}
|
|
}
|
|
|
|
/// Remove a member from a collection.
|
|
static Future<void> removeMember({
|
|
required String collectionId,
|
|
required String memberUserId,
|
|
}) async {
|
|
final currentUserId = _requireUserId();
|
|
|
|
final collection = await supabase
|
|
.from('collections')
|
|
.select('owner_id')
|
|
.eq('id', collectionId)
|
|
.maybeSingle();
|
|
|
|
if (collection == null) {
|
|
throw Exception('Collection not found.');
|
|
}
|
|
if (collection['owner_id'] != currentUserId) {
|
|
throw Exception('Only the collection owner can remove members.');
|
|
}
|
|
|
|
if (memberUserId == collection['owner_id']) {
|
|
throw Exception('Collection owner cannot be removed.');
|
|
}
|
|
|
|
final membersBefore = await getMembers(collectionId);
|
|
final targetMembership =
|
|
membersBefore.where((member) => member.userId == memberUserId);
|
|
|
|
if (targetMembership.isEmpty) {
|
|
throw Exception('Member not found in this collection.');
|
|
}
|
|
final target = targetMembership.first;
|
|
if (target.role == 'owner') {
|
|
throw Exception('Collection owner cannot be removed.');
|
|
}
|
|
|
|
var deleted = false;
|
|
try {
|
|
await supabase.rpc('remove_collection_member', params: {
|
|
'p_collection_id': collectionId,
|
|
'p_member_user_id': memberUserId,
|
|
});
|
|
deleted = true;
|
|
} catch (e) {
|
|
final message = e.toString();
|
|
final rpcUnavailable = message.contains('remove_collection_member') &&
|
|
(message.contains('not found') ||
|
|
message.contains('does not exist') ||
|
|
message.contains('PGRST202'));
|
|
|
|
if (!rpcUnavailable) {
|
|
rethrow;
|
|
}
|
|
|
|
await supabase
|
|
.from('collection_members')
|
|
.delete()
|
|
.eq('collection_id', collectionId)
|
|
.eq('user_id', memberUserId);
|
|
deleted = true;
|
|
}
|
|
|
|
if (!deleted) {
|
|
throw Exception('Member removal failed.');
|
|
}
|
|
|
|
final membersAfter = await getMembers(collectionId);
|
|
final stillExists =
|
|
membersAfter.any((member) => member.userId == memberUserId);
|
|
if (stillExists) {
|
|
throw Exception(
|
|
'Member could not be removed. Please try again in a moment.',
|
|
);
|
|
}
|
|
}
|
|
|
|
/// Leave a collection (for non-owners).
|
|
static Future<void> leave(String collectionId) async {
|
|
final userId = _requireUserId();
|
|
|
|
final membership = await supabase
|
|
.from('collection_members')
|
|
.select('role')
|
|
.eq('collection_id', collectionId)
|
|
.eq('user_id', userId)
|
|
.maybeSingle();
|
|
|
|
if (membership == null) {
|
|
throw Exception('You are not a member of this collection.');
|
|
}
|
|
if (membership['role'] == 'owner') {
|
|
throw Exception('Owner cannot leave. Delete the collection instead.');
|
|
}
|
|
|
|
await supabase
|
|
.from('collection_members')
|
|
.delete()
|
|
.eq('collection_id', collectionId)
|
|
.eq('user_id', userId);
|
|
}
|
|
}
|