import { collection, doc, getDoc, getDocs, query, where, limit, collectionGroup, updateDoc, serverTimestamp, runTransaction, orderBy, documentId, startAfter } from 'firebase/firestore'; import { firebaseAuth, firestore } from './firebase-client'; import { Club, ClubMember, ClubUpcomingRide, ClubMetrics, UserPortalPermissions, ClubActivityItem, ClubRole, ClubVisibility, ClubAdmissionPolicy, } from '../components/founder-portal/types'; import { FIXTURE_FOUNDER_CLUB, FIXTURE_METRICS, FIXTURE_UPCOMING_RIDES, FIXTURE_PAST_RIDES, FIXTURE_PENDING_APPLICATIONS, } from './club-fixtures'; export interface ManageableClubSummary { id: string; name: string; slug?: string; role: ClubRole; displayRank?: string; memberCount: number; homeRegion?: string; logoUrl?: string | null; } export interface ClubApplication { uid: string; clubId: string; status: 'requested'; role: ClubRole; displayRank?: string | null; requestedAt: string; updatedAt: string; displayName: string; handle?: string | null; avatarUrl?: string | null; } /** * Calculates software permissions derived solely from Administrative Role. * Club Display Rank does NOT grant software administrative access. */ export function getPortalPermissions(role: ClubRole, isAuthenticated = true): UserPortalPermissions { if (!isAuthenticated) { return { isAuthenticated: false, role: 'member', canManageSettings: false, canInviteMembers: false, canApproveApplications: false, canPlanRides: false, canTransferOwnership: false, }; } const isOwner = role === 'owner'; const isAdmin = role === 'admin' || isOwner; return { isAuthenticated: true, role, canManageSettings: isAdmin, canInviteMembers: isAdmin, canApproveApplications: isAdmin, canPlanRides: isAdmin, canTransferOwnership: isOwner, }; } /** * Sanitizes backend errors into closed, user-safe messages. * Prevents raw exception leakage, Firestore paths, internal codes, or stack traces. */ export function sanitizePortalError(error: unknown, fallback = 'Something went wrong. Please try again.'): string { if (process.env.NODE_ENV !== 'production') { console.error('[FounderPortal Internal Debug]:', error); } if (!error) return fallback; const errString = String(error); if ( errString.includes('permission-denied') || errString.includes('unauthenticated') || errString.includes('PERMISSION_DENIED') || errString.includes('unauthorized') ) { return 'Club information is restricted or requires authentication.'; } if (errString.includes('not-found') || errString.includes('NOT_FOUND')) { return 'Club information could not be found.'; } if ( errString.includes('unavailable') || errString.includes('network') || errString.includes('DEADLINE_EXCEEDED') || errString.includes('UNAVAILABLE') ) { return "Club information couldn't be loaded. Check your network connection."; } // Any internal precondition or unhandled error falls back to generic safe text return fallback; } /** * Safely fetches club details from Firestore. * PRODUCTION RUNTIME NEVER SILENTLY FALLS BACK TO DEMO DATA. * Dev preview is only returned if explicitly opted into in development mode. */ export async function fetchClubData( clubIdOrSlug: string, callerUid?: string, options?: { allowDemoPreview?: boolean } ): Promise<{ club: Club; metrics: ClubMetrics; upcomingRides: ClubUpcomingRide[]; pendingApplications: ClubMember[]; activities: ClubActivityItem[]; userRole: ClubRole; userRank: string; }> { const normalized = (clubIdOrSlug || '').trim().toLowerCase(); // Explicit opt-in development preview fixture ONLY if (options?.allowDemoPreview && process.env.NODE_ENV === 'development') { return { club: FIXTURE_FOUNDER_CLUB, metrics: FIXTURE_METRICS, upcomingRides: FIXTURE_UPCOMING_RIDES, pendingApplications: FIXTURE_PENDING_APPLICATIONS, activities: [], userRole: 'owner', userRank: 'President', }; } if (!normalized) { throw new Error('not-found'); } try { let resolvedClubId = clubIdOrSlug; // Try finding slug in club_handles first try { const handleRef = doc(firestore, 'club_handles', normalized); const handleSnap = await getDoc(handleRef); if (handleSnap.exists()) { resolvedClubId = handleSnap.data().clubId || clubIdOrSlug; } } catch { // Slug lookup failed; proceed to query direct club document } const clubRef = doc(firestore, 'clubs', resolvedClubId); const clubSnap = await getDoc(clubRef); if (!clubSnap.exists()) { throw new Error('not-found'); } const d = clubSnap.data(); if (d.archivedAt) { throw new Error('not-found'); } // Authoritative Founder Identity Resolution: // Club.ownerUid establishes ownership identity only. // Canonical rider profile lookup: /public_profiles/{ownerUid} -> displayName, username. // Conservative fallback when unauthenticated, restricted, or private. // Never persist or expect a redundant 'founderName' field in /clubs/{clubId}. let founderName = d.ownerUid === callerUid ? 'You' : 'Club Founder'; let founderHandle: string | undefined = undefined; if (d.ownerUid) { try { const ownerProfileSnap = await getDoc(doc(firestore, 'public_profiles', d.ownerUid)); if (ownerProfileSnap.exists()) { const profileData = ownerProfileSnap.data(); if (callerUid !== d.ownerUid && profileData.displayName) { founderName = profileData.displayName; } if (profileData.username) { founderHandle = `@${profileData.username}`; } } } catch { // Point read restricted, blocked, unauthenticated, or unavailable: maintain conservative fallback } } const club: Club = { id: clubSnap.id, name: d.name || 'Unnamed Club', slug: d.slug || normalized, description: d.description || '', visibility: d.visibility || 'public', admissionPolicy: d.admissionPolicy || 'request', ownerUid: d.ownerUid || '', memberCount: typeof d.memberCount === 'number' ? d.memberCount : 1, createdAt: d.createdAt?.toDate?.() ? d.createdAt.toDate().toISOString() : (d.createdAt || new Date().toISOString()), updatedAt: d.updatedAt?.toDate?.() ? d.updatedAt.toDate().toISOString() : (d.updatedAt || new Date().toISOString()), archivedAt: d.archivedAt?.toDate?.() ? d.archivedAt.toDate().toISOString() : (d.archivedAt || null), logoUrl: d.logoUrl || null, bannerUrl: d.bannerUrl || null, homeRegion: d.homeRegion || 'Global', motorcycleFocus: d.motorcycleFocus || 'General', founderName, founderHandle, founderRank: 'Founder', foundedYear: d.createdAt?.toDate?.() ? String(d.createdAt.toDate().getFullYear()) : '2024', }; // Determine current user's membership role let userRole: ClubRole = 'member'; let userRank = 'Member'; if (callerUid) { if (callerUid === club.ownerUid) { userRole = 'owner'; userRank = 'President'; } else { try { const myMemberSnap = await getDoc(doc(firestore, 'clubs', resolvedClubId, 'members', callerUid)); if (myMemberSnap.exists()) { const mData = myMemberSnap.data(); if (mData.status === 'active') { userRole = (mData.role as ClubRole) || 'member'; userRank = mData.displayRank || (userRole === 'admin' ? 'Officer' : 'Member'); } } } catch { // Point read restricted: default to standard member } } } // Fetch pending applications ONLY if caller is verified owner or admin let pendingApps: ClubMember[] = []; if (userRole === 'owner' || userRole === 'admin') { try { const appsQuery = query( collection(firestore, 'clubs', resolvedClubId, 'members'), where('status', '==', 'requested'), limit(10) ); const appsSnap = await getDocs(appsQuery); pendingApps = appsSnap.docs.map((s) => { const ad = s.data(); return { uid: s.id, clubId: resolvedClubId, status: 'requested', role: 'member', displayName: ad.displayName || ad.handle || 'Applicant', handle: ad.handle ? `@${ad.handle}` : `@rider_${s.id.slice(0, 6)}`, motorcycle: ad.motorcycle || undefined, location: ad.location || undefined, note: ad.note || undefined, updatedAt: ad.updatedAt?.toDate?.() ? ad.updatedAt.toDate().toISOString() : new Date().toISOString(), requestedAt: ad.requestedAt?.toDate?.() ? ad.requestedAt.toDate().toISOString() : new Date().toISOString(), }; }); } catch { // Query not permitted or rules constrained: safely empty } } // Authoritative Upcoming Club Rides Query: // CANONICAL LINK: GroupRide.clubId == club.id // Strictly unaffiliated from organizer membership, participant lists, accessUids, or live presence. // PRIVACY INVARIANT: ClubMember != GroupRideParticipant (location sharing requires accepted ride participation). // ROUTE METRICS: Derived from MotorSignalRoute. Optional road duration/distance fallback to '--'. let upcomingRides: ClubUpcomingRide[] = []; try { const ridesQuery = query( collection(firestore, 'group_rides'), where('clubId', '==', resolvedClubId), limit(10) ); const ridesSnap = await getDocs(ridesQuery); upcomingRides = ridesSnap.docs.map((s) => { const rd = s.data(); return { id: s.id, rideIdentifier: rd.rideIdentifier || `RIDE-${s.id.slice(0, 6).toUpperCase()}`, title: rd.title || 'Untitled Club Ride', scheduledStartAt: rd.scheduledStartAt?.toDate?.() ? rd.scheduledStartAt.toDate().toLocaleDateString() : (rd.scheduledStartAt || 'TBD'), departureAt: rd.departureAt?.toDate?.() ? rd.departureAt.toDate().toLocaleDateString() : rd.departureAt, distanceMiles: typeof rd.routeDistanceMeters === 'number' ? Math.round(rd.routeDistanceMeters * 0.000621371) : null, durationString: null, // Road duration is optional/transient; '--' if unpopulated paceClassification: rd.paceClassification || 'Club Paceline', roadCaptainName: rd.organizerDisplayName || 'Ride Leader', roadCaptainHandle: rd.organizerHandle ? `@${rd.organizerHandle}` : undefined, roadCaptainMotorcycle: rd.organizerMotorcycle || null, sweepName: rd.sweepName || null, sweepStatus: rd.sweepStatus || 'vacant', confirmedCount: typeof rd.participantCount === 'number' ? rd.participantCount : 1, pendingCount: typeof rd.pendingCount === 'number' ? rd.pendingCount : 0, capacity: rd.participantLimit || null, briefingTime: rd.briefingTime || null, routeId: rd.routeId || null, }; }); } catch { // Group rides query restricted or index unready: safely empty } // Truthful metrics derived strictly from schema: // No fabricated deltas (+6), active percent (98%), or road marshal counts const metrics: ClubMetrics = { members: club.memberCount, membersActivePercent: null, membersDeltaMonth: null, roadMarshalsCount: null, pendingApplications: pendingApps.length, upcomingRidesCount: upcomingRides.length, confirmedRidersTotal: upcomingRides.reduce((sum, r) => sum + r.confirmedCount, 0), nextRideDate: upcomingRides[0]?.scheduledStartAt || null, clubMilesThisMonth: null, completedClubRidesCount: 0, clubMilesAvailable: false, }; return { club, metrics, upcomingRides, pendingApplications: pendingApps, activities: [], userRole, userRank, }; } catch (error) { throw new Error(sanitizePortalError(error, "Club information couldn't be loaded.")); } } /** * Authenticated Club Discovery: * Returns only clubs where caller is club owner (ownerUid == callerUid) * or active club admin (role == 'admin' in /clubs/{clubId}/members/{callerUid}). * Ordinary members NEVER see their clubs in the management list. */ export async function fetchManageableClubs(callerUid: string): Promise { if (!callerUid) return []; const clubMap = new Map(); // 1. Query clubs owned by user (satisfies firestore rules: ownerUid == auth.uid && archivedAt == null) try { const ownedQuery = query( collection(firestore, 'clubs'), where('ownerUid', '==', callerUid), where('archivedAt', '==', null) ); const ownedSnap = await getDocs(ownedQuery); for (const docSnap of ownedSnap.docs) { const d = docSnap.data(); clubMap.set(docSnap.id, { id: docSnap.id, name: d.name || 'Unnamed Club', slug: d.slug, role: 'owner', displayRank: 'President', memberCount: typeof d.memberCount === 'number' ? d.memberCount : 1, homeRegion: d.homeRegion || 'Global', logoUrl: d.logoUrl || null, }); } } catch (error) { if (process.env.NODE_ENV !== 'production') { console.error('[fetchManageableClubs ownedQuery Debug]:', error); } } // 2. Query memberships where user is memberUid and role is admin or owner try { const membersQuery = query( collectionGroup(firestore, 'members'), where('uid', '==', callerUid) ); const memberSnap = await getDocs(membersQuery); for (const mDoc of memberSnap.docs) { const mData = mDoc.data(); if (mData.status === 'active' && (mData.role === 'admin' || mData.role === 'owner')) { const clubId = mData.clubId || mDoc.ref.parent.parent?.id; if (clubId && !clubMap.has(clubId)) { try { const clubSnap = await getDoc(doc(firestore, 'clubs', clubId)); if (clubSnap.exists()) { const cd = clubSnap.data(); if (!cd.archivedAt) { clubMap.set(clubId, { id: clubSnap.id, name: cd.name || 'Unnamed Club', slug: cd.slug, role: mData.role === 'owner' ? 'owner' : 'admin', displayRank: mData.displayRank || (mData.role === 'owner' ? 'Founder' : 'Admin'), memberCount: typeof cd.memberCount === 'number' ? cd.memberCount : 1, homeRegion: cd.homeRegion || 'Global', logoUrl: cd.logoUrl || null, }); } } } catch { // Point read failure for club } } } } } catch (error) { if (process.env.NODE_ENV !== 'production') { console.error('[fetchManageableClubs memberQuery Debug]:', error); } } return Array.from(clubMap.values()); } /** * Isolated Context for Founder Portal Sub-Routes. * Enforces O(1) point-read query gate (zero collection queries). */ export interface FounderClubContext { club: Club; currentUser: { uid: string; role: ClubRole; displayRank?: string; }; ownerProfile: { displayName: string; username?: string; } | null; permissions: UserPortalPermissions; isDemoFixture: boolean; } /** * Executes strictly O(1) point-reads to resolve club context for Founder sub-routes. * Does NOT query group_rides, club_applications, or member rosters. */ export async function fetchFounderClubContext( clubIdOrSlug: string, callerUid?: string, options?: { allowDemoPreview?: boolean } ): Promise { const normalized = (clubIdOrSlug || '').trim().toLowerCase(); // Explicit opt-in development preview fixture ONLY if (options?.allowDemoPreview && process.env.NODE_ENV === 'development') { return { club: FIXTURE_FOUNDER_CLUB, currentUser: { uid: callerUid || FIXTURE_FOUNDER_CLUB.ownerUid, role: 'owner', displayRank: 'President', }, ownerProfile: { displayName: FIXTURE_FOUNDER_CLUB.founderName || 'Marcus Vance', username: 'marcus_vance', }, permissions: getPortalPermissions('owner', true), isDemoFixture: true, }; } if (!normalized) { throw new Error('not-found'); } let resolvedClubId = clubIdOrSlug; // 1. Point read: resolve slug via club_handles try { const handleRef = doc(firestore, 'club_handles', normalized); const handleSnap = await getDoc(handleRef); if (handleSnap.exists()) { resolvedClubId = handleSnap.data().clubId || clubIdOrSlug; } } catch { // Fallback to direct ID } // 2. Point read: clubs/{clubId} const clubRef = doc(firestore, 'clubs', resolvedClubId); const clubSnap = await getDoc(clubRef); if (!clubSnap.exists()) { throw new Error('not-found'); } const d = clubSnap.data(); if (d.archivedAt) { throw new Error('not-found'); } // 3. Point read: public_profiles/{ownerUid} let ownerProfile: { displayName: string; username?: string } | null = null; let founderName = d.ownerUid === callerUid ? 'You' : 'Club Founder'; let founderHandle: string | undefined = undefined; if (d.ownerUid) { try { const ownerSnap = await getDoc(doc(firestore, 'public_profiles', d.ownerUid)); if (ownerSnap.exists()) { const pd = ownerSnap.data(); if (pd.displayName) { founderName = callerUid === d.ownerUid ? 'You' : pd.displayName; ownerProfile = { displayName: pd.displayName, username: pd.username, }; } if (pd.username) { founderHandle = `@${pd.username}`; } } } catch { // Conservative fallback } } // 4. Resolve caller role & permissions let callerRole: ClubRole = 'member'; let callerRank = 'Member'; let isCallerAuth = Boolean(callerUid); if (callerUid) { if (callerUid === d.ownerUid) { callerRole = 'owner'; callerRank = 'President'; } else { try { const myMemberSnap = await getDoc(doc(firestore, 'clubs', resolvedClubId, 'members', callerUid)); if (myMemberSnap.exists()) { const mData = myMemberSnap.data(); if (mData.status === 'active') { callerRole = (mData.role === 'admin' || mData.role === 'owner') ? mData.role : 'member'; callerRank = mData.displayRank || (callerRole === 'admin' ? 'Officer' : 'Member'); } else { isCallerAuth = false; } } else { isCallerAuth = false; } } catch { isCallerAuth = false; } } } const club: Club = { id: clubSnap.id, name: d.name || 'Unnamed Club', slug: d.slug || normalized, description: d.description || '', visibility: d.visibility || 'public', admissionPolicy: d.admissionPolicy || 'request', ownerUid: d.ownerUid || '', memberCount: typeof d.memberCount === 'number' ? d.memberCount : 1, createdAt: d.createdAt?.toDate?.() ? d.createdAt.toDate().toISOString() : (d.createdAt || new Date().toISOString()), updatedAt: d.updatedAt?.toDate?.() ? d.updatedAt.toDate().toISOString() : (d.updatedAt || new Date().toISOString()), archivedAt: d.archivedAt?.toDate?.() ? d.archivedAt.toDate().toISOString() : (d.archivedAt || null), logoUrl: d.logoUrl || null, bannerUrl: d.bannerUrl || null, homeRegion: d.homeRegion || 'Global', motorcycleFocus: d.motorcycleFocus || 'General', founderName, founderHandle, founderRank: 'Founder', foundedYear: d.createdAt?.toDate?.() ? String(d.createdAt.toDate().getFullYear()) : '2024', }; const permissions = getPortalPermissions(callerRole, isCallerAuth); return { club, currentUser: { uid: callerUid || '', role: callerRole, displayRank: callerRank, }, ownerProfile, permissions, isDemoFixture: false, }; } export interface UpdateClubProfileInput { name: string; description: string; homeRegion: string; motorcycleFocus: string; logoUrl?: string; bannerUrl?: string; } /** * Shared image URL validation for club logo/banner persistence. * Accepts https:// URLs or blank values (clearing). Returns the trimmed * URL, or null when the field should be cleared. Never normalizes an * invalid URL into a valid one; throws on http://, data:, blob:, * javascript:, or malformed values. */ export function validateClubImageUrl(value: string, fieldLabel: string): string | null { const trimmed = (value ?? '').trim(); if (!trimmed) { return null; } let parsed: URL; try { parsed = new URL(trimmed); } catch { throw new Error(`${fieldLabel} must be a valid HTTPS URL.`); } if (parsed.protocol !== 'https:') { throw new Error(`${fieldLabel} must use a secure https:// URL.`); } return trimmed; } /** * Whitelist update of club public profile metadata. * Strictly verifies caller is owner or admin. * Forbids mutating slug, visibility, admissionPolicy, ownerUid, memberCount, archivedAt. */ export async function updateClubProfile( clubId: string, callerUid: string, input: UpdateClubProfileInput ): Promise { if (!callerUid) { throw new Error('Authentication required.'); } const name = (input.name ?? '').trim(); if (name.length < 3 || name.length > 50) { throw new Error('Club name must be between 3 and 50 characters.'); } const description = (input.description ?? '').trim(); if (description.length > 500) { throw new Error('Club description must not exceed 500 characters.'); } const homeRegion = (input.homeRegion ?? '').trim(); if (homeRegion.length > 100) { throw new Error('Home region must not exceed 100 characters.'); } const motorcycleFocus = (input.motorcycleFocus ?? '').trim(); if (motorcycleFocus.length > 100) { throw new Error('Motorcycle focus must not exceed 100 characters.'); } const logoUrl = input.logoUrl !== undefined ? validateClubImageUrl(input.logoUrl, 'Club logo URL') : undefined; const bannerUrl = input.bannerUrl !== undefined ? validateClubImageUrl(input.bannerUrl, 'Club banner URL') : undefined; const clubRef = doc(firestore, 'clubs', clubId); const clubSnap = await getDoc(clubRef); if (!clubSnap.exists()) { throw new Error('Club not found.'); } const cd = clubSnap.data(); if (cd.archivedAt) { throw new Error('Archived clubs cannot be modified.'); } let isAuthorized = cd.ownerUid === callerUid; if (!isAuthorized) { try { const memberSnap = await getDoc(doc(firestore, 'clubs', clubId, 'members', callerUid)); if (memberSnap.exists()) { const m = memberSnap.data(); if (m.status === 'active' && (m.role === 'owner' || m.role === 'admin')) { isAuthorized = true; } } } catch { // Unauthorized } } if (!isAuthorized) { throw new Error('Permission denied. Only club owners and administrators can edit club profile details.'); } // Strict whitelist update await updateDoc(clubRef, { name, description, homeRegion, motorcycleFocus, ...(logoUrl !== undefined ? { logoUrl } : {}), ...(bannerUrl !== undefined ? { bannerUrl } : {}), updatedAt: serverTimestamp(), }); } export interface UpdateClubAccessSettingsInput { visibility: ClubVisibility; admissionPolicy: ClubAdmissionPolicy; } /** * Whitelist update of club access controls (visibility and admissionPolicy). * Authorizes active Owner and active Admin. * Forbids mutating slug, ownerUid, memberCount, archivedAt, name, description, etc. */ export async function updateClubAccessSettings( clubId: string, callerUid: string, input: UpdateClubAccessSettingsInput ): Promise { if (!callerUid) { throw new Error('Authentication required.'); } const validVisibilities: ClubVisibility[] = ['public', 'unlisted', 'private']; const validAdmissions: ClubAdmissionPolicy[] = ['open', 'request', 'inviteOnly']; if (!validVisibilities.includes(input.visibility)) { throw new Error('Invalid club visibility setting.'); } if (!validAdmissions.includes(input.admissionPolicy)) { throw new Error('Invalid club admission policy setting.'); } const clubRef = doc(firestore, 'clubs', clubId); const clubSnap = await getDoc(clubRef); if (!clubSnap.exists()) { throw new Error('Club not found.'); } const cd = clubSnap.data(); if (cd.archivedAt) { throw new Error('Archived clubs cannot be modified.'); } let isAuthorized = cd.ownerUid === callerUid; if (!isAuthorized) { try { const memberSnap = await getDoc(doc(firestore, 'clubs', clubId, 'members', callerUid)); if (memberSnap.exists()) { const m = memberSnap.data(); if (m.status === 'active' && (m.role === 'owner' || m.role === 'admin')) { isAuthorized = true; } } } catch { // Unauthorized } } if (!isAuthorized) { throw new Error('Permission denied. Only club owners and administrators can modify club access settings.'); } await updateDoc(clubRef, { visibility: input.visibility, admissionPolicy: input.admissionPolicy, updatedAt: serverTimestamp(), }); } /** * Bounded query for active members eligible to receive club ownership. * Excludes the current owner. * Never performs N+1 profile queries; uses only legally readable member document fields. */ export async function fetchEligibleTransferMembers( clubId: string, callerUid: string, options?: { allowDemoPreview?: boolean } ): Promise { if (options?.allowDemoPreview && process.env.NODE_ENV === 'development') { return [ { uid: 'demo-rider-02', clubId, status: 'active', role: 'admin', displayRank: 'Vice President', updatedAt: new Date().toISOString(), displayName: 'Elena Rostova', handle: '@elena_rides', }, { uid: 'demo-rider-03', clubId, status: 'active', role: 'member', displayRank: 'Road Captain', updatedAt: new Date().toISOString(), displayName: 'Dave Miller', handle: '@dave_m', }, ]; } if (!callerUid) { throw new Error('Authentication required.'); } const membersRef = collection(firestore, 'clubs', clubId, 'members'); const q = query( membersRef, where('status', '==', 'active'), limit(25) ); const snap = await getDocs(q); const eligible: ClubMember[] = []; for (const d of snap.docs) { if (d.id === callerUid) continue; // Exclude current owner const data = d.data(); eligible.push({ uid: d.id, clubId, status: 'active', role: (data.role as ClubRole) || 'member', displayRank: data.displayRank || null, joinedAt: data.joinedAt?.toDate?.() ? data.joinedAt.toDate().toISOString() : (data.joinedAt || null), updatedAt: data.updatedAt?.toDate?.() ? data.updatedAt.toDate().toISOString() : (data.updatedAt || new Date().toISOString()), displayName: data.displayName || null, handle: data.handle ? `@${data.handle}` : null, }); } return eligible; } /** * Atomically transfers club ownership from current owner to an active member. * Strictly verifies caller is current owner and target is active member. * Caller is demoted to admin; target is promoted to owner. * Preserves atomic rules contract (Case 1). */ export async function transferClubOwnership( clubId: string, currentOwnerUid: string, newOwnerUid: string ): Promise { if (!currentOwnerUid) { throw new Error('Authentication required.'); } if (!newOwnerUid || currentOwnerUid === newOwnerUid) { throw new Error('Invalid ownership transfer target.'); } const clubRef = doc(firestore, 'clubs', clubId); const currentOwnerMemberRef = doc(firestore, 'clubs', clubId, 'members', currentOwnerUid); const newOwnerMemberRef = doc(firestore, 'clubs', clubId, 'members', newOwnerUid); await runTransaction(firestore, async (tx) => { const clubSnap = await tx.get(clubRef); if (!clubSnap.exists()) { throw new Error('Club not found.'); } const cd = clubSnap.data(); if (cd.archivedAt) { throw new Error('Archived clubs cannot transfer ownership.'); } if (cd.ownerUid !== currentOwnerUid) { throw new Error('Only the club owner can transfer ownership.'); } const newOwnerSnap = await tx.get(newOwnerMemberRef); if (!newOwnerSnap.exists()) { throw new Error('Target member does not exist in this club.'); } const md = newOwnerSnap.data(); if (md.status !== 'active') { throw new Error('Target rider must be an active club member to receive ownership.'); } const now = serverTimestamp(); // 1. Update club root ownerUid tx.update(clubRef, { ownerUid: newOwnerUid, updatedAt: now, }); // 2. Demote current owner to admin tx.update(currentOwnerMemberRef, { role: 'admin', updatedAt: now, }); // 3. Promote new owner to owner role tx.update(newOwnerMemberRef, { role: 'owner', updatedAt: now, }); }); } /** * Soft-deletes/archives a Club. Only permitted by the Owner. * Removes club from discovery queries while preserving all historical records. * Club deletion is NOT supported. */ export async function archiveClub( clubId: string, callerUid: string ): Promise { if (!callerUid) { throw new Error('Authentication required.'); } const clubRef = doc(firestore, 'clubs', clubId); const clubSnap = await getDoc(clubRef); if (!clubSnap.exists()) { throw new Error('Club not found.'); } const cd = clubSnap.data(); if (cd.archivedAt) { throw new Error('Club is already archived.'); } if (cd.ownerUid !== callerUid) { throw new Error('Permission denied. Only the club owner can archive the club.'); } const now = serverTimestamp(); await updateDoc(clubRef, { archivedAt: now, updatedAt: now, }); } /** * Fetches pending membership requests (status == 'requested') for a Club. * Authoritative source: /clubs/{clubId}/members where status == 'requested'. * Strictly bounded query (limit 25) with safe public profile resolution. * Owner and Admin only. */ export async function fetchClubApplications( clubId: string, callerUid?: string, options?: { allowDemoPreview?: boolean } ): Promise { // Explicit opt-in development preview fixture ONLY if (options?.allowDemoPreview && process.env.NODE_ENV === 'development') { return FIXTURE_PENDING_APPLICATIONS.map((app) => ({ uid: app.uid, clubId, status: 'requested' as const, role: (app.role as ClubRole) || 'member', displayRank: app.displayRank || null, requestedAt: app.requestedAt || new Date().toISOString(), updatedAt: app.updatedAt || new Date().toISOString(), displayName: app.displayName || 'Club Applicant', handle: app.handle || null, avatarUrl: app.avatarUrl || null, })); } if (!callerUid) { throw new Error('Authentication required.'); } // Caller authorization check: must be active owner or admin const callerMemberRef = doc(firestore, 'clubs', clubId, 'members', callerUid); const callerSnap = await getDoc(callerMemberRef); if (!callerSnap.exists()) { throw new Error('Caller not a member of this club.'); } const callerData = callerSnap.data(); if (callerData.status !== 'active') { throw new Error('Caller is not an active member.'); } if (callerData.role !== 'owner' && callerData.role !== 'admin') { throw new Error('Only club owners and administrators can review applications.'); } const membersRef = collection(firestore, 'clubs', clubId, 'members'); const q = query( membersRef, where('status', '==', 'requested'), limit(25) ); const snap = await getDocs(q); const applications: ClubApplication[] = []; for (const d of snap.docs) { const data = d.data(); let displayName = 'Club Applicant'; let handle: string | null = null; let avatarUrl: string | null = null; // Safe point read to public_profiles/{uid} try { const profileSnap = await getDoc(doc(firestore, 'public_profiles', d.id)); if (profileSnap.exists()) { const pd = profileSnap.data(); if (pd.displayName) displayName = pd.displayName; if (pd.username) handle = `@${pd.username}`; if (pd.profilePhotoUrl) avatarUrl = pd.profilePhotoUrl; } } catch { // Conservative fallback - zero private leakage } applications.push({ uid: d.id, clubId, status: 'requested', role: (data.role as ClubRole) || 'member', displayRank: data.displayRank || null, requestedAt: data.requestedAt?.toDate?.() ? data.requestedAt.toDate().toISOString() : (data.requestedAt || new Date().toISOString()), updatedAt: data.updatedAt?.toDate?.() ? data.updatedAt.toDate().toISOString() : (data.updatedAt || new Date().toISOString()), displayName, handle, avatarUrl, }); } // Sort newest first applications.sort((a, b) => new Date(b.requestedAt).getTime() - new Date(a.requestedAt).getTime()); return applications; } /** * Atomically approves a pending membership request. * Transitions requested -> active, sets default role 'member' and displayRank 'Member', * and increments club memberCount by 1 with lastMembershipChangeUid. * Strictly verifies caller is active owner or admin. */ export async function approveClubApplication( clubId: string, callerUid: string, targetUid: string ): Promise { if (!callerUid) { throw new Error('Authentication required.'); } if (!targetUid) { throw new Error('Target applicant UID required.'); } const clubRef = doc(firestore, 'clubs', clubId); const callerRef = doc(firestore, 'clubs', clubId, 'members', callerUid); const targetRef = doc(firestore, 'clubs', clubId, 'members', targetUid); const banRef = doc(firestore, 'clubs', clubId, 'bans', targetUid); await runTransaction(firestore, async (tx) => { const clubSnap = await tx.get(clubRef); if (!clubSnap.exists()) { throw new Error('Club not found.'); } const cd = clubSnap.data(); if (cd.archivedAt) { throw new Error('Archived clubs cannot admit new members.'); } const callerSnap = await tx.get(callerRef); if (!callerSnap.exists()) { throw new Error('Caller not a member of this club.'); } const callerData = callerSnap.data(); if (callerData.status !== 'active' || (callerData.role !== 'owner' && callerData.role !== 'admin')) { throw new Error('Only club owners and administrators can approve applications.'); } const banSnap = await tx.get(banRef); if (banSnap.exists()) { throw new Error('This application can no longer be approved.'); } const targetSnap = await tx.get(targetRef); if (!targetSnap.exists()) { throw new Error('This application could not be found.'); } const targetData = targetSnap.data(); if (targetData.status !== 'requested') { throw new Error('This application is no longer pending.'); } const now = serverTimestamp(); // 1. Transactionally update member to active with default role 'member' and default rank 'Member' tx.update(targetRef, { status: 'active', role: 'member', displayRank: 'Member', joinedAt: now, updatedAt: now, }); // 2. Transactionally increment club memberCount and set lastMembershipChangeUid tx.update(clubRef, { memberCount: (cd.memberCount || 0) + 1, updatedAt: now, lastMembershipChangeUid: targetUid, }); }); } /** * Atomically declines a pending membership request. * Transitions requested -> declined. Non-destructive: preserves history, does not delete doc. * memberCount delta is 0. Rider account is unaffected. * Strictly verifies caller is active owner or admin. */ export async function declineClubApplication( clubId: string, callerUid: string, targetUid: string ): Promise { if (!callerUid) { throw new Error('Authentication required.'); } if (!targetUid) { throw new Error('Target applicant UID required.'); } const callerRef = doc(firestore, 'clubs', clubId, 'members', callerUid); const targetRef = doc(firestore, 'clubs', clubId, 'members', targetUid); await runTransaction(firestore, async (tx) => { const callerSnap = await tx.get(callerRef); if (!callerSnap.exists()) { throw new Error('Caller not a member of this club.'); } const callerData = callerSnap.data(); if (callerData.status !== 'active' || (callerData.role !== 'owner' && callerData.role !== 'admin')) { throw new Error('Only club owners and administrators can decline applications.'); } const targetSnap = await tx.get(targetRef); if (!targetSnap.exists()) { throw new Error('This application could not be found.'); } const targetData = targetSnap.data(); if (targetData.status !== 'requested') { throw new Error('This application is no longer pending.'); } const now = serverTimestamp(); // Non-destructive transition: requested -> declined. memberCount delta is 0. tx.update(targetRef, { status: 'declined', updatedAt: now, }); }); } export interface ClubRidesResult { upcomingRides: ClubUpcomingRide[]; pastRides: ClubUpcomingRide[]; } function mapGroupRideDoc(id: string, rd: Record): ClubUpcomingRide { const startStr = typeof rd.scheduledStartAt === 'string' ? rd.scheduledStartAt : (rd.scheduledStartAt?.toDate?.() ? rd.scheduledStartAt.toDate().toISOString() : 'TBD'); const depStr = typeof rd.departureAt === 'string' ? rd.departureAt : (rd.departureAt?.toDate?.() ? rd.departureAt.toDate().toISOString() : undefined); return { id, rideIdentifier: rd.rideIdentifier || `RIDE-${id.slice(0, 6).toUpperCase()}`, title: rd.title || 'Untitled Club Ride', scheduledStartAt: startStr, departureAt: depStr, distanceMiles: typeof rd.routeDistanceMeters === 'number' ? Math.round(rd.routeDistanceMeters * 0.000621371) : null, durationString: null, paceClassification: rd.paceClassification || 'Club Paceline', roadCaptainName: rd.organizerDisplayName || 'Ride Leader', roadCaptainHandle: rd.organizerHandle ? `@${rd.organizerHandle.replace(/^@/, '')}` : undefined, roadCaptainMotorcycle: rd.organizerMotorcycle || null, sweepName: rd.sweepName || null, sweepStatus: rd.sweepStatus || 'vacant', confirmedCount: typeof rd.participantCount === 'number' ? rd.participantCount : 1, pendingCount: typeof rd.pendingCount === 'number' ? rd.pendingCount : 0, capacity: typeof rd.participantLimit === 'number' ? rd.participantLimit : null, briefingTime: rd.briefingTime || null, routeId: rd.routeId || null, status: rd.status || null, organizerUid: rd.organizerUid || null, description: rd.description || null, }; } /** * Fetches official Club-affiliated rides for Founder Portal management. * * GOVERNANCE AUTHORIZATION: * - Scoped to clubId, status, and scheduledStartAt matching Firestore security rules and composite indexes. * - Active Club Owners and Admins have administrative visibility over all official club rides. * - Unrestricted collection reads are strictly avoided. * - Demo preview remains available when explicitly requested. */ export async function fetchClubRides( clubId: string, _callerUid?: string, options?: { allowDemoPreview?: boolean } ): Promise { const normalized = (clubId || '').trim(); if (options?.allowDemoPreview && process.env.NODE_ENV === 'development') { return { upcomingRides: FIXTURE_UPCOMING_RIDES, pastRides: FIXTURE_PAST_RIDES, }; } if (!normalized) { throw new Error('not-found'); } const upcomingQuery = query( collection(firestore, 'group_rides'), where('clubId', '==', normalized), where('status', 'in', ['scheduled', 'assembling', 'underway']), orderBy('scheduledStartAt', 'asc'), limit(30) ); const pastQuery = query( collection(firestore, 'group_rides'), where('clubId', '==', normalized), where('status', 'in', ['completed', 'canceled']), orderBy('scheduledStartAt', 'desc'), limit(30) ); const [upcomingSnap, pastSnap] = await Promise.all([ getDocs(upcomingQuery), getDocs(pastQuery), ]); const upcomingRides: ClubUpcomingRide[] = upcomingSnap.docs.map((d) => mapGroupRideDoc(d.id, d.data()) ); const pastRides: ClubUpcomingRide[] = pastSnap.docs.map((d) => mapGroupRideDoc(d.id, d.data()) ); return { upcomingRides, pastRides, }; } /** * Standard Club Ranks definitions conforming to motorcycle club organizational structure. */ export const DEFAULT_CLUB_RANKS: import('../components/founder-portal/types').ClubRankDefinition[] = [ { id: 'president', title: 'President', category: 'executive', defaultRole: 'owner', badgeColor: '#FFB800', badgeBg: 'rgba(255, 184, 0, 0.15)', icon: 'admin_panel_settings', description: 'Supreme organizational executive with full software and administrative authority.', level: 1, }, { id: 'vice_president', title: 'Vice President', category: 'executive', defaultRole: 'admin', badgeColor: '#FF9500', badgeBg: 'rgba(255, 149, 0, 0.15)', icon: 'shield', description: 'Executive second-in-command handling operations, applications, and leadership oversight.', level: 2, }, { id: 'road_captain', title: 'Road Captain', category: 'tactical', defaultRole: 'member', badgeColor: '#00F2FE', badgeBg: 'rgba(0, 242, 254, 0.15)', icon: 'explore', description: 'Tactical lead commanding route planning, formation speed, hazards, and lead positioning.', level: 3, }, { id: 'tail_gunner', title: 'Tail Gunner', category: 'tactical', defaultRole: 'member', badgeColor: '#A855F7', badgeBg: 'rgba(168, 85, 247, 0.15)', icon: 'security', description: 'Safety sweep riding at the rear of pack formation to guard lane changes and assist riders.', level: 3, }, { id: 'road_marshal', title: 'Road Marshal', category: 'tactical', defaultRole: 'member', badgeColor: '#3B82F6', badgeBg: 'rgba(59, 130, 246, 0.15)', icon: 'flag', description: 'Safety officer managing intersection staging, paceline spacing, and rider compliance.', level: 4, }, { id: 'treasurer', title: 'Treasurer', category: 'officer', defaultRole: 'admin', badgeColor: '#10B981', badgeBg: 'rgba(16, 185, 129, 0.15)', icon: 'payments', description: 'Administrative officer managing club dues, merchandise logistics, and financials.', level: 2, }, { id: 'member', title: 'Full Member', category: 'squad', defaultRole: 'member', badgeColor: '#64748B', badgeBg: 'rgba(100, 116, 139, 0.15)', icon: 'group', description: 'Patched full club member with standard access to official rides and the club wall.', level: 5, }, { id: 'prospect', title: 'Prospect', category: 'squad', defaultRole: 'member', badgeColor: '#94A3B8', badgeBg: 'rgba(148, 163, 184, 0.15)', icon: 'hourglass_top', description: 'Probationary recruit completing observation rides prior to full membership voting.', level: 6, }, ]; export const DEFAULT_CLUB_CAPABILITIES: import('../components/founder-portal/types').ClubCapabilityDefinition[] = [ { id: 'planRides', label: 'Plan Club Rides', description: 'Create, schedule, and lead official club group rides on tactical maps.', category: 'rides', }, { id: 'manageApplications', label: 'Review Applications', description: 'Review questionnaires, approve new applicants, and issue member invitations.', category: 'governance', }, { id: 'postAnnouncements', label: 'Post Announcements', description: 'Pin high-priority safety notices and official broadcasts to the Club Wall.', category: 'community', }, { id: 'moderateWall', label: 'Moderate Club Wall', description: 'Delete inappropriate comments, ban violators, and resolve reported posts.', category: 'community', }, { id: 'manageRoster', label: 'Manage Member Roster', description: 'Assign squad ranks, officer titles, and update member designations.', category: 'governance', }, { id: 'editProfile', label: 'Edit Club Profile', description: 'Update monogram crest, banner imagery, club manifesto, and focus tags.', category: 'governance', }, ]; export const DEFAULT_RANK_PERMISSIONS: Record> = { president: { planRides: true, manageApplications: true, postAnnouncements: true, moderateWall: true, manageRoster: true, editProfile: true, }, vice_president: { planRides: true, manageApplications: true, postAnnouncements: true, moderateWall: true, manageRoster: true, editProfile: true, }, road_captain: { planRides: true, manageApplications: false, postAnnouncements: true, moderateWall: true, manageRoster: false, editProfile: false, }, tail_gunner: { planRides: true, manageApplications: false, postAnnouncements: false, moderateWall: true, manageRoster: false, editProfile: false, }, road_marshal: { planRides: false, manageApplications: false, postAnnouncements: false, moderateWall: true, manageRoster: false, editProfile: false, }, treasurer: { planRides: false, manageApplications: true, postAnnouncements: false, moderateWall: false, manageRoster: false, editProfile: false, }, member: { planRides: false, manageApplications: false, postAnnouncements: false, moderateWall: false, manageRoster: false, editProfile: false, }, prospect: { planRides: false, manageApplications: false, postAnnouncements: false, moderateWall: false, manageRoster: false, editProfile: false, }, }; /** * Fetches a page of active club members with their current roles and display ranks. */ export async function fetchClubMembers( clubId: string, callerUid?: string, options?: { allowDemoPreview?: boolean; afterUid?: string } ): Promise { const normalized = (clubId || '').trim(); if (options?.allowDemoPreview && process.env.NODE_ENV === 'development') { return [ { uid: 'demo-founder-01', clubId, status: 'active', role: 'owner', displayRank: 'President', updatedAt: new Date().toISOString(), displayName: 'Marcus Vance', handle: '@marcus_v', }, { uid: 'demo-rider-02', clubId, status: 'active', role: 'admin', displayRank: 'Vice President', updatedAt: new Date().toISOString(), displayName: 'Elena Rostova', handle: '@elena_rides', }, { uid: 'demo-rider-03', clubId, status: 'active', role: 'member', displayRank: 'Road Captain', updatedAt: new Date().toISOString(), displayName: 'Dave Miller', handle: '@dave_m', }, { uid: 'demo-rider-04', clubId, status: 'active', role: 'member', displayRank: 'Tail Gunner', updatedAt: new Date().toISOString(), displayName: 'Samir Chen', handle: '@samir_c', }, { uid: 'demo-rider-05', clubId, status: 'active', role: 'member', displayRank: 'Road Marshal', updatedAt: new Date().toISOString(), displayName: 'Sarah Jenkins', handle: '@sjenkins', }, { uid: 'demo-rider-06', clubId, status: 'active', role: 'member', displayRank: 'Full Member', updatedAt: new Date().toISOString(), displayName: 'Alex Thorne', handle: '@athorne', }, { uid: 'demo-rider-07', clubId, status: 'active', role: 'member', displayRank: 'Prospect', updatedAt: new Date().toISOString(), displayName: 'Chris Walker', handle: '@c_walker', }, ]; } const membersRef = collection(firestore, 'clubs', normalized, 'members'); const q = query( membersRef, where('status', '==', 'active'), orderBy(documentId()), ...(options?.afterUid ? [startAfter(options.afterUid)] : []), limit(50) ); const snap = await getDocs(q); return snap.docs.map((d) => { const data = d.data(); return { uid: d.id, clubId: normalized, status: 'active', role: (data.role as ClubRole) || 'member', displayRank: data.displayRank || null, joinedAt: data.joinedAt?.toDate?.() ? data.joinedAt.toDate().toISOString() : (data.joinedAt || null), updatedAt: data.updatedAt?.toDate?.() ? data.updatedAt.toDate().toISOString() : (data.updatedAt || new Date().toISOString()), displayName: data.displayName || null, handle: data.handle ? (data.handle.startsWith('@') ? data.handle : `@${data.handle}`) : null, }; }); } /** * Updates a club member's cosmetic display rank in Firestore. * Allowed for Club Owner & Admins under Firestore rule line 1251. */ export async function updateMemberRank( clubId: string, targetUid: string, newDisplayRank: string ): Promise { const normalized = (clubId || '').trim(); const cleanRank = (newDisplayRank || '').trim(); if (!normalized || !targetUid) { throw new Error('Club ID and target UID are required.'); } if (!cleanRank || cleanRank.length > 60) { throw new Error('Display rank must contain 1–60 characters.'); } const user = firebaseAuth.currentUser; if (!user || user.isAnonymous) throw new Error('unauthenticated'); const clubRef = doc(firestore, 'clubs', normalized); const memberRef = doc(firestore, 'clubs', normalized, 'members', targetUid); await runTransaction(firestore, async (tx) => { const club = await tx.get(clubRef); const caller = await tx.get(doc(firestore, 'clubs', normalized, 'members', user.uid)); const target = await tx.get(memberRef); if (!club.exists() || club.data().archivedAt) throw new Error('not-found'); if (club.data().ownerUid !== user.uid && (!caller.exists() || caller.data().status !== 'active' || caller.data().role !== 'admin')) { throw new Error('permission-denied'); } if (!target.exists() || target.data().status !== 'active') throw new Error('Member is no longer active.'); tx.update(memberRef, { displayRank: cleanRank, updatedAt: serverTimestamp() }); }); }