hwhub/lib/services/collection_service.dart
copilot-swe-agent[bot] 4fbd776605 Add client-side ownership checks to update and delete methods
Co-authored-by: derkauzigekoala <79001016+derkauzigekoala@users.noreply.github.com>
2026-02-24 19:18:27 +00:00

292 lines
8.2 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';
}
/// 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';
}
/// Service for managing collections and membership.
class CollectionService {
CollectionService._();
/// 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')
.in_('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;
}
// Fetch all members for these collections in a single query and count them in memory.
final members = await supabase
.from('collection_members')
.select('id, collection_id')
.in_('collection_id', collectionIdList);
final memberCounts = <String, int>{};
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,
'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,
}) async {
// 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;
// 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.');
}
await supabase.from('collection_members').insert({
'collection_id': collectionId,
'user_id': userId,
'role': 'member',
});
}
/// Remove a member from a collection.
static Future<void> removeMember({
required String collectionId,
required String membershipId,
}) async {
await supabase
.from('collection_members')
.delete()
.eq('id', membershipId);
}
/// Leave a collection (for non-owners).
static Future<void> leave(String collectionId) async {
final userId = supabase.auth.currentUser!.id;
await supabase
.from('collection_members')
.delete()
.eq('collection_id', collectionId)
.eq('user_id', userId);
}
}