import 'dart:async'; import 'package:cloud_firestore/cloud_firestore.dart'; import 'package:firebase_auth/firebase_auth.dart'; import 'package:firebase_core/firebase_core.dart'; import 'club_models.dart'; import 'club_slug_validator.dart'; /// Exception thrown when a Club operation fails domain validation or security rules. class ClubOperationException implements Exception { final String message; final String? code; const ClubOperationException(this.message, {this.code}); @override String toString() => 'ClubOperationException: $message'; } /// Service managing Club creation, unique slug reservations, membership state machines, /// role-based administrative operations, and queries in Cloud Firestore. class ClubService { final FirebaseFirestore? _firestore; final FirebaseAuth? _auth; ClubService({ FirebaseFirestore? firestore, FirebaseAuth? auth, }) : _firestore = firestore ?? _resolveFirestore(), _auth = auth ?? _resolveAuth(); static FirebaseFirestore? _resolveFirestore() { try { if (Firebase.apps.isNotEmpty) return FirebaseFirestore.instance; } catch (_) {} return null; } static FirebaseAuth? _resolveAuth() { try { if (Firebase.apps.isNotEmpty) return FirebaseAuth.instance; } catch (_) {} return null; } bool get isAvailable => _firestore != null; String _requireRegisteredUid(String? explicitUid) { if (explicitUid != null && explicitUid.isNotEmpty) { return explicitUid; } final user = _auth?.currentUser; if (user == null || user.isAnonymous) { throw const ClubOperationException( 'A registered, non-anonymous account is required to interact with Clubs.', code: 'unauthenticated', ); } return user.uid; } // ========================================== // 1. CLUB CREATION & OWNERSHIP // ========================================== /// Atomically creates a new Club, reserves the unique slug, and creates the owner membership document. Future createClub({ required String name, required String slug, String description = '', ClubVisibility visibility = ClubVisibility.public, ClubAdmissionPolicy admissionPolicy = ClubAdmissionPolicy.request, String? logoUrl, String? bannerUrl, String? homeRegion, String? motorcycleFocus, String? callerUid, }) async { final uid = _requireRegisteredUid(callerUid); final cleanName = name.trim(); if (cleanName.length < 3 || cleanName.length > 50) { throw const ClubOperationException( 'Club name must be between 3 and 50 characters.', code: 'invalid-name', ); } final slugResult = ClubSlugValidator.validate(slug); if (!slugResult.isValid) { throw ClubOperationException( slugResult.error ?? 'Invalid club slug.', code: 'invalid-slug', ); } final normalizedSlug = slugResult.normalizedSlug!; final firestore = _firestore; if (firestore == null) { throw const ClubOperationException('Firestore is not available.', code: 'unavailable'); } final clubRef = firestore.collection('clubs').doc(); final slugRef = firestore.collection('club_handles').doc(normalizedSlug); final memberRef = clubRef.collection('members').doc(uid); final now = DateTime.now(); final createdClub = Club( id: clubRef.id, name: cleanName, slug: normalizedSlug, description: description.trim(), visibility: visibility, admissionPolicy: admissionPolicy, ownerUid: uid, memberCount: 1, createdAt: now, updatedAt: now, logoUrl: logoUrl, bannerUrl: bannerUrl, homeRegion: homeRegion, motorcycleFocus: motorcycleFocus, ); final ownerMember = ClubMember( uid: uid, clubId: clubRef.id, status: ClubMemberStatus.active, role: ClubRole.owner, displayRank: 'Founder', joinedAt: now, updatedAt: now, ); final clubHandle = ClubHandle( slug: normalizedSlug, clubId: clubRef.id, ownerUid: uid, createdAt: now, ); await firestore.runTransaction((tx) async { final slugDoc = await tx.get(slugRef); if (slugDoc.exists) { throw const ClubOperationException( 'This club slug is already taken.', code: 'slug-taken', ); } tx.set(slugRef, clubHandle.toFirestore()); tx.set(clubRef, createdClub.toFirestore()); tx.set(memberRef, ownerMember.toFirestore()); }); return createdClub; } /// Atomically transfers club ownership from caller to an active member. Future transferOwnership( String clubId, String newOwnerUid, { String? callerUid, }) async { final uid = _requireRegisteredUid(callerUid); if (uid == newOwnerUid) { throw const ClubOperationException( 'Cannot transfer ownership to yourself.', code: 'invalid-target', ); } final firestore = _firestore; if (firestore == null) return; final clubRef = firestore.collection('clubs').doc(clubId); final oldOwnerRef = clubRef.collection('members').doc(uid); final newOwnerRef = clubRef.collection('members').doc(newOwnerUid); await firestore.runTransaction((tx) async { final clubDoc = await tx.get(clubRef); if (!clubDoc.exists) { throw const ClubOperationException('Club not found.', code: 'not-found'); } final club = Club.fromFirestore(clubDoc); if (club.ownerUid != uid) { throw const ClubOperationException( 'Only the current club owner can transfer ownership.', code: 'permission-denied', ); } final newOwnerDoc = await tx.get(newOwnerRef); if (!newOwnerDoc.exists) { throw const ClubOperationException( 'Target member does not exist.', code: 'member-not-found', ); } final targetMember = ClubMember.fromFirestore(newOwnerDoc); if (targetMember.status != ClubMemberStatus.active) { throw const ClubOperationException( 'Target rider must be an active member to receive ownership.', code: 'invalid-state', ); } final now = FieldValue.serverTimestamp(); // Update club root tx.update(clubRef, { 'ownerUid': newOwnerUid, 'updatedAt': now, }); // Demote current owner to admin tx.update(oldOwnerRef, { 'role': ClubRole.admin.name, 'updatedAt': now, }); // Promote new owner to owner role tx.update(newOwnerRef, { 'role': ClubRole.owner.name, 'updatedAt': now, }); }); } /// Soft-deletes/archives a Club. Only permitted by the Owner. Future archiveClub(String clubId, {String? callerUid}) async { final firestore = _firestore; if (firestore == null) return; final uid = _requireRegisteredUid(callerUid); final clubRef = firestore.collection('clubs').doc(clubId); await firestore.runTransaction((tx) async { final doc = await tx.get(clubRef); if (!doc.exists) throw const ClubOperationException('Club not found.'); final club = Club.fromFirestore(doc); if (club.ownerUid != uid) { throw const ClubOperationException( 'Only the owner can archive the club.', code: 'permission-denied', ); } tx.update(clubRef, { 'archivedAt': FieldValue.serverTimestamp(), 'updatedAt': FieldValue.serverTimestamp(), }); }); } /// Updates Club metadata (name, description, graphics, focus, region). Future updateClubMetadata( String clubId, { String? name, String? description, ClubVisibility? visibility, ClubAdmissionPolicy? admissionPolicy, String? logoUrl, String? bannerUrl, String? homeRegion, String? motorcycleFocus, String? callerUid, }) async { final firestore = _firestore; if (firestore == null) return; final uid = _requireRegisteredUid(callerUid); final clubRef = firestore.collection('clubs').doc(clubId); final memberRef = clubRef.collection('members').doc(uid); await firestore.runTransaction((tx) async { final clubDoc = await tx.get(clubRef); if (!clubDoc.exists) throw const ClubOperationException('Club not found.'); final club = Club.fromFirestore(clubDoc); final memberDoc = await tx.get(memberRef); final isOwner = club.ownerUid == uid; final isAdmin = memberDoc.exists && ClubMember.fromFirestore(memberDoc).isAdmin; if (!isOwner && !isAdmin) { throw const ClubOperationException( 'Only club leadership can edit metadata.', code: 'permission-denied', ); } final updates = { 'updatedAt': FieldValue.serverTimestamp(), }; if (name != null) { final clean = name.trim(); if (clean.length < 3 || clean.length > 50) { throw const ClubOperationException('Name must be 3-50 characters.'); } updates['name'] = clean; } if (description != null) updates['description'] = description.trim(); if (visibility != null) updates['visibility'] = visibility.name; if (admissionPolicy != null) updates['admissionPolicy'] = admissionPolicy.name; if (logoUrl != null) updates['logoUrl'] = logoUrl; if (bannerUrl != null) updates['bannerUrl'] = bannerUrl; if (homeRegion != null) updates['homeRegion'] = homeRegion; if (motorcycleFocus != null) updates['motorcycleFocus'] = motorcycleFocus; tx.update(clubRef, updates); }); } // ========================================== // 2. MEMBERSHIP LIFECYCLE & STATE MACHINE // ========================================== /// Direct self-join when club admission policy is 'open'. Future joinOpenClub(String clubId, {String? callerUid}) async { final firestore = _firestore; if (firestore == null) return; final uid = _requireRegisteredUid(callerUid); final clubRef = firestore.collection('clubs').doc(clubId); final memberRef = clubRef.collection('members').doc(uid); final banRef = clubRef.collection('bans').doc(uid); await firestore.runTransaction((tx) async { final clubDoc = await tx.get(clubRef); if (!clubDoc.exists) throw const ClubOperationException('Club not found.'); final club = Club.fromFirestore(clubDoc); if (club.isArchived) throw const ClubOperationException('Club is archived.'); if (club.admissionPolicy != ClubAdmissionPolicy.open) { throw const ClubOperationException( 'This club does not allow open joining. Please submit a request.', code: 'policy-mismatch', ); } final banDoc = await tx.get(banRef); if (banDoc.exists) { throw const ClubOperationException( 'You are banned from joining this club.', code: 'banned', ); } final memberDoc = await tx.get(memberRef); if (memberDoc.exists) { final member = ClubMember.fromFirestore(memberDoc); if (member.status == ClubMemberStatus.active) { return; // Already active member (idempotent) } } final now = FieldValue.serverTimestamp(); final newMember = { 'uid': uid, 'clubId': clubId, 'status': ClubMemberStatus.active.name, 'role': ClubRole.member.name, 'joinedAt': now, 'updatedAt': now, }; tx.set(memberRef, newMember); tx.update(clubRef, { 'memberCount': FieldValue.increment(1), 'updatedAt': now, 'lastMembershipChangeUid': uid, }); }); } /// Rider requests to join when club admission policy is 'request'. Future requestToJoinClub(String clubId, {String? callerUid}) async { final firestore = _firestore; if (firestore == null) return; final uid = _requireRegisteredUid(callerUid); final clubRef = firestore.collection('clubs').doc(clubId); final memberRef = clubRef.collection('members').doc(uid); final banRef = clubRef.collection('bans').doc(uid); await firestore.runTransaction((tx) async { final clubDoc = await tx.get(clubRef); if (!clubDoc.exists) throw const ClubOperationException('Club not found.'); final club = Club.fromFirestore(clubDoc); if (club.isArchived) throw const ClubOperationException('Club is archived.'); if (club.admissionPolicy != ClubAdmissionPolicy.request) { throw const ClubOperationException( 'This club does not accept join requests.', code: 'policy-mismatch', ); } final banDoc = await tx.get(banRef); if (banDoc.exists) { throw const ClubOperationException('You are banned from this club.', code: 'banned'); } final memberDoc = await tx.get(memberRef); if (memberDoc.exists) { final member = ClubMember.fromFirestore(memberDoc); if (member.status == ClubMemberStatus.active) { throw const ClubOperationException('Already an active member of this club.'); } if (member.status == ClubMemberStatus.requested) { return; // Idempotent } } final now = FieldValue.serverTimestamp(); final requestData = { 'uid': uid, 'clubId': clubId, 'status': ClubMemberStatus.requested.name, 'role': ClubRole.member.name, 'requestedAt': now, 'updatedAt': now, }; tx.set(memberRef, requestData); }); } /// Rider withdraws their own pending join request. Future withdrawJoinRequest(String clubId, {String? callerUid}) async { final firestore = _firestore; if (firestore == null) return; final uid = _requireRegisteredUid(callerUid); final memberRef = firestore.collection('clubs').doc(clubId).collection('members').doc(uid); await firestore.runTransaction((tx) async { final doc = await tx.get(memberRef); if (!doc.exists) return; final member = ClubMember.fromFirestore(doc); if (member.status == ClubMemberStatus.requested) { tx.update(memberRef, { 'status': ClubMemberStatus.left.name, 'removedAt': FieldValue.serverTimestamp(), 'updatedAt': FieldValue.serverTimestamp(), }); } }); } /// Club administrator invites a rider to join. Future inviteRider( String clubId, String targetUid, { String? callerUid, }) async { final firestore = _firestore; if (firestore == null) return; final uid = _requireRegisteredUid(callerUid); final clubRef = firestore.collection('clubs').doc(clubId); final callerRef = clubRef.collection('members').doc(uid); final targetRef = clubRef.collection('members').doc(targetUid); final banRef = clubRef.collection('bans').doc(targetUid); await firestore.runTransaction((tx) async { final callerDoc = await tx.get(callerRef); if (!callerDoc.exists) { throw const ClubOperationException('Caller not a member of this club.'); } final callerMember = ClubMember.fromFirestore(callerDoc); if (!callerMember.isAdmin) { throw const ClubOperationException( 'Only club admins or owners can invite members.', code: 'permission-denied', ); } final banDoc = await tx.get(banRef); if (banDoc.exists) { throw const ClubOperationException('Cannot invite a banned rider.', code: 'banned'); } final targetDoc = await tx.get(targetRef); if (targetDoc.exists) { final target = ClubMember.fromFirestore(targetDoc); if (target.status == ClubMemberStatus.active) { throw const ClubOperationException('Rider is already an active member.'); } if (target.status == ClubMemberStatus.invited) { return; // Idempotent } } final now = FieldValue.serverTimestamp(); final inviteData = { 'uid': targetUid, 'clubId': clubId, 'status': ClubMemberStatus.invited.name, 'role': ClubRole.member.name, 'invitedByUid': uid, 'updatedAt': now, }; tx.set(targetRef, inviteData); }); } /// Invitee accepts or declines an invitation. Future respondToInvitation( String clubId, { required bool accept, String? callerUid, }) async { final firestore = _firestore; if (firestore == null) return; final uid = _requireRegisteredUid(callerUid); final clubRef = firestore.collection('clubs').doc(clubId); final memberRef = clubRef.collection('members').doc(uid); final banRef = clubRef.collection('bans').doc(uid); await firestore.runTransaction((tx) async { final banDoc = await tx.get(banRef); if (banDoc.exists) { throw const ClubOperationException('You are banned from this club.', code: 'banned'); } final memberDoc = await tx.get(memberRef); if (!memberDoc.exists) { throw const ClubOperationException('No invitation found.', code: 'not-found'); } final member = ClubMember.fromFirestore(memberDoc); if (member.status != ClubMemberStatus.invited) { throw const ClubOperationException('No pending invitation to respond to.'); } final now = FieldValue.serverTimestamp(); if (accept) { tx.update(memberRef, { 'status': ClubMemberStatus.active.name, 'joinedAt': now, 'updatedAt': now, }); tx.update(clubRef, { 'memberCount': FieldValue.increment(1), 'updatedAt': now, 'lastMembershipChangeUid': uid, }); } else { tx.update(memberRef, { 'status': ClubMemberStatus.declined.name, 'updatedAt': now, }); } }); } /// Club administrator approves or declines a join request. Future respondToJoinRequest( String clubId, String targetUid, { required bool approve, String? callerUid, }) async { final firestore = _firestore; if (firestore == null) return; final uid = _requireRegisteredUid(callerUid); final clubRef = firestore.collection('clubs').doc(clubId); final callerRef = clubRef.collection('members').doc(uid); final targetRef = clubRef.collection('members').doc(targetUid); await firestore.runTransaction((tx) async { final callerDoc = await tx.get(callerRef); if (!callerDoc.exists) throw const ClubOperationException('Caller not a member.'); final callerMember = ClubMember.fromFirestore(callerDoc); if (!callerMember.isAdmin) { throw const ClubOperationException( 'Only admins can respond to join requests.', code: 'permission-denied', ); } final targetDoc = await tx.get(targetRef); if (!targetDoc.exists) throw const ClubOperationException('Request not found.'); final target = ClubMember.fromFirestore(targetDoc); if (target.status != ClubMemberStatus.requested) { throw const ClubOperationException('No pending request for this user.'); } final now = FieldValue.serverTimestamp(); if (approve) { tx.update(targetRef, { 'status': ClubMemberStatus.active.name, 'joinedAt': now, 'updatedAt': now, }); tx.update(clubRef, { 'memberCount': FieldValue.increment(1), 'updatedAt': now, 'lastMembershipChangeUid': targetUid, }); } else { tx.update(targetRef, { 'status': ClubMemberStatus.declined.name, 'updatedAt': now, }); } }); } /// Active member voluntarily leaves the club. /// Enforces invariant: Owner cannot leave without prior ownership transfer. Future leaveClub(String clubId, {String? callerUid}) async { final firestore = _firestore; if (firestore == null) return; final uid = _requireRegisteredUid(callerUid); final clubRef = firestore.collection('clubs').doc(clubId); final memberRef = clubRef.collection('members').doc(uid); await firestore.runTransaction((tx) async { final clubDoc = await tx.get(clubRef); if (!clubDoc.exists) throw const ClubOperationException('Club not found.'); final club = Club.fromFirestore(clubDoc); if (club.ownerUid == uid) { throw const ClubOperationException( 'Club owner cannot leave without transferring ownership first.', code: 'owner-cannot-leave', ); } final memberDoc = await tx.get(memberRef); if (!memberDoc.exists) return; final member = ClubMember.fromFirestore(memberDoc); if (member.status != ClubMemberStatus.active) return; final now = FieldValue.serverTimestamp(); tx.update(memberRef, { 'status': ClubMemberStatus.left.name, 'removedAt': now, 'updatedAt': now, }); tx.update(clubRef, { 'memberCount': FieldValue.increment(-1), 'updatedAt': now, 'lastMembershipChangeUid': uid, }); }); } /// Leadership removes a member from the club. /// Enforces hierarchy: Owner can remove Admin/Member; Admin can remove Member only. Future removeMember( String clubId, String targetUid, { String? callerUid, }) async { final firestore = _firestore; if (firestore == null) return; final uid = _requireRegisteredUid(callerUid); final clubRef = firestore.collection('clubs').doc(clubId); final callerRef = clubRef.collection('members').doc(uid); final targetRef = clubRef.collection('members').doc(targetUid); await firestore.runTransaction((tx) async { final callerDoc = await tx.get(callerRef); if (!callerDoc.exists) throw const ClubOperationException('Caller not a member.'); final caller = ClubMember.fromFirestore(callerDoc); if (!caller.isAdmin) { throw const ClubOperationException( 'Only admins or owners can remove members.', code: 'permission-denied', ); } final targetDoc = await tx.get(targetRef); if (!targetDoc.exists) throw const ClubOperationException('Target not found.'); final target = ClubMember.fromFirestore(targetDoc); // Invariants: if (target.role == ClubRole.owner) { throw const ClubOperationException( 'Cannot remove the club owner.', code: 'cannot-remove-owner', ); } if (caller.role == ClubRole.admin && target.role == ClubRole.admin) { throw const ClubOperationException( 'Admins cannot remove other admins.', code: 'admin-peer-removal-disallowed', ); } final now = FieldValue.serverTimestamp(); tx.update(targetRef, { 'status': ClubMemberStatus.removed.name, 'removedAt': now, 'updatedAt': now, }); if (target.status == ClubMemberStatus.active) { tx.update(clubRef, { 'memberCount': FieldValue.increment(-1), 'updatedAt': now, 'lastMembershipChangeUid': targetUid, }); } }); } /// Bans a rider from the club and stores moderation data in clubs/{clubId}/bans/{uid}. Future banRider( String clubId, String targetUid, { String? reason, String? callerUid, }) async { final firestore = _firestore; if (firestore == null) return; final uid = _requireRegisteredUid(callerUid); final clubRef = firestore.collection('clubs').doc(clubId); final callerRef = clubRef.collection('members').doc(uid); final targetRef = clubRef.collection('members').doc(targetUid); final banRef = clubRef.collection('bans').doc(targetUid); await firestore.runTransaction((tx) async { final callerDoc = await tx.get(callerRef); if (!callerDoc.exists) throw const ClubOperationException('Caller not a member.'); final caller = ClubMember.fromFirestore(callerDoc); if (!caller.isAdmin) { throw const ClubOperationException( 'Only admins or owners can ban riders.', code: 'permission-denied', ); } final targetDoc = await tx.get(targetRef); bool wasActive = false; if (targetDoc.exists) { final target = ClubMember.fromFirestore(targetDoc); if (target.role == ClubRole.owner) { throw const ClubOperationException('Cannot ban the club owner.'); } if (caller.role == ClubRole.admin && target.role == ClubRole.admin) { throw const ClubOperationException('Admins cannot ban other admins.'); } wasActive = target.status == ClubMemberStatus.active; } final now = FieldValue.serverTimestamp(); // Write private administrative ban document tx.set(banRef, { 'uid': targetUid, 'clubId': clubId, 'bannedByUid': uid, 'bannedAt': now, if (reason != null && reason.trim().isNotEmpty) 'reason': reason.trim(), }); // Update membership status to removed if doc existed if (targetDoc.exists) { tx.update(targetRef, { 'status': ClubMemberStatus.removed.name, 'removedAt': now, 'updatedAt': now, }); } if (wasActive) { tx.update(clubRef, { 'memberCount': FieldValue.increment(-1), 'updatedAt': now, 'lastMembershipChangeUid': targetUid, }); } }); } /// Removes a ban, allowing the rider to request or join again. Future unbanRider(String clubId, String targetUid, {String? callerUid}) async { final firestore = _firestore; if (firestore == null) return; final uid = _requireRegisteredUid(callerUid); final callerRef = firestore.collection('clubs').doc(clubId).collection('members').doc(uid); final banRef = firestore.collection('clubs').doc(clubId).collection('bans').doc(targetUid); await firestore.runTransaction((tx) async { final callerDoc = await tx.get(callerRef); if (!callerDoc.exists) throw const ClubOperationException('Caller not a member.'); final caller = ClubMember.fromFirestore(callerDoc); if (!caller.isAdmin) { throw const ClubOperationException('Only admins can unban riders.'); } tx.delete(banRef); }); } /// Promotes or demotes an active member to/from admin, or assigns display rank. Future updateMemberRoleAndRank( String clubId, String targetUid, { ClubRole? newRole, String? newDisplayRank, String? callerUid, }) async { final firestore = _firestore; if (firestore == null) return; final uid = _requireRegisteredUid(callerUid); final clubRef = firestore.collection('clubs').doc(clubId); final callerRef = clubRef.collection('members').doc(uid); final targetRef = clubRef.collection('members').doc(targetUid); await firestore.runTransaction((tx) async { final callerDoc = await tx.get(callerRef); if (!callerDoc.exists) throw const ClubOperationException('Caller not found.'); final caller = ClubMember.fromFirestore(callerDoc); final targetDoc = await tx.get(targetRef); if (!targetDoc.exists) throw const ClubOperationException('Target not found.'); final target = ClubMember.fromFirestore(targetDoc); if (target.status != ClubMemberStatus.active) { throw const ClubOperationException( 'Target rider must be an active member.', code: 'invalid-state', ); } if (newRole != null) { // Only owner can promote to admin or demote admin if (caller.role != ClubRole.owner) { throw const ClubOperationException( 'Only the owner can assign security roles.', code: 'permission-denied', ); } if (newRole == ClubRole.owner) { throw const ClubOperationException( 'Use transferOwnership to assign the owner role.', code: 'invalid-operation', ); } } if (newDisplayRank != null) { // Admins and owners can assign cosmetic display rank if (!caller.isAdmin) { throw const ClubOperationException('Only admins can set display rank.'); } } final updates = { 'updatedAt': FieldValue.serverTimestamp(), }; if (newRole != null) updates['role'] = newRole.name; if (newDisplayRank != null) updates['displayRank'] = newDisplayRank.trim(); tx.update(targetRef, updates); }); } // ========================================== // 3. READ METHODS, QUERIES & STREAMS // ========================================== /// Point read of a Club document by ID. Future getClub(String clubId) async { final firestore = _firestore; if (firestore == null) return null; try { final doc = await firestore.collection('clubs').doc(clubId).get(); if (!doc.exists || doc.data() == null) return null; return Club.fromFirestore(doc); } catch (_) { // Permission denied or inaccessible private club return null; } } /// Realtime stream of a Club document by ID. Stream clubStream(String clubId) { final firestore = _firestore; if (firestore == null) return Stream.value(null); return firestore.collection('clubs').doc(clubId).snapshots().map((doc) { if (!doc.exists || doc.data() == null) return null; return Club.fromFirestore(doc); }).handleError((_) => null); } /// Checks whether a slug is valid and available (not already reserved in club_handles/{slug}). /// Gated to registered, non-anonymous users. Future isSlugAvailable(String slug) async { final validation = ClubSlugValidator.validate(slug); if (!validation.isValid) return false; final user = _auth?.currentUser; if (user == null || user.isAnonymous) return false; final firestore = _firestore; if (firestore == null) return false; final doc = await firestore .collection('club_handles') .doc(validation.normalizedSlug!) .get(); return !doc.exists; } /// Exact lookup of a Club by its reserved handle slug. /// Resolves club_handles/{slug} -> clubs/{clubId}. /// Gated to registered, non-anonymous users. Translates permission denials into safe null results. Future getClubBySlug(String slug) async { final validation = ClubSlugValidator.validate(slug); if (!validation.isValid) return null; final user = _auth?.currentUser; if (user == null || user.isAnonymous) { throw const ClubOperationException( 'A registered, non-anonymous account is required to perform club lookups.', code: 'unauthenticated', ); } final firestore = _firestore; if (firestore == null) return null; try { final handleDoc = await firestore .collection('club_handles') .doc(validation.normalizedSlug!) .get(); if (!handleDoc.exists || handleDoc.data() == null) return null; final clubId = handleDoc.data()?['clubId'] as String?; if (clubId == null || clubId.isEmpty) return null; return await getClub(clubId); } on ClubOperationException { rethrow; } catch (_) { // Permission denied or inaccessible private club -> safe null return null; } } /// Point read of a ClubMember document. Future getMember(String clubId, String uid) async { final firestore = _firestore; if (firestore == null) return null; final doc = await firestore .collection('clubs') .doc(clubId) .collection('members') .doc(uid) .get(); if (!doc.exists || doc.data() == null) return null; return ClubMember.fromFirestore(doc); } /// Realtime stream of a ClubMember document. Stream memberStream(String clubId, String uid) { final firestore = _firestore; if (firestore == null) return Stream.value(null); return firestore .collection('clubs') .doc(clubId) .collection('members') .doc(uid) .snapshots() .map((doc) { if (!doc.exists || doc.data() == null) return null; return ClubMember.fromFirestore(doc); }); } /// Paginated query for public club discovery. /// Statically satisfies Firestore rules: visibility == public && archivedAt == null. Future> discoverPublicClubs({ int limit = 25, DocumentSnapshot? startAfter, }) async { final firestore = _firestore; if (firestore == null) return const []; Query query = firestore .collection('clubs') .where('visibility', isEqualTo: ClubVisibility.public.name) .where('archivedAt', isNull: true) .orderBy('memberCount', descending: true) .limit(limit); if (startAfter != null) { query = query.startAfterDocument(startAfter); } final snapshot = await query.get(); return snapshot.docs.map((doc) => Club.fromFirestore(doc)).toList(); } /// Stream of caller's active club memberships across all clubs via collection group query. /// Statically satisfies Firestore rules: uid == caller.uid && status == active. Stream> myActiveClubsStream(String uid) { final firestore = _firestore; if (firestore == null) return Stream.value(const []); return firestore .collectionGroup('members') .where('uid', isEqualTo: uid) .where('status', isEqualTo: ClubMemberStatus.active.name) .orderBy('joinedAt', descending: true) .snapshots() .map((snap) => snap.docs.map((doc) => ClubMember.fromFirestore(doc)).toList()); } /// Stream of caller's pending incoming invitations across all clubs via collection group query. Stream> myInvitationsStream(String uid) { final firestore = _firestore; if (firestore == null) return Stream.value(const []); return firestore .collectionGroup('members') .where('uid', isEqualTo: uid) .where('status', isEqualTo: ClubMemberStatus.invited.name) .orderBy('updatedAt', descending: true) .snapshots() .map((snap) => snap.docs.map((doc) => ClubMember.fromFirestore(doc)).toList()); } /// Stream of active members for a specific club (paginated limit). Stream> activeMembersStream(String clubId, {int limit = 50}) { final firestore = _firestore; if (firestore == null) return Stream.value(const []); return firestore .collection('clubs') .doc(clubId) .collection('members') .where('status', isEqualTo: ClubMemberStatus.active.name) .orderBy('joinedAt', descending: false) .limit(limit) .snapshots() .map((snap) => snap.docs.map((doc) => ClubMember.fromFirestore(doc)).toList()); } /// Stream of pending join requests for club management. Stream> pendingRequestsStream(String clubId) { final firestore = _firestore; if (firestore == null) return Stream.value(const []); return firestore .collection('clubs') .doc(clubId) .collection('members') .where('status', isEqualTo: ClubMemberStatus.requested.name) .orderBy('requestedAt', descending: false) .snapshots() .map((snap) => snap.docs.map((doc) => ClubMember.fromFirestore(doc)).toList()); } }