From 0d8e26750fffa3bf9b3049307a1ee1623fb09c86 Mon Sep 17 00:00:00 2001 From: Developer Date: Sun, 6 Sep 2026 17:44:56 -0400 Subject: [PATCH] feat: add secure clubs foundation and membership architecture --- .../emulator-tests/firestore.rules.test.js | 1061 ++++++++++++++++- firestore.indexes.json | 82 ++ firestore.rules | 286 +++++ lib/services/clubs/club_models.dart | 607 ++++++++++ lib/services/clubs/club_providers.dart | 59 + lib/services/clubs/club_service.dart | 1021 ++++++++++++++++ lib/services/clubs/club_slug_validator.dart | 102 ++ test/club_models_test.dart | 247 ++++ test/club_service_test.dart | 139 +++ test/club_slug_validator_test.dart | 107 ++ 10 files changed, 3710 insertions(+), 1 deletion(-) create mode 100644 lib/services/clubs/club_models.dart create mode 100644 lib/services/clubs/club_providers.dart create mode 100644 lib/services/clubs/club_service.dart create mode 100644 lib/services/clubs/club_slug_validator.dart create mode 100644 test/club_models_test.dart create mode 100644 test/club_service_test.dart create mode 100644 test/club_slug_validator_test.dart diff --git a/firebase/emulator-tests/firestore.rules.test.js b/firebase/emulator-tests/firestore.rules.test.js index 3fac68a..204c469 100644 --- a/firebase/emulator-tests/firestore.rules.test.js +++ b/firebase/emulator-tests/firestore.rules.test.js @@ -4,7 +4,7 @@ import { assertSucceeds, initializeTestEnvironment, } from '@firebase/rules-unit-testing'; -import { collection, deleteDoc, doc, getDoc, getDocs, setDoc, updateDoc, writeBatch, query, where } from 'firebase/firestore'; +import { collection, collectionGroup, deleteDoc, doc, getDoc, getDocs, setDoc, updateDoc, writeBatch, query, where } from 'firebase/firestore'; import fs from 'node:fs'; import path from 'node:path'; @@ -1791,5 +1791,1064 @@ describe('Social Architecture Security Rules', () => { await assertSucceeds(setDoc(doc(alice, 'routes/route_25_stops'), baseRoute('route_25_stops', maxStops))); }); }); + + describe('MotorSignal Club Foundation and Membership Security Rules', () => { + test('Authentication boundary: registered users allowed, anonymous and unauthenticated users rejected', async () => { + const alice = testEnv.authenticatedContext('alice').firestore(); + const anon = testEnv.authenticatedContext('anon', { firebase: { sign_in_provider: 'anonymous' } }).firestore(); + const unauth = testEnv.unauthenticatedContext().firestore(); + + // Seed a public club + await testEnv.withSecurityRulesDisabled(async (context) => { + const db = context.firestore(); + await setDoc(doc(db, 'clubs/club_auth_test'), { + id: 'club_auth_test', + name: 'Auth Test Club', + slug: 'auth-test', + visibility: 'public', + admissionPolicy: 'open', + ownerUid: 'alice', + memberCount: 1, + createdAt: new Date(), + updatedAt: new Date(), + archivedAt: null, + }); + }); + + // 1. Registered user can point-read public club -> ALLOW + await assertSucceeds(getDoc(doc(alice, 'clubs/club_auth_test'))); + + // 2. Anonymous user cannot point-read -> DENY + await assertFails(getDoc(doc(anon, 'clubs/club_auth_test'))); + + // 3. Unauthenticated user cannot point-read -> DENY + await assertFails(getDoc(doc(unauth, 'clubs/club_auth_test'))); + + // 4. Anonymous user cannot query discovery -> DENY + const discoveryQuery = query( + collection(anon, 'clubs'), + where('visibility', '==', 'public'), + where('archivedAt', '==', null) + ); + await assertFails(getDocs(discoveryQuery)); + }); + + test('Club creation atomicity: atomic creation of club, owner member, and slug reservation', async () => { + const alice = testEnv.authenticatedContext('alice').firestore(); + const bob = testEnv.authenticatedContext('bob').firestore(); + + // 1. Valid atomic creation batch -> ALLOW + const validBatch = writeBatch(alice); + validBatch.set(doc(alice, 'clubs/club_create_valid'), { + id: 'club_create_valid', + name: 'Bay Area Riders', + slug: 'bay-area-riders', + description: 'Local motorcycle club', + visibility: 'public', + admissionPolicy: 'request', + ownerUid: 'alice', + memberCount: 1, + createdAt: new Date(), + updatedAt: new Date(), + archivedAt: null, + }); + validBatch.set(doc(alice, 'clubs/club_create_valid/members/alice'), { + uid: 'alice', + clubId: 'club_create_valid', + status: 'active', + role: 'owner', + joinedAt: new Date(), + updatedAt: new Date(), + }); + validBatch.set(doc(alice, 'club_handles/bay-area-riders'), { + slug: 'bay-area-riders', + clubId: 'club_create_valid', + ownerUid: 'alice', + createdAt: new Date(), + }); + await assertSucceeds(validBatch.commit()); + + // 2. Partial creation: missing slug handle -> DENY + const missingHandleBatch = writeBatch(bob); + missingHandleBatch.set(doc(bob, 'clubs/club_no_slug'), { + id: 'club_no_slug', + name: 'No Slug MC', + slug: 'no-slug', + visibility: 'public', + admissionPolicy: 'open', + ownerUid: 'bob', + memberCount: 1, + createdAt: new Date(), + updatedAt: new Date(), + archivedAt: null, + }); + missingHandleBatch.set(doc(bob, 'clubs/club_no_slug/members/bob'), { + uid: 'bob', + clubId: 'club_no_slug', + status: 'active', + role: 'owner', + joinedAt: new Date(), + updatedAt: new Date(), + }); + await assertFails(missingHandleBatch.commit()); + + // 3. Mismatched owner in slug handle -> DENY + const mismatchedOwnerBatch = writeBatch(bob); + mismatchedOwnerBatch.set(doc(bob, 'clubs/club_mismatched'), { + id: 'club_mismatched', + name: 'Mismatched MC', + slug: 'mismatched-mc', + visibility: 'public', + admissionPolicy: 'open', + ownerUid: 'bob', + memberCount: 1, + createdAt: new Date(), + updatedAt: new Date(), + archivedAt: null, + }); + mismatchedOwnerBatch.set(doc(bob, 'clubs/club_mismatched/members/bob'), { + uid: 'bob', + clubId: 'club_mismatched', + status: 'active', + role: 'owner', + joinedAt: new Date(), + updatedAt: new Date(), + }); + mismatchedOwnerBatch.set(doc(bob, 'club_handles/mismatched-mc'), { + slug: 'mismatched-mc', + clubId: 'club_mismatched', + ownerUid: 'alice', // Falsified owner + createdAt: new Date(), + }); + await assertFails(mismatchedOwnerBatch.commit()); + }); + + test('Discovery query security: public discovery query allowed, unconstrained collection scan rejected', async () => { + const alice = testEnv.authenticatedContext('alice').firestore(); + const bob = testEnv.authenticatedContext('bob').firestore(); + + await testEnv.withSecurityRulesDisabled(async (context) => { + const db = context.firestore(); + // Public active club + await setDoc(doc(db, 'clubs/club_public'), { + id: 'club_public', + name: 'Public Club', + slug: 'public-club', + visibility: 'public', + admissionPolicy: 'open', + ownerUid: 'alice', + memberCount: 5, + archivedAt: null, + }); + // Private club + await setDoc(doc(db, 'clubs/club_private'), { + id: 'club_private', + name: 'Private Secret Club', + slug: 'private-club', + visibility: 'private', + admissionPolicy: 'inviteOnly', + ownerUid: 'alice', + memberCount: 2, + archivedAt: null, + }); + // Archived club + await setDoc(doc(db, 'clubs/club_archived'), { + id: 'club_archived', + name: 'Archived Club', + slug: 'archived-club', + visibility: 'public', + admissionPolicy: 'open', + ownerUid: 'alice', + memberCount: 10, + archivedAt: new Date(), + }); + }); + + // 1. Properly constrained discovery query -> ALLOW + const validQuery = query( + collection(bob, 'clubs'), + where('visibility', '==', 'public'), + where('archivedAt', '==', null) + ); + await assertSucceeds(getDocs(validQuery)); + + // 2. Unconstrained collection scan -> DENY + await assertFails(getDocs(collection(bob, 'clubs'))); + + // 3. Bob queries without archivedAt filter -> DENY + const missingArchiveFilter = query( + collection(bob, 'clubs'), + where('visibility', '==', 'public') + ); + await assertFails(getDocs(missingArchiveFilter)); + + // 4. Bob cannot read private club document directly -> DENY + await assertFails(getDoc(doc(bob, 'clubs/club_private'))); + + // 5. Alice (owner) can read private club document -> ALLOW + await assertSucceeds(getDoc(doc(alice, 'clubs/club_private'))); + }); + + test('Membership lifecycle: open join, request to join, invitation response, and leave', async () => { + const alice = testEnv.authenticatedContext('alice').firestore(); + const bob = testEnv.authenticatedContext('bob').firestore(); + const charlie = testEnv.authenticatedContext('charlie').firestore(); + + await testEnv.withSecurityRulesDisabled(async (context) => { + const db = context.firestore(); + // Club with 'open' admission + await setDoc(doc(db, 'clubs/club_open'), { + id: 'club_open', + name: 'Open Riders', + slug: 'open-riders', + visibility: 'public', + admissionPolicy: 'open', + ownerUid: 'alice', + memberCount: 1, + archivedAt: null, + }); + await setDoc(doc(db, 'clubs/club_open/members/alice'), { + uid: 'alice', + clubId: 'club_open', + status: 'active', + role: 'owner', + }); + + // Club with 'request' admission + await setDoc(doc(db, 'clubs/club_req'), { + id: 'club_req', + name: 'Vetted Riders', + slug: 'vetted-riders', + visibility: 'public', + admissionPolicy: 'request', + ownerUid: 'alice', + memberCount: 1, + archivedAt: null, + }); + await setDoc(doc(db, 'clubs/club_req/members/alice'), { + uid: 'alice', + clubId: 'club_req', + status: 'active', + role: 'owner', + }); + + // Club with 'inviteOnly' admission + await setDoc(doc(db, 'clubs/club_inv'), { + id: 'club_inv', + name: 'Invite Only MC', + slug: 'invite-only', + visibility: 'private', + admissionPolicy: 'inviteOnly', + ownerUid: 'alice', + memberCount: 1, + archivedAt: null, + }); + await setDoc(doc(db, 'clubs/club_inv/members/alice'), { + uid: 'alice', + clubId: 'club_inv', + status: 'active', + role: 'owner', + }); + }); + + // 1. Standalone self-join without atomic club memberCount increment -> DENY + await assertFails(setDoc(doc(bob, 'clubs/club_open/members/bob'), { + uid: 'bob', + clubId: 'club_open', + status: 'active', + role: 'member', + })); + + // Valid atomic self-join (member active + club memberCount +1) -> ALLOW + const joinBatch = writeBatch(bob); + joinBatch.set(doc(bob, 'clubs/club_open/members/bob'), { + uid: 'bob', + clubId: 'club_open', + status: 'active', + role: 'member', + }); + joinBatch.update(doc(bob, 'clubs/club_open'), { + memberCount: 2, + updatedAt: new Date(), + lastMembershipChangeUid: 'bob', + }); + await assertSucceeds(joinBatch.commit()); + + // 2. Bob tries to directly self-join request-policy club -> DENY + await assertFails(setDoc(doc(bob, 'clubs/club_req/members/bob'), { + uid: 'bob', + clubId: 'club_req', + status: 'active', + role: 'member', + })); + + // 3. Bob requests to join request-policy club (count does not change) -> ALLOW + await assertSucceeds(setDoc(doc(bob, 'clubs/club_req/members/bob'), { + uid: 'bob', + clubId: 'club_req', + status: 'requested', + role: 'member', + })); + + // 4. Bob tries to self-join invite-only club -> DENY + await assertFails(setDoc(doc(bob, 'clubs/club_inv/members/bob'), { + uid: 'bob', + clubId: 'club_inv', + status: 'active', + role: 'member', + })); + + // 5. Alice (owner) invites Charlie to invite-only club -> ALLOW + await assertSucceeds(setDoc(doc(alice, 'clubs/club_inv/members/charlie'), { + uid: 'charlie', + clubId: 'club_inv', + status: 'invited', + role: 'member', + })); + + // 6. Standalone invitation accept without club memberCount increment -> DENY + await assertFails(updateDoc(doc(charlie, 'clubs/club_inv/members/charlie'), { + status: 'active', + })); + + // Valid atomic invitation accept (member active + club count +1) -> ALLOW + const acceptBatch = writeBatch(charlie); + acceptBatch.update(doc(charlie, 'clubs/club_inv/members/charlie'), { + status: 'active', + }); + acceptBatch.update(doc(charlie, 'clubs/club_inv'), { + memberCount: 2, + updatedAt: new Date(), + lastMembershipChangeUid: 'charlie', + }); + await assertSucceeds(acceptBatch.commit()); + + // 7. Standalone voluntary leave without club memberCount decrement -> DENY + await assertFails(updateDoc(doc(charlie, 'clubs/club_inv/members/charlie'), { + status: 'left', + })); + + // Valid atomic leave (member left + club count -1) -> ALLOW + const leaveBatch = writeBatch(charlie); + leaveBatch.update(doc(charlie, 'clubs/club_inv/members/charlie'), { + status: 'left', + }); + leaveBatch.update(doc(charlie, 'clubs/club_inv'), { + memberCount: 1, + updatedAt: new Date(), + lastMembershipChangeUid: 'charlie', + }); + await assertSucceeds(leaveBatch.commit()); + + // 8. Alice (owner) tries to leave without transferring ownership -> DENY + await assertFails(updateDoc(doc(alice, 'clubs/club_inv/members/alice'), { + status: 'left', + })); + }); + + test('Role hierarchy & self-promotion prevention: members cannot promote self or alter roles', async () => { + const bob = testEnv.authenticatedContext('bob').firestore(); + const charlie = testEnv.authenticatedContext('charlie').firestore(); + + await testEnv.withSecurityRulesDisabled(async (context) => { + const db = context.firestore(); + await setDoc(doc(db, 'clubs/club_role_test'), { + id: 'club_role_test', + ownerUid: 'alice', + slug: 'role-test', + memberCount: 3, + }); + await setDoc(doc(db, 'clubs/club_role_test/members/alice'), { + uid: 'alice', + role: 'owner', + status: 'active', + }); + await setDoc(doc(db, 'clubs/club_role_test/members/bob'), { + uid: 'bob', + role: 'admin', + status: 'active', + }); + await setDoc(doc(db, 'clubs/club_role_test/members/charlie'), { + uid: 'charlie', + role: 'member', + status: 'active', + }); + }); + + // 1. Charlie (member) attempts to promote himself to admin -> DENY + await assertFails(updateDoc(doc(charlie, 'clubs/club_role_test/members/charlie'), { + role: 'admin', + })); + + // 2. Charlie attempts to promote himself to owner -> DENY + await assertFails(updateDoc(doc(charlie, 'clubs/club_role_test/members/charlie'), { + role: 'owner', + })); + + // 3. Bob (admin) attempts to promote himself to owner directly -> DENY + await assertFails(updateDoc(doc(bob, 'clubs/club_role_test/members/bob'), { + role: 'owner', + })); + + // 4. Bob (admin) attempts to remove the owner Alice -> DENY + await assertFails(updateDoc(doc(bob, 'clubs/club_role_test/members/alice'), { + status: 'removed', + })); + + // 5. Standalone remove without atomic club memberCount decrement -> DENY + await assertFails(updateDoc(doc(bob, 'clubs/club_role_test/members/charlie'), { + status: 'removed', + })); + + // Valid atomic remove of active member by admin -> ALLOW + const removeBatch = writeBatch(bob); + removeBatch.update(doc(bob, 'clubs/club_role_test/members/charlie'), { + status: 'removed', + }); + removeBatch.update(doc(bob, 'clubs/club_role_test'), { + memberCount: 2, + updatedAt: new Date(), + lastMembershipChangeUid: 'charlie', + }); + await assertSucceeds(removeBatch.commit()); + }); + + test('Atomic ownership transfer: valid transfer succeeds, partial/unauthorized transfer rejected', async () => { + const alice = testEnv.authenticatedContext('alice').firestore(); + const bob = testEnv.authenticatedContext('bob').firestore(); + const charlie = testEnv.authenticatedContext('charlie').firestore(); + + await testEnv.withSecurityRulesDisabled(async (context) => { + const db = context.firestore(); + await setDoc(doc(db, 'clubs/club_transfer_test'), { + id: 'club_transfer_test', + ownerUid: 'alice', + slug: 'transfer-test', + memberCount: 2, + createdAt: new Date('2026-01-01T00:00:00Z'), + }); + await setDoc(doc(db, 'clubs/club_transfer_test/members/alice'), { + uid: 'alice', + role: 'owner', + status: 'active', + }); + await setDoc(doc(db, 'clubs/club_transfer_test/members/bob'), { + uid: 'bob', + role: 'admin', + status: 'active', + }); + await setDoc(doc(db, 'clubs/club_transfer_test/members/charlie'), { + uid: 'charlie', + role: 'member', + status: 'declined', // Not active! + }); + }); + + // 1. Bob (non-owner) tries to initiate transfer -> DENY + const nonOwnerBatch = writeBatch(bob); + nonOwnerBatch.update(doc(bob, 'clubs/club_transfer_test'), { ownerUid: 'bob' }); + nonOwnerBatch.update(doc(bob, 'clubs/club_transfer_test/members/alice'), { role: 'admin' }); + nonOwnerBatch.update(doc(bob, 'clubs/club_transfer_test/members/bob'), { role: 'owner' }); + await assertFails(nonOwnerBatch.commit()); + + // 2. Alice transfers to Charlie who is NOT an active member -> DENY + const invalidTargetBatch = writeBatch(alice); + invalidTargetBatch.update(doc(alice, 'clubs/club_transfer_test'), { ownerUid: 'charlie' }); + invalidTargetBatch.update(doc(alice, 'clubs/club_transfer_test/members/alice'), { role: 'admin' }); + invalidTargetBatch.update(doc(alice, 'clubs/club_transfer_test/members/charlie'), { role: 'owner' }); + await assertFails(invalidTargetBatch.commit()); + + // 3. Partial transfer: Alice updates club root without updating member roles -> DENY + await assertFails(updateDoc(doc(alice, 'clubs/club_transfer_test'), { ownerUid: 'bob' })); + + // 4. Valid complete atomic transfer to active member Bob -> ALLOW + const validTransferBatch = writeBatch(alice); + validTransferBatch.update(doc(alice, 'clubs/club_transfer_test'), { + ownerUid: 'bob', + updatedAt: new Date(), + }); + validTransferBatch.update(doc(alice, 'clubs/club_transfer_test/members/alice'), { role: 'admin' }); + validTransferBatch.update(doc(alice, 'clubs/club_transfer_test/members/bob'), { role: 'owner' }); + await assertSucceeds(validTransferBatch.commit()); + }); + + test('Administrative bans: regular members cannot access bans, banned riders cannot join', async () => { + const alice = testEnv.authenticatedContext('alice').firestore(); + const bob = testEnv.authenticatedContext('bob').firestore(); + const charlie = testEnv.authenticatedContext('charlie').firestore(); + + await testEnv.withSecurityRulesDisabled(async (context) => { + const db = context.firestore(); + await setDoc(doc(db, 'clubs/club_ban_test'), { + id: 'club_ban_test', + ownerUid: 'alice', + slug: 'ban-test', + admissionPolicy: 'open', + memberCount: 1, + }); + await setDoc(doc(db, 'clubs/club_ban_test/members/alice'), { + uid: 'alice', + role: 'owner', + status: 'active', + }); + await setDoc(doc(db, 'clubs/club_ban_test/members/bob'), { + uid: 'bob', + role: 'member', + status: 'active', + }); + // Charlie is banned + await setDoc(doc(db, 'clubs/club_ban_test/bans/charlie'), { + uid: 'charlie', + bannedByUid: 'alice', + reason: 'Reckless riding behavior', + }); + }); + + // 1. Bob (member) cannot read bans subcollection -> DENY + await assertFails(getDocs(collection(bob, 'clubs/club_ban_test/bans'))); + await assertFails(getDoc(doc(bob, 'clubs/club_ban_test/bans/charlie'))); + + // 2. Alice (owner/admin) can read bans -> ALLOW + await assertSucceeds(getDoc(doc(alice, 'clubs/club_ban_test/bans/charlie'))); + + // 3. Charlie (banned) attempts to join open club -> DENY + await assertFails(setDoc(doc(charlie, 'clubs/club_ban_test/members/charlie'), { + uid: 'charlie', + clubId: 'club_ban_test', + status: 'active', + role: 'member', + })); + }); + + test('Collection group query: caller can query own memberships/invites, cannot query others', async () => { + const alice = testEnv.authenticatedContext('alice').firestore(); + const bob = testEnv.authenticatedContext('bob').firestore(); + + // 1. Alice queries her own memberships across all clubs -> ALLOW + const aliceMyClubsQuery = query( + collectionGroup(alice, 'members'), + where('uid', '==', 'alice') + ); + await assertSucceeds(getDocs(aliceMyClubsQuery)); + + // 2. Bob attempts to query Alice memberships across clubs -> DENY + const bobQueryAlice = query( + collectionGroup(bob, 'members'), + where('uid', '==', 'alice') + ); + await assertFails(getDocs(bobQueryAlice)); + + // 3. Unconstrained collection group scan -> DENY + await assertFails(getDocs(collectionGroup(bob, 'members'))); + }); + + test('Slug handle creation atomicity: handle reservation strictly requires consistent atomic club and owner creation (Cases A-J)', async () => { + const alice = testEnv.authenticatedContext('alice').firestore(); + const bob = testEnv.authenticatedContext('bob').firestore(); + + // Seed an existing club for Bob with slug 'bob-mc' + await testEnv.withSecurityRulesDisabled(async (context) => { + const db = context.firestore(); + await setDoc(doc(db, 'clubs/club_bob_exist'), { + id: 'club_bob_exist', + name: 'Bob Existing MC', + slug: 'bob-mc', + visibility: 'public', + admissionPolicy: 'open', + ownerUid: 'bob', + memberCount: 1, + archivedAt: null, + }); + await setDoc(doc(db, 'clubs/club_bob_exist/members/bob'), { + uid: 'bob', + clubId: 'club_bob_exist', + status: 'active', + role: 'owner', + }); + await setDoc(doc(db, 'club_handles/bob-mc'), { + slug: 'bob-mc', + clubId: 'club_bob_exist', + ownerUid: 'bob', + createdAt: new Date(), + }); + }); + + // A. Standalone handle reservation with nonexistent club -> DENIED + await assertFails(setDoc(doc(alice, 'club_handles/standalone-slug'), { + slug: 'standalone-slug', + clubId: 'club_nonexistent', + ownerUid: 'alice', + createdAt: new Date(), + })); + + // B. Standalone handle reservation pointing at existing club with different slug -> DENIED + await assertFails(setDoc(doc(bob, 'club_handles/hijack-slug'), { + slug: 'hijack-slug', + clubId: 'club_bob_exist', + ownerUid: 'bob', + createdAt: new Date(), + })); + + // C. Standalone handle reservation pointing at another user's club -> DENIED + await assertFails(setDoc(doc(alice, 'club_handles/bob-mc-stolen'), { + slug: 'bob-mc-stolen', + clubId: 'club_bob_exist', + ownerUid: 'alice', + createdAt: new Date(), + })); + + // D. Batch containing handle + club but missing owner membership -> DENIED + const missingOwnerMemberBatch = writeBatch(alice); + missingOwnerMemberBatch.set(doc(alice, 'clubs/club_missing_owner'), { + id: 'club_missing_owner', + name: 'Missing Owner MC', + slug: 'missing-owner-mc', + visibility: 'public', + admissionPolicy: 'open', + ownerUid: 'alice', + memberCount: 1, + createdAt: new Date(), + updatedAt: new Date(), + archivedAt: null, + }); + missingOwnerMemberBatch.set(doc(alice, 'club_handles/missing-owner-mc'), { + slug: 'missing-owner-mc', + clubId: 'club_missing_owner', + ownerUid: 'alice', + createdAt: new Date(), + }); + await assertFails(missingOwnerMemberBatch.commit()); + + // E. Batch containing handle + owner membership but missing club -> DENIED + const missingClubBatch = writeBatch(alice); + missingClubBatch.set(doc(alice, 'clubs/club_ghost/members/alice'), { + uid: 'alice', + clubId: 'club_ghost', + status: 'active', + role: 'owner', + joinedAt: new Date(), + updatedAt: new Date(), + }); + missingClubBatch.set(doc(alice, 'club_handles/ghost-mc'), { + slug: 'ghost-mc', + clubId: 'club_ghost', + ownerUid: 'alice', + createdAt: new Date(), + }); + await assertFails(missingClubBatch.commit()); + + // F. Batch containing club + owner membership but missing handle -> DENIED + const missingHandleBatch = writeBatch(alice); + missingHandleBatch.set(doc(alice, 'clubs/club_no_handle'), { + id: 'club_no_handle', + name: 'No Handle MC', + slug: 'no-handle-mc', + visibility: 'public', + admissionPolicy: 'open', + ownerUid: 'alice', + memberCount: 1, + createdAt: new Date(), + updatedAt: new Date(), + archivedAt: null, + }); + missingHandleBatch.set(doc(alice, 'clubs/club_no_handle/members/alice'), { + uid: 'alice', + clubId: 'club_no_handle', + status: 'active', + role: 'owner', + joinedAt: new Date(), + updatedAt: new Date(), + }); + await assertFails(missingHandleBatch.commit()); + + // G. Handle ownerUid mismatch with club.ownerUid -> DENIED + const ownerMismatchBatch = writeBatch(alice); + ownerMismatchBatch.set(doc(alice, 'clubs/club_owner_mismatch'), { + id: 'club_owner_mismatch', + name: 'Mismatch Owner MC', + slug: 'mismatch-owner', + visibility: 'public', + admissionPolicy: 'open', + ownerUid: 'alice', + memberCount: 1, + createdAt: new Date(), + updatedAt: new Date(), + archivedAt: null, + }); + ownerMismatchBatch.set(doc(alice, 'clubs/club_owner_mismatch/members/alice'), { + uid: 'alice', + clubId: 'club_owner_mismatch', + status: 'active', + role: 'owner', + joinedAt: new Date(), + updatedAt: new Date(), + }); + ownerMismatchBatch.set(doc(alice, 'club_handles/mismatch-owner'), { + slug: 'mismatch-owner', + clubId: 'club_owner_mismatch', + ownerUid: 'bob', // Falsified owner + createdAt: new Date(), + }); + await assertFails(ownerMismatchBatch.commit()); + + // H. Handle slug mismatch with club.slug -> DENIED + const slugMismatchBatch = writeBatch(alice); + slugMismatchBatch.set(doc(alice, 'clubs/club_slug_mismatch'), { + id: 'club_slug_mismatch', + name: 'Mismatch Slug MC', + slug: 'slug-in-club', + visibility: 'public', + admissionPolicy: 'open', + ownerUid: 'alice', + memberCount: 1, + createdAt: new Date(), + updatedAt: new Date(), + archivedAt: null, + }); + slugMismatchBatch.set(doc(alice, 'clubs/club_slug_mismatch/members/alice'), { + uid: 'alice', + clubId: 'club_slug_mismatch', + status: 'active', + role: 'owner', + joinedAt: new Date(), + updatedAt: new Date(), + }); + slugMismatchBatch.set(doc(alice, 'club_handles/slug-in-handle'), { + slug: 'slug-in-handle', + clubId: 'club_slug_mismatch', + ownerUid: 'alice', + createdAt: new Date(), + }); + await assertFails(slugMismatchBatch.commit()); + + // I. Correct three-document atomic club creation -> ALLOWED + const validThreeDocBatch = writeBatch(alice); + validThreeDocBatch.set(doc(alice, 'clubs/club_atomic_ok'), { + id: 'club_atomic_ok', + name: 'Atomic OK MC', + slug: 'atomic-ok', + description: 'Perfect 3-doc creation', + visibility: 'public', + admissionPolicy: 'open', + ownerUid: 'alice', + memberCount: 1, + createdAt: new Date(), + updatedAt: new Date(), + archivedAt: null, + }); + validThreeDocBatch.set(doc(alice, 'clubs/club_atomic_ok/members/alice'), { + uid: 'alice', + clubId: 'club_atomic_ok', + status: 'active', + role: 'owner', + joinedAt: new Date(), + updatedAt: new Date(), + }); + validThreeDocBatch.set(doc(alice, 'club_handles/atomic-ok'), { + slug: 'atomic-ok', + clubId: 'club_atomic_ok', + ownerUid: 'alice', + createdAt: new Date(), + }); + await assertSucceeds(validThreeDocBatch.commit()); + + // J. Duplicate slug -> DENIED + const dupSlugBatch = writeBatch(bob); + dupSlugBatch.set(doc(bob, 'clubs/club_dup_slug'), { + id: 'club_dup_slug', + name: 'Duplicate Slug MC', + slug: 'atomic-ok', // Duplicate! + visibility: 'public', + admissionPolicy: 'open', + ownerUid: 'bob', + memberCount: 1, + createdAt: new Date(), + updatedAt: new Date(), + archivedAt: null, + }); + dupSlugBatch.set(doc(bob, 'clubs/club_dup_slug/members/bob'), { + uid: 'bob', + clubId: 'club_dup_slug', + status: 'active', + role: 'owner', + joinedAt: new Date(), + updatedAt: new Date(), + }); + dupSlugBatch.set(doc(bob, 'club_handles/atomic-ok'), { + slug: 'atomic-ok', + clubId: 'club_dup_slug', + ownerUid: 'bob', + createdAt: new Date(), + }); + await assertFails(dupSlugBatch.commit()); + + // Point read and collection list checks: + // Point read existing handle -> ALLOW + await assertSucceeds(getDoc(doc(bob, 'club_handles/atomic-ok'))); + // List all handles -> DENIED + await assertFails(getDocs(collection(bob, 'club_handles'))); + }); + + test('Orphan owner membership creation attack: standalone or uncoupled owner creation rejected', async () => { + const bob = testEnv.authenticatedContext('bob').firestore(); + + // 1. Attacker (bob) attempts to create standalone owner member in nonexistent club -> DENY + await assertFails(setDoc(doc(bob, 'clubs/club_ghost_orphan/members/bob'), { + uid: 'bob', + clubId: 'club_ghost_orphan', + status: 'active', + role: 'owner', + })); + + // 2. Attacker (bob) attempts to inject self as owner into existing club -> DENY + await assertFails(setDoc(doc(bob, 'clubs/club_atomic_ok/members/bob'), { + uid: 'bob', + clubId: 'club_atomic_ok', + status: 'active', + role: 'owner', + })); + }); + + test('Slug handle invariants: deletion and updates strictly prohibited (permanent tombstone)', async () => { + const alice = testEnv.authenticatedContext('alice').firestore(); + const bob = testEnv.authenticatedContext('bob').firestore(); + + // Seed handle + await testEnv.withSecurityRulesDisabled(async (context) => { + const db = context.firestore(); + await setDoc(doc(db, 'club_handles/tombstone-slug'), { + slug: 'tombstone-slug', + clubId: 'club_tombstone', + ownerUid: 'alice', + createdAt: new Date(), + }); + }); + + // 1. Owner Alice attempts to delete slug -> DENY + await assertFails(deleteDoc(doc(alice, 'club_handles/tombstone-slug'))); + + // 2. Non-owner Bob attempts to delete slug -> DENY + await assertFails(deleteDoc(doc(bob, 'club_handles/tombstone-slug'))); + + // 3. Owner Alice attempts to update slug -> DENY + await assertFails(updateDoc(doc(alice, 'club_handles/tombstone-slug'), { + clubId: 'club_reassigned', + })); + + // 4. Non-owner Bob attempts to update slug -> DENY + await assertFails(updateDoc(doc(bob, 'club_handles/tombstone-slug'), { + ownerUid: 'bob', + })); + }); + + test('Club root slug immutability: slug cannot be modified by owner, admin, or member', async () => { + const alice = testEnv.authenticatedContext('alice').firestore(); + const bob = testEnv.authenticatedContext('bob').firestore(); + const charlie = testEnv.authenticatedContext('charlie').firestore(); + + await testEnv.withSecurityRulesDisabled(async (context) => { + const db = context.firestore(); + await setDoc(doc(db, 'clubs/club_slug_immutability'), { + id: 'club_slug_immutability', + name: 'Immutability MC', + slug: 'immutability-mc', + visibility: 'public', + admissionPolicy: 'open', + ownerUid: 'alice', + memberCount: 3, + }); + await setDoc(doc(db, 'clubs/club_slug_immutability/members/alice'), { + uid: 'alice', + role: 'owner', + status: 'active', + }); + await setDoc(doc(db, 'clubs/club_slug_immutability/members/bob'), { + uid: 'bob', + role: 'admin', + status: 'active', + }); + await setDoc(doc(db, 'clubs/club_slug_immutability/members/charlie'), { + uid: 'charlie', + role: 'member', + status: 'active', + }); + }); + + // 1. Owner Alice attempts to change slug -> DENY + await assertFails(updateDoc(doc(alice, 'clubs/club_slug_immutability'), { + slug: 'new-slug', + })); + + // 2. Admin Bob attempts to change slug -> DENY + await assertFails(updateDoc(doc(bob, 'clubs/club_slug_immutability'), { + slug: 'new-slug', + })); + + // 3. Member Charlie attempts to change slug -> DENY + await assertFails(updateDoc(doc(charlie, 'clubs/club_slug_immutability'), { + slug: 'new-slug', + })); + }); + + test('Direct memberCount mutation attacks: standalone or uncoupled mutations strictly rejected', async () => { + const alice = testEnv.authenticatedContext('alice').firestore(); + const bob = testEnv.authenticatedContext('bob').firestore(); + const charlie = testEnv.authenticatedContext('charlie').firestore(); + + await testEnv.withSecurityRulesDisabled(async (context) => { + const db = context.firestore(); + await setDoc(doc(db, 'clubs/club_counter_attack'), { + id: 'club_counter_attack', + name: 'Counter Defense Club', + slug: 'counter-defense', + visibility: 'public', + admissionPolicy: 'open', + ownerUid: 'alice', + memberCount: 2, + }); + await setDoc(doc(db, 'clubs/club_counter_attack/members/alice'), { + uid: 'alice', + role: 'owner', + status: 'active', + }); + await setDoc(doc(db, 'clubs/club_counter_attack/members/bob'), { + uid: 'bob', + role: 'admin', + status: 'active', + }); + }); + + // 1. Standalone memberCount increment by owner without member doc -> DENY + await assertFails(updateDoc(doc(alice, 'clubs/club_counter_attack'), { + memberCount: 3, + updatedAt: new Date(), + lastMembershipChangeUid: 'alice', + })); + + // 2. Standalone memberCount increment by admin -> DENY + await assertFails(updateDoc(doc(bob, 'clubs/club_counter_attack'), { + memberCount: 3, + updatedAt: new Date(), + lastMembershipChangeUid: 'bob', + })); + + // 3. Standalone memberCount increment by regular member/user Charlie -> DENY + await assertFails(updateDoc(doc(charlie, 'clubs/club_counter_attack'), { + memberCount: 3, + updatedAt: new Date(), + lastMembershipChangeUid: 'charlie', + })); + + // 4. Standalone memberCount decrement by owner -> DENY + await assertFails(updateDoc(doc(alice, 'clubs/club_counter_attack'), { + memberCount: 1, + updatedAt: new Date(), + lastMembershipChangeUid: 'alice', + })); + + // 5. Arbitrary jump (+5) in a batch with a single member addition -> DENY + const jumpBatch = writeBatch(charlie); + jumpBatch.set(doc(charlie, 'clubs/club_counter_attack/members/charlie'), { + uid: 'charlie', + clubId: 'club_counter_attack', + status: 'active', + role: 'member', + }); + jumpBatch.update(doc(charlie, 'clubs/club_counter_attack'), { + memberCount: 7, + updatedAt: new Date(), + lastMembershipChangeUid: 'charlie', + }); + await assertFails(jumpBatch.commit()); + + // 6. Arbitrary decrement (-5) or decrement below 1 -> DENY + const dropBatch = writeBatch(bob); + dropBatch.update(doc(bob, 'clubs/club_counter_attack/members/bob'), { + status: 'left', + }); + dropBatch.update(doc(bob, 'clubs/club_counter_attack'), { + memberCount: 0, + updatedAt: new Date(), + lastMembershipChangeUid: 'bob', + }); + await assertFails(dropBatch.commit()); + }); + + test('Ownership transfer and archive invariants: memberCount and slug preserved', async () => { + const alice = testEnv.authenticatedContext('alice').firestore(); + const bob = testEnv.authenticatedContext('bob').firestore(); + + await testEnv.withSecurityRulesDisabled(async (context) => { + const db = context.firestore(); + await setDoc(doc(db, 'clubs/club_preserve_test'), { + id: 'club_preserve_test', + name: 'Preserve MC', + slug: 'preserve-mc', + visibility: 'public', + admissionPolicy: 'open', + ownerUid: 'alice', + memberCount: 2, + }); + await setDoc(doc(db, 'clubs/club_preserve_test/members/alice'), { + uid: 'alice', + role: 'owner', + status: 'active', + }); + await setDoc(doc(db, 'clubs/club_preserve_test/members/bob'), { + uid: 'bob', + role: 'admin', + status: 'active', + }); + }); + + // 1. Ownership transfer attempting to alter memberCount -> DENY + const transferCountAttack = writeBatch(alice); + transferCountAttack.update(doc(alice, 'clubs/club_preserve_test'), { + ownerUid: 'bob', + memberCount: 99, + updatedAt: new Date(), + }); + transferCountAttack.update(doc(alice, 'clubs/club_preserve_test/members/alice'), { role: 'admin' }); + transferCountAttack.update(doc(alice, 'clubs/club_preserve_test/members/bob'), { role: 'owner' }); + await assertFails(transferCountAttack.commit()); + + // 2. Ownership transfer attempting to mutate slug -> DENY + const transferSlugAttack = writeBatch(alice); + transferSlugAttack.update(doc(alice, 'clubs/club_preserve_test'), { + ownerUid: 'bob', + slug: 'altered-slug', + updatedAt: new Date(), + }); + transferSlugAttack.update(doc(alice, 'clubs/club_preserve_test/members/alice'), { role: 'admin' }); + transferSlugAttack.update(doc(alice, 'clubs/club_preserve_test/members/bob'), { role: 'owner' }); + await assertFails(transferSlugAttack.commit()); + + // 3. Valid archive by owner -> ALLOW + await assertSucceeds(updateDoc(doc(alice, 'clubs/club_preserve_test'), { + archivedAt: new Date(), + updatedAt: new Date(), + })); + + // 4. Archive attempting to alter memberCount -> DENY + await assertFails(updateDoc(doc(alice, 'clubs/club_preserve_test'), { + archivedAt: new Date(), + memberCount: 50, + updatedAt: new Date(), + })); + + // 5. Archive attempting to mutate slug -> DENY + await assertFails(updateDoc(doc(alice, 'clubs/club_preserve_test'), { + archivedAt: new Date(), + slug: 'archived-preserve-mc', + updatedAt: new Date(), + })); + + // 6. Admin Bob attempts to archive -> DENY (only owner may archive) + await assertFails(updateDoc(doc(bob, 'clubs/club_preserve_test'), { + archivedAt: new Date(), + updatedAt: new Date(), + })); + }); + }); }); diff --git a/firestore.indexes.json b/firestore.indexes.json index 765a81c..6e89c40 100644 --- a/firestore.indexes.json +++ b/firestore.indexes.json @@ -27,6 +27,88 @@ "order": "ASCENDING" } ] + }, + { + "collectionGroup": "clubs", + "queryScope": "COLLECTION", + "fields": [ + { + "fieldPath": "visibility", + "order": "ASCENDING" + }, + { + "fieldPath": "archivedAt", + "order": "ASCENDING" + }, + { + "fieldPath": "memberCount", + "order": "DESCENDING" + } + ] + }, + { + "collectionGroup": "members", + "queryScope": "COLLECTION", + "fields": [ + { + "fieldPath": "status", + "order": "ASCENDING" + }, + { + "fieldPath": "joinedAt", + "order": "ASCENDING" + } + ] + }, + { + "collectionGroup": "members", + "queryScope": "COLLECTION", + "fields": [ + { + "fieldPath": "status", + "order": "ASCENDING" + }, + { + "fieldPath": "requestedAt", + "order": "ASCENDING" + } + ] + }, + { + "collectionGroup": "members", + "queryScope": "COLLECTION_GROUP", + "fields": [ + { + "fieldPath": "uid", + "order": "ASCENDING" + }, + { + "fieldPath": "status", + "order": "ASCENDING" + }, + { + "fieldPath": "joinedAt", + "order": "DESCENDING" + } + ] + }, + { + "collectionGroup": "members", + "queryScope": "COLLECTION_GROUP", + "fields": [ + { + "fieldPath": "uid", + "order": "ASCENDING" + }, + { + "fieldPath": "status", + "order": "ASCENDING" + }, + { + "fieldPath": "updatedAt", + "order": "DESCENDING" + } + ] } ], "fieldOverrides": [] diff --git a/firestore.rules b/firestore.rules index c81a6de..cf9e4bb 100644 --- a/firestore.rules +++ b/firestore.rules @@ -5,6 +5,13 @@ service cloud.firestore { return request.auth != null; } + function isRegisteredUser() { + return signedIn() + && (!('firebase' in request.auth.token) + || !('sign_in_provider' in request.auth.token.firebase) + || request.auth.token.firebase.sign_in_provider != 'anonymous'); + } + function ownsUser(userId) { return signedIn() && request.auth.uid == userId; } @@ -637,6 +644,285 @@ service cloud.firestore { allow delete: if isRouteOwner(); } + // --- CLUB HANDLE RESERVATIONS --- + match /club_handles/{slug} { + allow list: if false; + allow get: if isRegisteredUser(); + allow create: if isRegisteredUser() + && request.resource.data.slug == slug + && request.resource.data.ownerUid == request.auth.uid + && request.resource.data.clubId is string + && request.resource.data.clubId.size() > 0 + // Post-write club document invariant + && getAfter(/databases/$(database)/documents/clubs/$(request.resource.data.clubId)).data.slug == slug + && getAfter(/databases/$(database)/documents/clubs/$(request.resource.data.clubId)).data.ownerUid == request.auth.uid + && getAfter(/databases/$(database)/documents/clubs/$(request.resource.data.clubId)).data.memberCount == 1 + && getAfter(/databases/$(database)/documents/clubs/$(request.resource.data.clubId)).data.archivedAt == null + // Post-write owner membership invariant + && getAfter(/databases/$(database)/documents/clubs/$(request.resource.data.clubId)/members/$(request.auth.uid)).data.uid == request.auth.uid + && getAfter(/databases/$(database)/documents/clubs/$(request.resource.data.clubId)/members/$(request.auth.uid)).data.clubId == request.resource.data.clubId + && getAfter(/databases/$(database)/documents/clubs/$(request.resource.data.clubId)/members/$(request.auth.uid)).data.status == 'active' + && getAfter(/databases/$(database)/documents/clubs/$(request.resource.data.clubId)/members/$(request.auth.uid)).data.role == 'owner'; + allow delete: if false; + allow update: if false; + } + + // --- CLUBS & MEMBERSHIP --- + match /clubs/{clubId} { + function getClubDoc() { + return get(/databases/$(database)/documents/clubs/$(clubId)); + } + + function isCallerClubOwner() { + return isRegisteredUser() && getClubDoc().data.ownerUid == request.auth.uid; + } + + function hasMemberDoc(uid) { + return exists(/databases/$(database)/documents/clubs/$(clubId)/members/$(uid)); + } + + function memberData(uid) { + return get(/databases/$(database)/documents/clubs/$(clubId)/members/$(uid)).data; + } + + function isActiveMember(uid) { + return isRegisteredUser() && hasMemberDoc(uid) && memberData(uid).status == 'active'; + } + + function isPendingInvite(uid) { + return isRegisteredUser() && hasMemberDoc(uid) && memberData(uid).status == 'invited'; + } + + function isClubAdmin(uid) { + return isActiveMember(uid) && (memberData(uid).role == 'admin' || memberData(uid).role == 'owner'); + } + + function isBanned(uid) { + return exists(/databases/$(database)/documents/clubs/$(clubId)/bans/$(uid)); + } + + function isValidMemberCountIncrement() { + let targetUid = ('lastMembershipChangeUid' in request.resource.data) + ? request.resource.data.lastMembershipChangeUid + : request.auth.uid; + let memberDocPath = /databases/$(database)/documents/clubs/$(clubId)/members/$(targetUid); + return (targetUid == request.auth.uid || isClubAdmin(request.auth.uid)) + && request.resource.data.memberCount == resource.data.memberCount + 1 + && getAfter(memberDocPath).data.status == 'active' + && (!exists(memberDocPath) || get(memberDocPath).data.status != 'active'); + } + + function isValidMemberCountDecrement() { + let targetUid = ('lastMembershipChangeUid' in request.resource.data) + ? request.resource.data.lastMembershipChangeUid + : request.auth.uid; + let memberDocPath = /databases/$(database)/documents/clubs/$(clubId)/members/$(targetUid); + return (targetUid == request.auth.uid || isClubAdmin(request.auth.uid)) + && request.resource.data.memberCount == resource.data.memberCount - 1 + && request.resource.data.memberCount >= 1 + && getAfter(memberDocPath).data.status in ['left', 'removed'] + && exists(memberDocPath) + && get(memberDocPath).data.status == 'active'; + } + + // Point-read access to club root: + // Active members, owner, or invited riders evaluating an invite may read private clubs. + // Unlisted and public clubs can be point-read by any registered rider. + allow get: if isRegisteredUser() && ( + resource.data.visibility in ['public', 'unlisted'] || + resource.data.ownerUid == request.auth.uid || + isActiveMember(request.auth.uid) || + isPendingInvite(request.auth.uid) + ); + + // Discovery list query: Safe public discovery or owner's owned clubs query only + allow list: if isRegisteredUser() && ( + (resource.data.visibility == 'public' && resource.data.archivedAt == null) || + (resource.data.ownerUid == request.auth.uid && resource.data.archivedAt == null) + ); + + // Club creation: Atomic batch/transaction creating club + owner membership + slug reservation + allow create: if isRegisteredUser() + && request.resource.data.id == clubId + && request.resource.data.ownerUid == request.auth.uid + && request.resource.data.memberCount == 1 + && request.resource.data.archivedAt == null + && request.resource.data.slug is string + && request.resource.data.slug.size() >= 3 + && request.resource.data.slug.size() <= 30 + && getAfter(/databases/$(database)/documents/clubs/$(clubId)/members/$(request.auth.uid)).data.role == 'owner' + && getAfter(/databases/$(database)/documents/clubs/$(clubId)/members/$(request.auth.uid)).data.status == 'active' + && getAfter(/databases/$(database)/documents/clubs/$(clubId)/members/$(request.auth.uid)).data.clubId == clubId + && getAfter(/databases/$(database)/documents/club_handles/$(request.resource.data.slug)).data.clubId == clubId + && getAfter(/databases/$(database)/documents/club_handles/$(request.resource.data.slug)).data.ownerUid == request.auth.uid + && getAfter(/databases/$(database)/documents/club_handles/$(request.resource.data.slug)).data.slug == request.resource.data.slug; + + // Club update: Ownership transfer, metadata edit, or member count change + allow update: if isRegisteredUser() && ( + // Case 1: Owner transferring ownership atomically (cannot mutate memberCount, slug, createdAt) + ( + request.auth.uid == resource.data.ownerUid + && request.resource.data.diff(resource.data).affectedKeys().hasOnly(['ownerUid', 'updatedAt']) + && request.resource.data.ownerUid != resource.data.ownerUid + && getAfter(/databases/$(database)/documents/clubs/$(clubId)/members/$(request.resource.data.ownerUid)).data.role == 'owner' + && getAfter(/databases/$(database)/documents/clubs/$(clubId)/members/$(request.resource.data.ownerUid)).data.status == 'active' + && getAfter(/databases/$(database)/documents/clubs/$(clubId)/members/$(resource.data.ownerUid)).data.role != 'owner' + ) || + // Case 2: Owner general metadata update or archiving (slug, ownerUid, memberCount, createdAt immutable) + ( + request.auth.uid == resource.data.ownerUid + && request.resource.data.diff(resource.data).affectedKeys().hasOnly([ + 'name', 'description', 'visibility', 'admissionPolicy', + 'logoUrl', 'bannerUrl', 'homeRegion', 'motorcycleFocus', + 'archivedAt', 'updatedAt' + ]) + && (!('name' in request.resource.data) || (request.resource.data.name is string && request.resource.data.name.size() >= 3 && request.resource.data.name.size() <= 50)) + ) || + // Case 3: Admin editing metadata (cannot change ownerUid, slug, createdAt, archivedAt, visibility, admissionPolicy, memberCount) + ( + isClubAdmin(request.auth.uid) + && request.resource.data.diff(resource.data).affectedKeys().hasOnly([ + 'name', 'description', 'logoUrl', 'bannerUrl', + 'homeRegion', 'motorcycleFocus', 'updatedAt' + ]) + && (!('name' in request.resource.data) || (request.resource.data.name is string && request.resource.data.name.size() >= 3 && request.resource.data.name.size() <= 50)) + ) || + // Case 4: Member count increment/decrement by membership transitions ONLY + ( + request.resource.data.diff(resource.data).affectedKeys().hasOnly(['memberCount', 'updatedAt', 'lastMembershipChangeUid']) + && (isValidMemberCountIncrement() || isValidMemberCountDecrement()) + ) + ); + + allow delete: if false; + + // --- MEMBERS SUBCOLLECTION --- + match /members/{memberUid} { + // Point read: Caller themselves, club management, or peer active member (if not mutually blocked) + allow get: if isRegisteredUser() && ( + request.auth.uid == memberUid || + isCallerClubOwner() || + isClubAdmin(request.auth.uid) || + (isActiveMember(request.auth.uid) && resource.data.status == 'active' && !areMutuallyBlocked(request.auth.uid, memberUid)) + ); + + // List roster: Active members or club management only + allow list: if isRegisteredUser() && ( + isCallerClubOwner() || + isClubAdmin(request.auth.uid) || + (isActiveMember(request.auth.uid) && resource.data.status == 'active') + ); + + // Create membership doc: Owner creation, open join, request to join, or admin invitation + allow create: if isRegisteredUser() && !isBanned(memberUid) && ( + // Owner creating self doc during club creation + ( + request.auth.uid == memberUid + && request.resource.data.role == 'owner' + && request.resource.data.status == 'active' + && request.resource.data.clubId == clubId + && getAfter(/databases/$(database)/documents/clubs/$(clubId)).data.ownerUid == request.auth.uid + && getAfter(/databases/$(database)/documents/clubs/$(clubId)).data.memberCount == 1 + && getAfter(/databases/$(database)/documents/clubs/$(clubId)).data.archivedAt == null + && getAfter(/databases/$(database)/documents/club_handles/$(getAfter(/databases/$(database)/documents/clubs/$(clubId)).data.slug)).data.clubId == clubId + && getAfter(/databases/$(database)/documents/club_handles/$(getAfter(/databases/$(database)/documents/clubs/$(clubId)).data.slug)).data.ownerUid == request.auth.uid + ) || + // Rider joining open club + ( + request.auth.uid == memberUid + && get(/databases/$(database)/documents/clubs/$(clubId)).data.admissionPolicy == 'open' + && request.resource.data.role == 'member' + && request.resource.data.status == 'active' + && !areMutuallyBlocked(request.auth.uid, get(/databases/$(database)/documents/clubs/$(clubId)).data.ownerUid) + && getAfter(/databases/$(database)/documents/clubs/$(clubId)).data.memberCount == get(/databases/$(database)/documents/clubs/$(clubId)).data.memberCount + 1 + ) || + // Rider requesting to join + ( + request.auth.uid == memberUid + && get(/databases/$(database)/documents/clubs/$(clubId)).data.admissionPolicy == 'request' + && request.resource.data.role == 'member' + && request.resource.data.status == 'requested' + && !areMutuallyBlocked(request.auth.uid, get(/databases/$(database)/documents/clubs/$(clubId)).data.ownerUid) + ) || + // Admin / Owner inviting rider + ( + isClubAdmin(request.auth.uid) + && request.resource.data.status == 'invited' + && request.resource.data.role == 'member' + && !areMutuallyBlocked(request.auth.uid, memberUid) + ) + ); + + // Transitions + allow update: if isRegisteredUser() && ( + // Invitee accepts invite (invited -> active) + ( + request.auth.uid == memberUid + && resource.data.status == 'invited' + && request.resource.data.status == 'active' + && request.resource.data.role == resource.data.role + && !isBanned(memberUid) + && getAfter(/databases/$(database)/documents/clubs/$(clubId)).data.memberCount == get(/databases/$(database)/documents/clubs/$(clubId)).data.memberCount + 1 + ) || + // Invitee declines invite (invited -> declined) + (request.auth.uid == memberUid && resource.data.status == 'invited' && request.resource.data.status == 'declined' && request.resource.data.role == resource.data.role) || + // Member leaves (active -> left), non-owner only + ( + request.auth.uid == memberUid + && resource.data.status == 'active' + && request.resource.data.status == 'left' + && resource.data.role != 'owner' + && getAfter(/databases/$(database)/documents/clubs/$(clubId)).data.memberCount == get(/databases/$(database)/documents/clubs/$(clubId)).data.memberCount - 1 + ) || + // Requestor cancels request (requested -> left) + (request.auth.uid == memberUid && resource.data.status == 'requested' && request.resource.data.status == 'left') || + // Admin/Owner approves request (requested -> active) + ( + isClubAdmin(request.auth.uid) + && resource.data.status == 'requested' + && request.resource.data.status == 'active' + && getAfter(/databases/$(database)/documents/clubs/$(clubId)).data.memberCount == get(/databases/$(database)/documents/clubs/$(clubId)).data.memberCount + 1 + ) || + // Admin/Owner declines request (requested -> declined) + (isClubAdmin(request.auth.uid) && resource.data.status == 'requested' && request.resource.data.status == 'declined') || + // Admin/Owner removes active member (active -> removed), hierarchy enforced + ( + isClubAdmin(request.auth.uid) + && resource.data.status == 'active' + && request.resource.data.status == 'removed' + && resource.data.role != 'owner' + && (isCallerClubOwner() || resource.data.role != 'admin') + && getAfter(/databases/$(database)/documents/clubs/$(clubId)).data.memberCount == get(/databases/$(database)/documents/clubs/$(clubId)).data.memberCount - 1 + ) || + // Admin/Owner removes non-active member (requested/invited -> removed) + ( + isClubAdmin(request.auth.uid) + && resource.data.status != 'active' + && request.resource.data.status == 'removed' + && resource.data.role != 'owner' + && (isCallerClubOwner() || resource.data.role != 'admin') + ) || + // Owner updates role (role update or ownership transfer) + (isCallerClubOwner() && request.resource.data.status == resource.data.status) || + // Display rank update by admin/owner + (isClubAdmin(request.auth.uid) && request.resource.data.diff(resource.data).affectedKeys().hasOnly(['displayRank', 'updatedAt'])) + ); + + allow delete: if false; + } + + // --- ADMINISTRATIVE BANS SUBCOLLECTION --- + match /bans/{bannedUid} { + allow read, write: if isRegisteredUser() && isClubAdmin(request.auth.uid); + } + } + + // --- COLLECTION GROUP: MEMBERS --- + // Scoped strictly so caller can list only documents belonging to their own UID. + match /{path=**}/members/{memberUid} { + allow list: if isRegisteredUser() && resource.data.uid == request.auth.uid; + } + match /{document=**} { allow read, write: if false; } diff --git a/lib/services/clubs/club_models.dart b/lib/services/clubs/club_models.dart new file mode 100644 index 0000000..d5ff6c7 --- /dev/null +++ b/lib/services/clubs/club_models.dart @@ -0,0 +1,607 @@ +import 'package:cloud_firestore/cloud_firestore.dart'; +import 'package:flutter/foundation.dart'; + +/// Visibility setting governing discoverability and access to a Club. +enum ClubVisibility { + public, + unlisted, + private; + + String get label { + switch (this) { + case ClubVisibility.public: + return 'Public'; + case ClubVisibility.unlisted: + return 'Unlisted'; + case ClubVisibility.private: + return 'Private'; + } + } + + static ClubVisibility fromString(String? value) { + switch (value) { + case 'unlisted': + return ClubVisibility.unlisted; + case 'private': + return ClubVisibility.private; + case 'public': + default: + return ClubVisibility.public; + } + } +} + +/// Admission policy governing onboarding into a Club. +enum ClubAdmissionPolicy { + open, + request, + inviteOnly; + + String get label { + switch (this) { + case ClubAdmissionPolicy.open: + return 'Open to Join'; + case ClubAdmissionPolicy.request: + return 'Request to Join'; + case ClubAdmissionPolicy.inviteOnly: + return 'Invite Only'; + } + } + + static ClubAdmissionPolicy fromString(String? value) { + switch (value) { + case 'open': + return ClubAdmissionPolicy.open; + case 'inviteOnly': + return ClubAdmissionPolicy.inviteOnly; + case 'request': + default: + return ClubAdmissionPolicy.request; + } + } +} + +/// Security permission role within a Club. +enum ClubRole { + owner, + admin, + member; + + String get label { + switch (this) { + case ClubRole.owner: + return 'Owner'; + case ClubRole.admin: + return 'Admin'; + case ClubRole.member: + return 'Member'; + } + } + + bool isAtLeast(ClubRole requiredRole) { + if (this == ClubRole.owner) return true; + if (this == ClubRole.admin) { + return requiredRole == ClubRole.admin || requiredRole == ClubRole.member; + } + return requiredRole == ClubRole.member; + } + + static ClubRole fromString(String? value) { + switch (value) { + case 'owner': + return ClubRole.owner; + case 'admin': + return ClubRole.admin; + case 'member': + default: + return ClubRole.member; + } + } +} + +/// Lifecycle status of an individual Club member. +enum ClubMemberStatus { + invited, + requested, + active, + declined, + left, + removed; + + String get label { + switch (this) { + case ClubMemberStatus.invited: + return 'Invited'; + case ClubMemberStatus.requested: + return 'Requested'; + case ClubMemberStatus.active: + return 'Active'; + case ClubMemberStatus.declined: + return 'Declined'; + case ClubMemberStatus.left: + return 'Left'; + case ClubMemberStatus.removed: + return 'Removed'; + } + } + + bool get isActive => this == ClubMemberStatus.active; + bool get isPending => this == ClubMemberStatus.invited || this == ClubMemberStatus.requested; + bool get isHistorical => this == ClubMemberStatus.declined || this == ClubMemberStatus.left || this == ClubMemberStatus.removed; + + static ClubMemberStatus fromString(String? value) { + switch (value) { + case 'invited': + return ClubMemberStatus.invited; + case 'requested': + return ClubMemberStatus.requested; + case 'active': + return ClubMemberStatus.active; + case 'declined': + return ClubMemberStatus.declined; + case 'left': + return ClubMemberStatus.left; + case 'removed': + return ClubMemberStatus.removed; + default: + return ClubMemberStatus.declined; + } + } +} + +DateTime? _parseDateTime(dynamic value) { + if (value == null) return null; + if (value is Timestamp) return value.toDate(); + if (value is DateTime) return value; + if (value is String) return DateTime.tryParse(value); + if (value is int) return DateTime.fromMillisecondsSinceEpoch(value); + return null; +} + +dynamic _dateTimeToFirestore(DateTime? dt) { + if (dt == null) return null; + return Timestamp.fromDate(dt); +} + +/// Core MotorSignal Club model. +@immutable +class Club { + final String id; + final String name; + final String slug; + final String description; + final ClubVisibility visibility; + final ClubAdmissionPolicy admissionPolicy; + final String ownerUid; + final int memberCount; + final DateTime createdAt; + final DateTime updatedAt; + final DateTime? archivedAt; + final String? logoUrl; + final String? bannerUrl; + final String? homeRegion; + final String? motorcycleFocus; + + const Club({ + required this.id, + required this.name, + required this.slug, + this.description = '', + this.visibility = ClubVisibility.public, + this.admissionPolicy = ClubAdmissionPolicy.request, + required this.ownerUid, + this.memberCount = 1, + required this.createdAt, + required this.updatedAt, + this.archivedAt, + this.logoUrl, + this.bannerUrl, + this.homeRegion, + this.motorcycleFocus, + }); + + bool get isArchived => archivedAt != null; + + Map toFirestore() => { + 'id': id, + 'name': name, + 'slug': slug, + 'description': description, + 'visibility': visibility.name, + 'admissionPolicy': admissionPolicy.name, + 'ownerUid': ownerUid, + 'memberCount': memberCount, + 'createdAt': _dateTimeToFirestore(createdAt), + 'updatedAt': _dateTimeToFirestore(updatedAt), + if (archivedAt != null) 'archivedAt': _dateTimeToFirestore(archivedAt), + if (logoUrl != null) 'logoUrl': logoUrl, + if (bannerUrl != null) 'bannerUrl': bannerUrl, + if (homeRegion != null) 'homeRegion': homeRegion, + if (motorcycleFocus != null) 'motorcycleFocus': motorcycleFocus, + }; + + Map toJson() => { + 'id': id, + 'name': name, + 'slug': slug, + 'description': description, + 'visibility': visibility.name, + 'admissionPolicy': admissionPolicy.name, + 'ownerUid': ownerUid, + 'memberCount': memberCount, + 'createdAt': createdAt.toIso8601String(), + 'updatedAt': updatedAt.toIso8601String(), + if (archivedAt != null) 'archivedAt': archivedAt!.toIso8601String(), + if (logoUrl != null) 'logoUrl': logoUrl, + if (bannerUrl != null) 'bannerUrl': bannerUrl, + if (homeRegion != null) 'homeRegion': homeRegion, + if (motorcycleFocus != null) 'motorcycleFocus': motorcycleFocus, + }; + + factory Club.fromFirestore(DocumentSnapshot doc) { + final data = doc.data() as Map? ?? {}; + return Club.fromJson(data, idOverride: doc.id); + } + + factory Club.fromJson(Map json, {String? idOverride}) { + final created = _parseDateTime(json['createdAt']) ?? DateTime.now(); + final updated = _parseDateTime(json['updatedAt']) ?? created; + final archived = _parseDateTime(json['archivedAt']); + + return Club( + id: idOverride ?? json['id'] as String? ?? '', + name: json['name'] as String? ?? '', + slug: (json['slug'] as String? ?? '').toLowerCase().trim(), + description: json['description'] as String? ?? '', + visibility: ClubVisibility.fromString(json['visibility'] as String?), + admissionPolicy: ClubAdmissionPolicy.fromString(json['admissionPolicy'] as String?), + ownerUid: json['ownerUid'] as String? ?? '', + memberCount: (json['memberCount'] as num?)?.toInt() ?? 1, + createdAt: created, + updatedAt: updated, + archivedAt: archived, + logoUrl: json['logoUrl'] as String?, + bannerUrl: json['bannerUrl'] as String?, + homeRegion: json['homeRegion'] as String?, + motorcycleFocus: json['motorcycleFocus'] as String?, + ); + } + + Club copyWith({ + String? id, + String? name, + String? slug, + String? description, + ClubVisibility? visibility, + ClubAdmissionPolicy? admissionPolicy, + String? ownerUid, + int? memberCount, + DateTime? createdAt, + DateTime? updatedAt, + DateTime? archivedAt, + String? logoUrl, + String? bannerUrl, + String? homeRegion, + String? motorcycleFocus, + bool clearArchivedAt = false, + }) { + return Club( + id: id ?? this.id, + name: name ?? this.name, + slug: slug ?? this.slug, + description: description ?? this.description, + visibility: visibility ?? this.visibility, + admissionPolicy: admissionPolicy ?? this.admissionPolicy, + ownerUid: ownerUid ?? this.ownerUid, + memberCount: memberCount ?? this.memberCount, + createdAt: createdAt ?? this.createdAt, + updatedAt: updatedAt ?? this.updatedAt, + archivedAt: clearArchivedAt ? null : (archivedAt ?? this.archivedAt), + logoUrl: logoUrl ?? this.logoUrl, + bannerUrl: bannerUrl ?? this.bannerUrl, + homeRegion: homeRegion ?? this.homeRegion, + motorcycleFocus: motorcycleFocus ?? this.motorcycleFocus, + ); + } + + @override + bool operator ==(Object other) => + identical(this, other) || + other is Club && + runtimeType == other.runtimeType && + id == other.id && + name == other.name && + slug == other.slug && + description == other.description && + visibility == other.visibility && + admissionPolicy == other.admissionPolicy && + ownerUid == other.ownerUid && + memberCount == other.memberCount && + createdAt == other.createdAt && + updatedAt == other.updatedAt && + archivedAt == other.archivedAt && + logoUrl == other.logoUrl && + bannerUrl == other.bannerUrl && + homeRegion == other.homeRegion && + motorcycleFocus == other.motorcycleFocus; + + @override + int get hashCode => + id.hashCode ^ + name.hashCode ^ + slug.hashCode ^ + description.hashCode ^ + visibility.hashCode ^ + admissionPolicy.hashCode ^ + ownerUid.hashCode ^ + memberCount.hashCode ^ + createdAt.hashCode ^ + updatedAt.hashCode ^ + archivedAt.hashCode ^ + logoUrl.hashCode ^ + bannerUrl.hashCode ^ + homeRegion.hashCode ^ + motorcycleFocus.hashCode; +} + +/// Lean ClubMember document maintaining organizational state. +/// Canonical rider identity remains in public_profiles/{uid}. +@immutable +class ClubMember { + final String uid; + final String clubId; + final ClubMemberStatus status; + final ClubRole role; + final String? displayRank; + final DateTime? joinedAt; + final DateTime? requestedAt; + final String? invitedByUid; + final DateTime updatedAt; + final DateTime? removedAt; + + const ClubMember({ + required this.uid, + required this.clubId, + required this.status, + required this.role, + this.displayRank, + this.joinedAt, + this.requestedAt, + this.invitedByUid, + required this.updatedAt, + this.removedAt, + }); + + bool get isActive => status == ClubMemberStatus.active; + bool get isOwner => role == ClubRole.owner && isActive; + bool get isAdmin => (role == ClubRole.admin || role == ClubRole.owner) && isActive; + + Map toFirestore() => { + 'uid': uid, + 'clubId': clubId, + 'status': status.name, + 'role': role.name, + if (displayRank != null) 'displayRank': displayRank, + if (joinedAt != null) 'joinedAt': _dateTimeToFirestore(joinedAt), + if (requestedAt != null) 'requestedAt': _dateTimeToFirestore(requestedAt), + if (invitedByUid != null) 'invitedByUid': invitedByUid, + 'updatedAt': _dateTimeToFirestore(updatedAt), + if (removedAt != null) 'removedAt': _dateTimeToFirestore(removedAt), + }; + + Map toJson() => { + 'uid': uid, + 'clubId': clubId, + 'status': status.name, + 'role': role.name, + if (displayRank != null) 'displayRank': displayRank, + if (joinedAt != null) 'joinedAt': joinedAt!.toIso8601String(), + if (requestedAt != null) 'requestedAt': requestedAt!.toIso8601String(), + if (invitedByUid != null) 'invitedByUid': invitedByUid, + 'updatedAt': updatedAt.toIso8601String(), + if (removedAt != null) 'removedAt': removedAt!.toIso8601String(), + }; + + factory ClubMember.fromFirestore(DocumentSnapshot doc) { + final data = doc.data() as Map? ?? {}; + return ClubMember.fromJson(data, uidOverride: doc.id); + } + + factory ClubMember.fromJson(Map json, {String? uidOverride}) { + final updated = _parseDateTime(json['updatedAt']) ?? DateTime.now(); + + return ClubMember( + uid: uidOverride ?? json['uid'] as String? ?? '', + clubId: json['clubId'] as String? ?? '', + status: ClubMemberStatus.fromString(json['status'] as String?), + role: ClubRole.fromString(json['role'] as String?), + displayRank: json['displayRank'] as String?, + joinedAt: _parseDateTime(json['joinedAt']), + requestedAt: _parseDateTime(json['requestedAt']), + invitedByUid: json['invitedByUid'] as String?, + updatedAt: updated, + removedAt: _parseDateTime(json['removedAt']), + ); + } + + ClubMember copyWith({ + String? uid, + String? clubId, + ClubMemberStatus? status, + ClubRole? role, + String? displayRank, + DateTime? joinedAt, + DateTime? requestedAt, + String? invitedByUid, + DateTime? updatedAt, + DateTime? removedAt, + bool clearDisplayRank = false, + }) { + return ClubMember( + uid: uid ?? this.uid, + clubId: clubId ?? this.clubId, + status: status ?? this.status, + role: role ?? this.role, + displayRank: clearDisplayRank ? null : (displayRank ?? this.displayRank), + joinedAt: joinedAt ?? this.joinedAt, + requestedAt: requestedAt ?? this.requestedAt, + invitedByUid: invitedByUid ?? this.invitedByUid, + updatedAt: updatedAt ?? this.updatedAt, + removedAt: removedAt ?? this.removedAt, + ); + } + + @override + bool operator ==(Object other) => + identical(this, other) || + other is ClubMember && + runtimeType == other.runtimeType && + uid == other.uid && + clubId == other.clubId && + status == other.status && + role == other.role && + displayRank == other.displayRank && + joinedAt == other.joinedAt && + requestedAt == other.requestedAt && + invitedByUid == other.invitedByUid && + updatedAt == other.updatedAt && + removedAt == other.removedAt; + + @override + int get hashCode => + uid.hashCode ^ + clubId.hashCode ^ + status.hashCode ^ + role.hashCode ^ + displayRank.hashCode ^ + joinedAt.hashCode ^ + requestedAt.hashCode ^ + invitedByUid.hashCode ^ + updatedAt.hashCode ^ + removedAt.hashCode; +} + +/// Administrative ban document stored at clubs/{clubId}/bans/{uid}. +/// Private to club leadership; never exposed on regular member roster. +@immutable +class ClubBan { + final String uid; + final String clubId; + final String bannedByUid; + final DateTime bannedAt; + final String? reason; + + const ClubBan({ + required this.uid, + required this.clubId, + required this.bannedByUid, + required this.bannedAt, + this.reason, + }); + + Map toFirestore() => { + 'uid': uid, + 'clubId': clubId, + 'bannedByUid': bannedByUid, + 'bannedAt': _dateTimeToFirestore(bannedAt), + if (reason != null) 'reason': reason, + }; + + Map toJson() => { + 'uid': uid, + 'clubId': clubId, + 'bannedByUid': bannedByUid, + 'bannedAt': bannedAt.toIso8601String(), + if (reason != null) 'reason': reason, + }; + + factory ClubBan.fromFirestore(DocumentSnapshot doc) { + final data = doc.data() as Map? ?? {}; + return ClubBan.fromJson(data, uidOverride: doc.id); + } + + factory ClubBan.fromJson(Map json, {String? uidOverride}) { + return ClubBan( + uid: uidOverride ?? json['uid'] as String? ?? '', + clubId: json['clubId'] as String? ?? '', + bannedByUid: json['bannedByUid'] as String? ?? '', + bannedAt: _parseDateTime(json['bannedAt']) ?? DateTime.now(), + reason: json['reason'] as String?, + ); + } + + @override + bool operator ==(Object other) => + identical(this, other) || + other is ClubBan && + runtimeType == other.runtimeType && + uid == other.uid && + clubId == other.clubId && + bannedByUid == other.bannedByUid && + bannedAt == other.bannedAt && + reason == other.reason; + + @override + int get hashCode => + uid.hashCode ^ + clubId.hashCode ^ + bannedByUid.hashCode ^ + bannedAt.hashCode ^ + reason.hashCode; +} + +/// Global slug reservation document at club_handles/{slug}. +@immutable +class ClubHandle { + final String slug; + final String clubId; + final String ownerUid; + final DateTime createdAt; + + const ClubHandle({ + required this.slug, + required this.clubId, + required this.ownerUid, + required this.createdAt, + }); + + Map toFirestore() => { + 'slug': slug, + 'clubId': clubId, + 'ownerUid': ownerUid, + 'createdAt': _dateTimeToFirestore(createdAt), + }; + + Map toJson() => { + 'slug': slug, + 'clubId': clubId, + 'ownerUid': ownerUid, + 'createdAt': createdAt.toIso8601String(), + }; + + factory ClubHandle.fromJson(Map json, {String? slugOverride}) { + return ClubHandle( + slug: slugOverride ?? json['slug'] as String? ?? '', + clubId: json['clubId'] as String? ?? '', + ownerUid: json['ownerUid'] as String? ?? '', + createdAt: _parseDateTime(json['createdAt']) ?? DateTime.now(), + ); + } + + @override + bool operator ==(Object other) => + identical(this, other) || + other is ClubHandle && + runtimeType == other.runtimeType && + slug == other.slug && + clubId == other.clubId && + ownerUid == other.ownerUid && + createdAt == other.createdAt; + + @override + int get hashCode => + slug.hashCode ^ + clubId.hashCode ^ + ownerUid.hashCode ^ + createdAt.hashCode; +} diff --git a/lib/services/clubs/club_providers.dart b/lib/services/clubs/club_providers.dart new file mode 100644 index 0000000..9174a12 --- /dev/null +++ b/lib/services/clubs/club_providers.dart @@ -0,0 +1,59 @@ +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import '../auth/auth_providers.dart'; +import 'club_models.dart'; +import 'club_service.dart'; + +/// Provider for ClubService. +final clubServiceProvider = Provider((ref) { + return ClubService(); +}); + +/// Stream of the active user's active Club memberships. +/// Gated to registered, non-anonymous users. +final myClubsProvider = StreamProvider>((ref) { + final user = ref.watch(currentUserProvider); + if (user == null || user.isAnonymous) { + return Stream.value(const []); + } + final service = ref.watch(clubServiceProvider); + return service.myActiveClubsStream(user.uid); +}); + +/// Stream of incoming club invitations for the active user. +/// Gated to registered, non-anonymous users. +final myClubInvitesProvider = StreamProvider>((ref) { + final user = ref.watch(currentUserProvider); + if (user == null || user.isAnonymous) { + return Stream.value(const []); + } + final service = ref.watch(clubServiceProvider); + return service.myInvitationsStream(user.uid); +}); + +/// Realtime stream of a specific Club's root document. +final clubDetailProvider = StreamProvider.family((ref, clubId) { + final service = ref.watch(clubServiceProvider); + return service.clubStream(clubId); +}); + +/// Realtime stream of the active caller's membership document within a specific Club. +final currentMemberProvider = StreamProvider.family((ref, clubId) { + final user = ref.watch(currentUserProvider); + if (user == null || user.isAnonymous) { + return Stream.value(null); + } + final service = ref.watch(clubServiceProvider); + return service.memberStream(clubId, user.uid); +}); + +/// Realtime stream of active members for a specific Club (paginated limit 50). +final clubMembersProvider = StreamProvider.family, String>((ref, clubId) { + final service = ref.watch(clubServiceProvider); + return service.activeMembersStream(clubId); +}); + +/// Realtime stream of pending join requests for a specific Club (leadership only). +final pendingClubRequestsProvider = StreamProvider.family, String>((ref, clubId) { + final service = ref.watch(clubServiceProvider); + return service.pendingRequestsStream(clubId); +}); diff --git a/lib/services/clubs/club_service.dart b/lib/services/clubs/club_service.dart new file mode 100644 index 0000000..1fc4f95 --- /dev/null +++ b/lib/services/clubs/club_service.dart @@ -0,0 +1,1021 @@ +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', + ); + } + + // 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 = { + '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; + final doc = await firestore.collection('clubs').doc(clubId).get(); + if (!doc.exists || doc.data() == null) return null; + return Club.fromFirestore(doc); + } + + /// 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); + }); + } + + /// 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()); + } +} diff --git a/lib/services/clubs/club_slug_validator.dart b/lib/services/clubs/club_slug_validator.dart new file mode 100644 index 0000000..3c21572 --- /dev/null +++ b/lib/services/clubs/club_slug_validator.dart @@ -0,0 +1,102 @@ +import '../social/handle_validator.dart'; + +/// Result of a Club slug validation check. +class ClubSlugValidationResult { + final bool isValid; + final String? error; + final String? normalizedSlug; + + const ClubSlugValidationResult._({ + required this.isValid, + this.error, + this.normalizedSlug, + }); + + factory ClubSlugValidationResult.valid(String normalized) => + ClubSlugValidationResult._(isValid: true, normalizedSlug: normalized); + + factory ClubSlugValidationResult.invalid(String error) => + ClubSlugValidationResult._(isValid: false, error: error); +} + +/// Normalization and validation logic for MotorSignal Club slugs. +class ClubSlugValidator { + static const int minLength = 3; + static const int maxLength = 30; + + static final RegExp _formatRegex = RegExp(r'^[a-z0-9][a-z0-9-]{1,28}[a-z0-9]$'); + + /// Reserved words specific to clubs, chapters, and platform organizations. + static const Set clubReservedWords = { + 'club', + 'clubs', + 'motorcycle', + 'motorcycles', + 'mc', + 'chapter', + 'chapters', + 'national', + 'international', + 'official', + 'rally', + 'groupride', + 'grouprides', + 'event', + 'events', + 'all', + 'global', + 'feed', + 'roster', + 'members', + 'settings', + 'admin', + }; + + /// Normalizes a raw slug: trims whitespace and lowercases. + static String normalize(String rawSlug) => rawSlug.trim().toLowerCase(); + + /// Validates a candidate slug format and checks against reserved words. + static ClubSlugValidationResult validate(String rawSlug) { + final normalized = normalize(rawSlug); + + if (normalized.isEmpty) { + return ClubSlugValidationResult.invalid('Club slug cannot be empty'); + } + + if (normalized.length < minLength) { + return ClubSlugValidationResult.invalid( + 'Club slug must be at least $minLength characters', + ); + } + + if (normalized.length > maxLength) { + return ClubSlugValidationResult.invalid( + 'Club slug cannot exceed $maxLength characters', + ); + } + + if (normalized.contains('--')) { + return ClubSlugValidationResult.invalid( + 'Club slug cannot contain consecutive hyphens', + ); + } + + if (!_formatRegex.hasMatch(normalized)) { + return ClubSlugValidationResult.invalid( + 'Club slug must start and end with a letter or number, and contain only lowercase letters, numbers, and hyphens', + ); + } + + // Check against standard platform handles (admin, support, motorsignal, etc.) + if (HandleValidator.reservedHandles.contains(normalized)) { + return ClubSlugValidationResult.invalid('This club slug is reserved'); + } + + // Check against club-specific reserved words + if (clubReservedWords.contains(normalized)) { + return ClubSlugValidationResult.invalid('This club slug is reserved'); + } + + return ClubSlugValidationResult.valid(normalized); + } +} diff --git a/test/club_models_test.dart b/test/club_models_test.dart new file mode 100644 index 0000000..978e5ff --- /dev/null +++ b/test/club_models_test.dart @@ -0,0 +1,247 @@ +import 'package:cloud_firestore/cloud_firestore.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:motorcycle_dash/services/clubs/club_models.dart'; + +void main() { + group('Club Enums', () { + test('ClubVisibility labels and parsing', () { + expect(ClubVisibility.public.label, equals('Public')); + expect(ClubVisibility.unlisted.label, equals('Unlisted')); + expect(ClubVisibility.private.label, equals('Private')); + + expect(ClubVisibility.fromString('public'), equals(ClubVisibility.public)); + expect(ClubVisibility.fromString('unlisted'), equals(ClubVisibility.unlisted)); + expect(ClubVisibility.fromString('private'), equals(ClubVisibility.private)); + expect(ClubVisibility.fromString('unknown'), equals(ClubVisibility.public)); + expect(ClubVisibility.fromString(null), equals(ClubVisibility.public)); + }); + + test('ClubAdmissionPolicy labels and parsing', () { + expect(ClubAdmissionPolicy.open.label, equals('Open to Join')); + expect(ClubAdmissionPolicy.request.label, equals('Request to Join')); + expect(ClubAdmissionPolicy.inviteOnly.label, equals('Invite Only')); + + expect(ClubAdmissionPolicy.fromString('open'), equals(ClubAdmissionPolicy.open)); + expect(ClubAdmissionPolicy.fromString('request'), equals(ClubAdmissionPolicy.request)); + expect(ClubAdmissionPolicy.fromString('inviteOnly'), equals(ClubAdmissionPolicy.inviteOnly)); + expect(ClubAdmissionPolicy.fromString('other'), equals(ClubAdmissionPolicy.request)); + expect(ClubAdmissionPolicy.fromString(null), equals(ClubAdmissionPolicy.request)); + }); + + test('ClubRole labels, rank hierarchy, and parsing', () { + expect(ClubRole.owner.label, equals('Owner')); + expect(ClubRole.admin.label, equals('Admin')); + expect(ClubRole.member.label, equals('Member')); + + // Hierarchy + expect(ClubRole.owner.isAtLeast(ClubRole.owner), isTrue); + expect(ClubRole.owner.isAtLeast(ClubRole.admin), isTrue); + expect(ClubRole.owner.isAtLeast(ClubRole.member), isTrue); + + expect(ClubRole.admin.isAtLeast(ClubRole.owner), isFalse); + expect(ClubRole.admin.isAtLeast(ClubRole.admin), isTrue); + expect(ClubRole.admin.isAtLeast(ClubRole.member), isTrue); + + expect(ClubRole.member.isAtLeast(ClubRole.owner), isFalse); + expect(ClubRole.member.isAtLeast(ClubRole.admin), isFalse); + expect(ClubRole.member.isAtLeast(ClubRole.member), isTrue); + + expect(ClubRole.fromString('owner'), equals(ClubRole.owner)); + expect(ClubRole.fromString('admin'), equals(ClubRole.admin)); + expect(ClubRole.fromString('member'), equals(ClubRole.member)); + expect(ClubRole.fromString(null), equals(ClubRole.member)); + }); + + test('ClubMemberStatus classification and parsing', () { + expect(ClubMemberStatus.active.isActive, isTrue); + expect(ClubMemberStatus.invited.isActive, isFalse); + expect(ClubMemberStatus.requested.isActive, isFalse); + + expect(ClubMemberStatus.invited.isPending, isTrue); + expect(ClubMemberStatus.requested.isPending, isTrue); + expect(ClubMemberStatus.active.isPending, isFalse); + + expect(ClubMemberStatus.declined.isHistorical, isTrue); + expect(ClubMemberStatus.left.isHistorical, isTrue); + expect(ClubMemberStatus.removed.isHistorical, isTrue); + expect(ClubMemberStatus.active.isHistorical, isFalse); + + expect(ClubMemberStatus.fromString('active'), equals(ClubMemberStatus.active)); + expect(ClubMemberStatus.fromString('invited'), equals(ClubMemberStatus.invited)); + expect(ClubMemberStatus.fromString('requested'), equals(ClubMemberStatus.requested)); + expect(ClubMemberStatus.fromString('left'), equals(ClubMemberStatus.left)); + expect(ClubMemberStatus.fromString('removed'), equals(ClubMemberStatus.removed)); + expect(ClubMemberStatus.fromString('declined'), equals(ClubMemberStatus.declined)); + expect(ClubMemberStatus.fromString(null), equals(ClubMemberStatus.declined)); + }); + }); + + group('Club Model', () { + final now = DateTime(2026, 9, 6, 12, 0, 0); + + test('full serialization roundtrip (JSON and Firestore)', () { + final club = Club( + id: 'club_1', + name: 'Bay Area Sport Riders', + slug: 'bay-area-sport-riders', + description: 'Twisties and canyon carving', + visibility: ClubVisibility.public, + admissionPolicy: ClubAdmissionPolicy.request, + ownerUid: 'user_alice', + memberCount: 24, + createdAt: now, + updatedAt: now, + archivedAt: null, + logoUrl: 'https://example.com/logo.png', + bannerUrl: 'https://example.com/banner.png', + homeRegion: 'San Francisco Bay Area', + motorcycleFocus: 'Sport / Canyon', + ); + + final json = club.toJson(); + final fromJson = Club.fromJson(json); + + expect(fromJson.id, equals(club.id)); + expect(fromJson.name, equals(club.name)); + expect(fromJson.slug, equals(club.slug)); + expect(fromJson.description, equals(club.description)); + expect(fromJson.visibility, equals(club.visibility)); + expect(fromJson.admissionPolicy, equals(club.admissionPolicy)); + expect(fromJson.ownerUid, equals(club.ownerUid)); + expect(fromJson.memberCount, equals(club.memberCount)); + expect(fromJson.createdAt, equals(club.createdAt)); + expect(fromJson.updatedAt, equals(club.updatedAt)); + expect(fromJson.archivedAt, isNull); + expect(fromJson.isArchived, isFalse); + expect(fromJson.logoUrl, equals(club.logoUrl)); + expect(fromJson.bannerUrl, equals(club.bannerUrl)); + expect(fromJson.homeRegion, equals(club.homeRegion)); + expect(fromJson.motorcycleFocus, equals(club.motorcycleFocus)); + + // Firestore map serialization + final fsMap = club.toFirestore(); + expect(fsMap['createdAt'], isA()); + expect(fsMap['updatedAt'], isA()); + + // Deserializing from map with Timestamps + final fromFs = Club.fromJson(fsMap); + expect(fromFs.name, equals(club.name)); + expect(fromFs.createdAt, equals(club.createdAt)); + }); + + test('copyWith handles updates and null clearing', () { + final club = Club( + id: 'club_1', + name: 'Alpha Club', + slug: 'alpha-club', + ownerUid: 'alice', + createdAt: now, + updatedAt: now, + archivedAt: now, + ); + + expect(club.isArchived, isTrue); + + final unarchived = club.copyWith(clearArchivedAt: true); + expect(unarchived.archivedAt, isNull); + expect(unarchived.isArchived, isFalse); + + final updatedName = club.copyWith(name: 'Beta Club'); + expect(updatedName.name, equals('Beta Club')); + expect(updatedName.slug, equals('alpha-club')); + }); + }); + + group('ClubMember Model', () { + final now = DateTime(2026, 9, 6, 12, 0, 0); + + test('lean serialization roundtrip without duplicated rider profile identity', () { + final member = ClubMember( + uid: 'user_bob', + clubId: 'club_1', + status: ClubMemberStatus.active, + role: ClubRole.admin, + displayRank: 'Road Captain', + joinedAt: now, + requestedAt: now.subtract(const Duration(days: 1)), + invitedByUid: 'user_alice', + updatedAt: now, + ); + + expect(member.isActive, isTrue); + expect(member.isAdmin, isTrue); + expect(member.isOwner, isFalse); + + final json = member.toJson(); + // Ensure no identity duplication + expect(json.containsKey('riderHandleSnapshot'), isFalse); + expect(json.containsKey('riderDisplayNameSnapshot'), isFalse); + expect(json.containsKey('riderAvatarPreset'), isFalse); + expect(json.containsKey('riderAvatarUrl'), isFalse); + + final fromJson = ClubMember.fromJson(json); + expect(fromJson.uid, equals(member.uid)); + expect(fromJson.clubId, equals(member.clubId)); + expect(fromJson.status, equals(member.status)); + expect(fromJson.role, equals(member.role)); + expect(fromJson.displayRank, equals('Road Captain')); + expect(fromJson.invitedByUid, equals('user_alice')); + expect(fromJson.joinedAt, equals(member.joinedAt)); + }); + + test('copyWith handles displayRank clearing', () { + final member = ClubMember( + uid: 'user_bob', + clubId: 'club_1', + status: ClubMemberStatus.active, + role: ClubRole.member, + displayRank: 'Tail Gunner', + updatedAt: now, + ); + + final cleared = member.copyWith(clearDisplayRank: true); + expect(cleared.displayRank, isNull); + }); + }); + + group('ClubBan Model', () { + final now = DateTime(2026, 9, 6, 12, 0, 0); + + test('ban document serialization preserves reason', () { + final ban = ClubBan( + uid: 'troublemaker', + clubId: 'club_1', + bannedByUid: 'alice', + bannedAt: now, + reason: 'Violating group ride safety rules', + ); + + final json = ban.toJson(); + final fromJson = ClubBan.fromJson(json); + expect(fromJson.uid, equals('troublemaker')); + expect(fromJson.bannedByUid, equals('alice')); + expect(fromJson.bannedAt, equals(now)); + expect(fromJson.reason, equals('Violating group ride safety rules')); + }); + }); + + group('ClubHandle Model', () { + final now = DateTime(2026, 9, 6, 12, 0, 0); + + test('handle reservation document serialization', () { + final handle = ClubHandle( + slug: 'norcal-adv', + clubId: 'club_norcal', + ownerUid: 'alice', + createdAt: now, + ); + + final json = handle.toJson(); + final fromJson = ClubHandle.fromJson(json); + expect(fromJson.slug, equals('norcal-adv')); + expect(fromJson.clubId, equals('club_norcal')); + expect(fromJson.ownerUid, equals('alice')); + expect(fromJson.createdAt, equals(now)); + }); + }); +} diff --git a/test/club_service_test.dart b/test/club_service_test.dart new file mode 100644 index 0000000..e9b815d --- /dev/null +++ b/test/club_service_test.dart @@ -0,0 +1,139 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:motorcycle_dash/services/clubs/club_service.dart'; + +void main() { + group('ClubService Validation & Invariant Contracts', () { + test('reports availability correctly when offline', () { + final service = ClubService(); + expect(service.isAvailable, isFalse); + }); + + test('rejects club creation when unauthenticated or caller UID missing', () async { + final service = ClubService(); + + expect( + () => service.createClub( + name: 'Bay Area Riders', + slug: 'bay-area-riders', + ), + throwsA(isA().having( + (e) => e.code, + 'code', + equals('unauthenticated'), + )), + ); + }); + + test('rejects club creation with invalid name length', () async { + final service = ClubService(); + + // Too short (< 3) + expect( + () => service.createClub( + name: 'AB', + slug: 'valid-slug', + callerUid: 'alice', + ), + throwsA(isA().having( + (e) => e.code, + 'code', + equals('invalid-name'), + )), + ); + + // Too long (> 50) + expect( + () => service.createClub( + name: 'A' * 51, + slug: 'valid-slug', + callerUid: 'alice', + ), + throwsA(isA().having( + (e) => e.code, + 'code', + equals('invalid-name'), + )), + ); + }); + + test('rejects club creation with invalid slug format or reserved keywords', () async { + final service = ClubService(); + + // Invalid slug format (underscores) + expect( + () => service.createClub( + name: 'Valid Name', + slug: 'invalid_slug_with_underscores', + callerUid: 'alice', + ), + throwsA(isA().having( + (e) => e.code, + 'code', + equals('invalid-slug'), + )), + ); + + // Reserved slug + expect( + () => service.createClub( + name: 'Valid Name', + slug: 'admin', + callerUid: 'alice', + ), + throwsA(isA().having( + (e) => e.code, + 'code', + equals('invalid-slug'), + )), + ); + }); + + test('rejects ownership transfer to self', () async { + final service = ClubService(); + + expect( + () => service.transferOwnership( + 'club_1', + 'alice', + callerUid: 'alice', + ), + throwsA(isA().having( + (e) => e.code, + 'code', + equals('invalid-target'), + )), + ); + }); + + test('gracefully returns null/empty for streams when offline', () async { + final service = ClubService(); + + final club = await service.getClub('club_1'); + expect(club, isNull); + + final member = await service.getMember('club_1', 'alice'); + expect(member, isNull); + + final clubStream = service.clubStream('club_1'); + expect(await clubStream.first, isNull); + + final memberStream = service.memberStream('club_1', 'alice'); + expect(await memberStream.first, isNull); + + final activeStream = service.activeMembersStream('club_1'); + expect(await activeStream.first, isEmpty); + + final pendingStream = service.pendingRequestsStream('club_1'); + expect(await pendingStream.first, isEmpty); + + final myClubsStream = service.myActiveClubsStream('alice'); + expect(await myClubsStream.first, isEmpty); + + final myInvitesStream = service.myInvitationsStream('alice'); + expect(await myInvitesStream.first, isEmpty); + + final discovery = await service.discoverPublicClubs(); + expect(discovery, isEmpty); + }); + }); +} diff --git a/test/club_slug_validator_test.dart b/test/club_slug_validator_test.dart new file mode 100644 index 0000000..31b0cbf --- /dev/null +++ b/test/club_slug_validator_test.dart @@ -0,0 +1,107 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:motorcycle_dash/services/clubs/club_slug_validator.dart'; + +void main() { + group('ClubSlugValidator', () { + test('accepts valid slugs', () { + final validCases = [ + 'bay-area-riders', + 'sf-mc', + 'apex-hunters-99', + 'adv1200', + 'track-day-paddock', + 'moto-club-california', + 'canyon-carvers', + ]; + + for (final slug in validCases) { + final result = ClubSlugValidator.validate(slug); + expect(result.isValid, isTrue, reason: 'Expected $slug to be valid'); + expect(result.normalizedSlug, equals(slug)); + expect(result.error, isNull); + } + }); + + test('normalizes slugs with uppercase and whitespace', () { + final result = ClubSlugValidator.validate(' Bay-Area-Riders '); + expect(result.isValid, isTrue); + expect(result.normalizedSlug, equals('bay-area-riders')); + }); + + test('rejects empty or whitespace-only slug', () { + expect(ClubSlugValidator.validate('').isValid, isFalse); + expect(ClubSlugValidator.validate(' ').isValid, isFalse); + }); + + test('rejects slugs shorter than 3 characters', () { + final result = ClubSlugValidator.validate('ab'); + expect(result.isValid, isFalse); + expect(result.error, contains('at least 3 characters')); + }); + + test('rejects slugs longer than 30 characters', () { + final longSlug = 'a' * 31; + final result = ClubSlugValidator.validate(longSlug); + expect(result.isValid, isFalse); + expect(result.error, contains('cannot exceed 30 characters')); + }); + + test('rejects consecutive hyphens', () { + final result = ClubSlugValidator.validate('bay--area'); + expect(result.isValid, isFalse); + expect(result.error, contains('consecutive hyphens')); + }); + + test('rejects leading or trailing hyphens', () { + expect(ClubSlugValidator.validate('-bay-area').isValid, isFalse); + expect(ClubSlugValidator.validate('bay-area-').isValid, isFalse); + }); + + test('rejects underscores and special characters', () { + expect(ClubSlugValidator.validate('bay_area_mc').isValid, isFalse); + expect(ClubSlugValidator.validate('bay.area').isValid, isFalse); + expect(ClubSlugValidator.validate('bay@area').isValid, isFalse); + expect(ClubSlugValidator.validate('bay!area').isValid, isFalse); + }); + + test('rejects platform reserved keywords', () { + final reservedKeywords = [ + 'admin', + 'administrator', + 'motorsignal', + 'support', + 'system', + 'root', + 'api', + 'mod', + ]; + + for (final kw in reservedKeywords) { + final result = ClubSlugValidator.validate(kw); + expect(result.isValid, isFalse, reason: 'Expected $kw to be reserved'); + expect(result.error, contains('reserved')); + } + }); + + test('rejects club-specific reserved words', () { + final clubReserved = [ + 'club', + 'clubs', + 'motorcycle', + 'motorcycles', + 'chapter', + 'national', + 'official', + 'rally', + 'groupride', + 'event', + ]; + + for (final kw in clubReserved) { + final result = ClubSlugValidator.validate(kw); + expect(result.isValid, isFalse, reason: 'Expected $kw to be reserved'); + expect(result.error, contains('reserved')); + } + }); + }); +}