hwhub/lib/services/collection_service.dart
2026-03-05 11:19:41 +01:00

437 lines
13 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._();
/// 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 = supabase.auth.currentUser!.id;
// 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();
// Fetch all items for these collections in a single query and count them in memory.
final items = await supabase
.from('hotwheels')
.select('id, collection_id')
.inFilter('collection_id', collectionIdList);
final itemCounts = <String, int>{};
for (final item in items) {
final collectionId = item['collection_id'] as String;
itemCounts[collectionId] = (itemCounts[collectionId] ?? 0) + 1;
}
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 (_) {
// Keep fallback below when RPC fails.
}
}));
// Fallback member count for collections where RPC did not return data.
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;
}
/// Create a new collection. The caller is automatically added as owner.
static Future<Collection> create({
required String name,
String? description,
}) async {
final userId = supabase.auth.currentUser!.id;
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 = supabase.auth.currentUser!.id;
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 = supabase.auth.currentUser!.id;
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 = supabase.auth.currentUser!.id;
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 not enabled in your database yet. Please apply the viewer-role migration first.',
);
}
rethrow;
}
}
/// Remove a member from a collection.
static Future<void> removeMember({
required String collectionId,
required String memberUserId,
}) async {
final currentUserId = supabase.auth.currentUser!.id;
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 members = await getMembers(collectionId);
final targetMembership = members.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.');
}
final deleted = await supabase
.from('collection_members')
.delete()
.eq('collection_id', collectionId)
.eq('user_id', memberUserId)
.select('id');
if (deleted.isEmpty) {
throw Exception(
'Member could not be removed (blocked by database policy).',
);
}
final stillExists = await supabase
.from('collection_members')
.select('id')
.eq('collection_id', collectionId)
.eq('user_id', memberUserId)
.maybeSingle();
if (stillExists != null) {
throw Exception('Member removal did not persist. Please try again.');
}
}
/// Leave a collection (for non-owners).
static Future<void> leave(String collectionId) async {
final userId = supabase.auth.currentUser!.id;
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);
}
}