MotorSignal-App/lib/services/clubs/club_service.dart

1076 lines
35 KiB
Dart

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<Club> 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<void> 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<void> 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<void> 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',
);
}
// Only owner can alter visibility or admission policy
if ((visibility != null || admissionPolicy != null) && !isOwner) {
throw const ClubOperationException(
'Only the club owner can change visibility or admission policy.',
code: 'permission-denied',
);
}
final updates = <String, dynamic>{
'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<void> 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<void> 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<void> 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<void> 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<void> 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<void> 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<void> 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<void> 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<void> 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<void> 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<void> 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 = <String, dynamic>{
'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<Club?> 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<Club?> 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<bool> 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<Club?> 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<ClubMember?> 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<ClubMember?> 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<List<Club>> 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<List<ClubMember>> 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<List<ClubMember>> 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<List<ClubMember>> 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<List<ClubMember>> 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());
}
}