1079 lines
35 KiB
Dart
1079 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;
|
|
try {
|
|
final doc = await firestore
|
|
.collection('club_handles')
|
|
.doc(validation.normalizedSlug!)
|
|
.get();
|
|
return !doc.exists;
|
|
} catch (_) {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
/// 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());
|
|
}
|
|
}
|