import { collection, deleteDoc, doc, getDoc, getDocs, limit, onSnapshot, orderBy, query, runTransaction, serverTimestamp, setDoc, updateDoc, type DocumentData, type Transaction, } from 'firebase/firestore'; import { firebaseAuth, firestore } from './firebase-client'; import { fetchClubMembers } from './club-data'; import type { ClubMember, ClubRole } from '../components/founder-portal/types'; export interface WallComment { id: string; postId: string; authorUid: string; authorDisplayName: string; authorHandle?: string; authorDisplayRank?: string; authorRole: ClubRole; content: string; createdAt: string; } export interface WallPost extends Omit { clubId: string; isPinned: boolean; fistBumpCount: number; comments: WallComment[]; } function callerUid(): string { const user = firebaseAuth.currentUser; if (!user || user.isAnonymous) throw new Error('unauthenticated'); return user.uid; } // Only point-read privacy-filtered public profiles. Never read private user docs. export async function fetchMemberRoster(clubId: string, afterUid?: string): Promise { const members = await fetchClubMembers(clubId, callerUid(), afterUid ? { afterUid } : undefined); const hydrated: ClubMember[] = []; for (let i = 0; i < members.length; i += 10) { hydrated.push(...await Promise.all(members.slice(i, i + 10).map(async (member) => { try { const profile = await getDoc(doc(firestore, 'public_profiles', member.uid)); if (profile.exists()) { const data = profile.data(); return { ...member, displayName: data.displayName || null, handle: data.username ? `@${String(data.username).replace(/^@/, '')}` : null }; } } catch { /* Restricted or absent profiles retain a conservative identity. */ } return { ...member, displayName: null, handle: null }; }))); } return hydrated; } async function managementContext(tx: Transaction, clubId: string, uid: string) { const clubRef = doc(firestore, 'clubs', clubId); const club = await tx.get(clubRef); const member = await tx.get(doc(firestore, 'clubs', clubId, 'members', uid)); if (!club.exists() || club.data().archivedAt) throw new Error('not-found'); const isOwner = club.data().ownerUid === uid; if (!isOwner && (!member.exists() || member.data().status !== 'active' || member.data().role !== 'admin')) { throw new Error('permission-denied'); } return { clubRef, data: club.data(), isOwner }; } export async function removeClubMember(clubId: string, targetUid: string): Promise { const uid = callerUid(); if (targetUid === uid) throw new Error('Use the mobile leave-club flow to leave your club.'); await runTransaction(firestore, async (tx) => { const context = await managementContext(tx, clubId, uid); const targetRef = doc(firestore, 'clubs', clubId, 'members', targetUid); const target = await tx.get(targetRef); if (!target.exists() || target.data().status !== 'active') throw new Error('Member is no longer active.'); if (targetUid === context.data.ownerUid || target.data().role === 'owner' || (!context.isOwner && target.data().role === 'admin')) throw new Error('permission-denied'); if (typeof context.data.memberCount !== 'number' || context.data.memberCount <= 1) { throw new Error('Club member count needs to be reconciled before removal.'); } tx.update(targetRef, { status: 'removed', removedAt: serverTimestamp(), updatedAt: serverTimestamp() }); tx.update(context.clubRef, { memberCount: context.data.memberCount - 1, lastMembershipChangeUid: targetUid, updatedAt: serverTimestamp() }); }); } function isoDate(value: unknown): string { if (value && typeof value === 'object' && 'toDate' in value && typeof value.toDate === 'function') { return value.toDate().toISOString(); } return typeof value === 'string' ? value : ''; } function mapComment(data: DocumentData): WallComment { return { id: String(data.id || ''), postId: String(data.postId || ''), authorUid: String(data.authorUid || ''), authorDisplayName: String(data.authorDisplayName || 'Rider'), authorHandle: data.authorHandle, authorDisplayRank: data.authorDisplayRank, authorRole: data.authorRole === 'owner' || data.authorRole === 'admin' ? data.authorRole : 'member', content: String(data.content || ''), createdAt: isoDate(data.createdAt) }; } export function subscribeClubWall( clubId: string, onPosts: (posts: WallPost[]) => void, onError: (error: unknown) => void, ): () => void { return onSnapshot(query(collection(firestore, 'clubs', clubId, 'wall_posts'), orderBy('createdAt', 'desc'), limit(50)), (snapshot) => { const posts = snapshot.docs.map((item) => { const data = item.data(); const { postId: _postId, ...author } = mapComment(data); return { ...author, id: item.id, clubId, isPinned: data.isPinned === true, fistBumpCount: typeof data.fistBumpCount === 'number' ? data.fistBumpCount : 0, comments: Array.isArray(data.comments) ? data.comments.map(mapComment) : [] }; }); onPosts(posts.sort((a, b) => Number(b.isPinned) - Number(a.isPinned))); }, onError); } function cleanContent(content: string, max: number): string { const clean = content.trim(); if (!clean || clean.length > max) throw new Error(`Content must contain 1–${max} characters.`); return clean; } async function wallAuthor(clubId: string) { const uid = callerUid(); const [club, membership] = await Promise.all([ getDoc(doc(firestore, 'clubs', clubId)), getDoc(doc(firestore, 'clubs', clubId, 'members', uid)), ]); if (!club.exists() || club.data().archivedAt) throw new Error('not-found'); const owner = club.data().ownerUid === uid; if (!owner && (!membership.exists() || membership.data().status !== 'active')) throw new Error('permission-denied'); const data = membership.exists() ? membership.data() : {}; const role: ClubRole = owner ? 'owner' : data.role === 'admin' ? 'admin' : 'member'; const author: Omit = { authorUid: uid, authorDisplayName: 'Rider', authorRole: role, }; if (data.displayRank) author.authorDisplayRank = data.displayRank; const profile = await getDoc(doc(firestore, 'public_profiles', uid)); if (profile.exists()) { author.authorDisplayName = profile.data().displayName || 'Rider'; if (profile.data().username) author.authorHandle = `@${String(profile.data().username).replace(/^@/, '')}`; } return author; } export async function createWallPost(clubId: string, content: string, isPinned: boolean): Promise { const clean = cleanContent(content, 5000); const author = await wallAuthor(clubId); if (isPinned && author.authorRole === 'member') throw new Error('permission-denied'); const ref = doc(collection(firestore, 'clubs', clubId, 'wall_posts')); await setDoc(ref, { ...author, id: ref.id, clubId, content: clean, createdAt: serverTimestamp(), isPinned, fistBumpCount: 0, fistBumpUserIds: [], comments: [] }); } export async function replyToWallPost(clubId: string, postId: string, content: string): Promise { const clean = cleanContent(content, 2000); const author = await wallAuthor(clubId); const comment: WallComment = { ...author, id: crypto.randomUUID(), postId, content: clean, createdAt: new Date().toISOString() }; const ref = doc(firestore, 'clubs', clubId, 'wall_posts', postId); await runTransaction(firestore, async (tx) => { const post = await tx.get(ref); if (!post.exists()) throw new Error('not-found'); const comments = post.data().comments; if (!Array.isArray(comments) || comments.length >= 200) throw new Error('This thread has reached its reply limit.'); // Retry against the latest list so simultaneous replies never overwrite each other. tx.update(ref, { comments: [...comments, comment] }); }); } export async function setWallPostPinned(clubId: string, postId: string, isPinned: boolean): Promise { callerUid(); await updateDoc(doc(firestore, 'clubs', clubId, 'wall_posts', postId), { isPinned }); } export async function deleteWallPost(clubId: string, postId: string): Promise { callerUid(); await deleteDoc(doc(firestore, 'clubs', clubId, 'wall_posts', postId)); } // ============================================================================ // CLUB ROUTES MANAGEMENT // ============================================================================ export interface ClubRoute { id: string; clubId: string; title: string; description: string; distanceMiles: number; estimatedMinutes: number; elevationGainFt: number; curvatureRating: 'High Twisties' | 'Moderate' | 'Scenic Cruise' | 'Track Pace'; surfaceType: 'Paved Asphalt' | 'Mixed Surface' | 'Mountain Pass'; startLocation: string; endLocation: string; gpxUrl?: string | null; curatorUid: string; curatorName: string; createdAt: string; } export const FIXTURE_CLUB_ROUTES: ClubRoute[] = [ { id: 'route-blood-mountain', clubId: 'ClubID', title: 'Blood Mountain Gap Loop', description: 'Technical mountain sweepers through US-19/129 with fast elevation transitions and fresh asphalt.', distanceMiles: 48.6, estimatedMinutes: 65, elevationGainFt: 3120, curvatureRating: 'High Twisties', surfaceType: 'Paved Asphalt', startLocation: 'Dahlonega Outpost', endLocation: 'Vogel State Park', gpxUrl: '/fixtures/routes/blood-mountain.gpx', curatorUid: 'owner', curatorName: 'Morgan Vance', createdAt: '2026-03-15T10:00:00Z', }, { id: 'route-cherohala-skyway', clubId: 'ClubID', title: 'Cherohala High Ridge Sprint', description: 'Sweeping national scenic byway climbing above 5,300 feet. Broad turns with optimal lean sightlines.', distanceMiles: 52.4, estimatedMinutes: 72, elevationGainFt: 4200, curvatureRating: 'Moderate', surfaceType: 'Mountain Pass', startLocation: 'Tellico Plains', endLocation: 'Robbinsville Overlook', gpxUrl: '/fixtures/routes/cherohala.gpx', curatorUid: 'member', curatorName: 'Taylor Cruz', createdAt: '2026-04-02T14:30:00Z', }, { id: 'route-coastal-creek', clubId: 'ClubID', title: 'Coastal Marshes Scenic Cruise', description: 'Relaxed scenic run through coastal causeways and oak canopies. Ideal for group pack acclimation.', distanceMiles: 38.0, estimatedMinutes: 50, elevationGainFt: 180, curvatureRating: 'Scenic Cruise', surfaceType: 'Paved Asphalt', startLocation: 'Savannah Riverfront', endLocation: 'Tybee Lighthouse', gpxUrl: '/fixtures/routes/coastal-creek.gpx', curatorUid: 'admin', curatorName: 'Alex Mercer', createdAt: '2026-05-10T09:15:00Z', }, ]; export async function fetchClubRoutes(clubId: string, options?: { allowDemoPreview?: boolean }): Promise { if (options?.allowDemoPreview) { return FIXTURE_CLUB_ROUTES.map((r) => ({ ...r, clubId })); } callerUid(); const routesRef = collection(firestore, 'clubs', clubId, 'routes'); const q = query(routesRef, orderBy('createdAt', 'desc'), limit(50)); const snap = await getDocs(q); if (snap.empty) { // Return sample starter routes if no custom routes have been uploaded return FIXTURE_CLUB_ROUTES.map((r) => ({ ...r, clubId })); } return snap.docs.map((d) => { const data = d.data(); return { id: d.id, clubId, title: String(data.title || 'Untitled Route'), description: String(data.description || ''), distanceMiles: typeof data.distanceMiles === 'number' ? data.distanceMiles : 0, estimatedMinutes: typeof data.estimatedMinutes === 'number' ? data.estimatedMinutes : 0, elevationGainFt: typeof data.elevationGainFt === 'number' ? data.elevationGainFt : 0, curvatureRating: data.curvatureRating || 'Moderate', surfaceType: data.surfaceType || 'Paved Asphalt', startLocation: String(data.startLocation || 'Club Staging Point'), endLocation: String(data.endLocation || 'Destination'), gpxUrl: data.gpxUrl || null, curatorUid: String(data.curatorUid || ''), curatorName: String(data.curatorName || 'Road Captain'), createdAt: isoDate(data.createdAt), }; }); } export async function createClubRoute( clubId: string, route: { title: string; description: string; distanceMiles: number; estimatedMinutes: number; elevationGainFt: number; curvatureRating: 'High Twisties' | 'Moderate' | 'Scenic Cruise' | 'Track Pace'; surfaceType: 'Paved Asphalt' | 'Mixed Surface' | 'Mountain Pass'; startLocation: string; endLocation: string; gpxUrl?: string | null; } ): Promise { const author = await wallAuthor(clubId); const title = cleanContent(route.title, 120); const description = (route.description || '').trim().slice(0, 1000); const startLoc = cleanContent(route.startLocation, 120); const endLoc = cleanContent(route.endLocation, 120); const ref = doc(collection(firestore, 'clubs', clubId, 'routes')); await setDoc(ref, { id: ref.id, clubId, title, description, distanceMiles: Math.max(0.1, Number(route.distanceMiles) || 1), estimatedMinutes: Math.max(1, Number(route.estimatedMinutes) || 10), elevationGainFt: Math.max(0, Number(route.elevationGainFt) || 0), curvatureRating: route.curvatureRating, surfaceType: route.surfaceType, startLocation: startLoc, endLocation: endLoc, gpxUrl: route.gpxUrl || null, curatorUid: author.authorUid, curatorName: author.authorDisplayName, createdAt: serverTimestamp(), }); } export async function deleteClubRoute(clubId: string, routeId: string): Promise { const uid = callerUid(); const [club, member] = await Promise.all([ getDoc(doc(firestore, 'clubs', clubId)), getDoc(doc(firestore, 'clubs', clubId, 'members', uid)), ]); if (!club.exists() || club.data().archivedAt) throw new Error('not-found'); const isOwner = club.data().ownerUid === uid; const isAdmin = member.exists() && member.data().status === 'active' && member.data().role === 'admin'; if (!isOwner && !isAdmin) throw new Error('permission-denied'); await deleteDoc(doc(firestore, 'clubs', clubId, 'routes', routeId)); } // ============================================================================ // CLUB CHAT MESSAGING // ============================================================================ export interface ChatMessage { id: string; clubId: string; channelId: string; authorUid: string; authorDisplayName: string; authorHandle?: string | null; authorDisplayRank?: string | null; authorRole: ClubRole; content: string; createdAt: string; } export const FIXTURE_CHAT_MESSAGES: Record = { general: [ { id: 'msg-gen-01', clubId: 'ClubID', channelId: 'general', authorUid: 'owner', authorDisplayName: 'Morgan Vance', authorHandle: '@morgan_v', authorDisplayRank: 'President', authorRole: 'owner', content: 'Welcome to the club squad room! Roll call for Saturday mountain run is open in Rides.', createdAt: '2026-06-01T12:00:00Z', }, { id: 'msg-gen-02', clubId: 'ClubID', channelId: 'general', authorUid: 'member', authorDisplayName: 'Taylor Cruz', authorHandle: '@taylor_c', authorDisplayRank: 'Road Captain', authorRole: 'member', content: 'I scouted the Gap yesterday — pavement is pristine and zero gravel washouts.', createdAt: '2026-06-01T12:15:00Z', }, ], officers: [ { id: 'msg-off-01', clubId: 'ClubID', channelId: 'officers', authorUid: 'owner', authorDisplayName: 'Morgan Vance', authorHandle: '@morgan_v', authorDisplayRank: 'President', authorRole: 'owner', content: 'Officers: let us review the pending membership requests before Friday briefing.', createdAt: '2026-06-01T11:00:00Z', }, { id: 'msg-off-02', clubId: 'ClubID', channelId: 'officers', authorUid: 'admin', authorDisplayName: 'Alex Mercer', authorHandle: '@alex_m', authorDisplayRank: 'Vice President', authorRole: 'admin', content: 'Understood. I checked both applicants; their safety profiles and ride histories look solid.', createdAt: '2026-06-01T11:20:00Z', }, ], 'ride-intel': [ { id: 'msg-intel-01', clubId: 'ClubID', channelId: 'ride-intel', authorUid: 'member', authorDisplayName: 'Taylor Cruz', authorHandle: '@taylor_c', authorDisplayRank: 'Road Captain', authorRole: 'member', content: 'Radar alert: morning fog near the summit clearing by 10 AM. Recommended tire pressures nominal.', createdAt: '2026-06-01T08:30:00Z', }, ], }; export function subscribeClubChat( clubId: string, channelId: string, onMessages: (msgs: ChatMessage[]) => void, onError: (err: unknown) => void ): () => void { const colRef = collection(firestore, 'clubs', clubId, 'chat_channels', channelId, 'messages'); const q = query(colRef, orderBy('createdAt', 'asc'), limit(100)); return onSnapshot( q, (snap) => { if (snap.empty) { // Fallback to fixture messages if channel has no messages yet const initial = FIXTURE_CHAT_MESSAGES[channelId] || []; onMessages(initial.map((m) => ({ ...m, clubId, channelId }))); return; } const msgs: ChatMessage[] = snap.docs.map((d) => { const data = d.data(); return { id: d.id, clubId, channelId, authorUid: String(data.authorUid || ''), authorDisplayName: String(data.authorDisplayName || 'Rider'), authorHandle: data.authorHandle || null, authorDisplayRank: data.authorDisplayRank || null, authorRole: (data.authorRole as ClubRole) || 'member', content: String(data.content || ''), createdAt: isoDate(data.createdAt), }; }); onMessages(msgs); }, onError ); } export async function sendClubChatMessage( clubId: string, channelId: string, content: string ): Promise { const clean = cleanContent(content, 1000); const author = await wallAuthor(clubId); // Officers channel requires owner or admin role if (channelId === 'officers' && author.authorRole === 'member') { throw new Error('permission-denied'); } const ref = doc(collection(firestore, 'clubs', clubId, 'chat_channels', channelId, 'messages')); await setDoc(ref, { ...author, id: ref.id, clubId, channelId, content: clean, createdAt: serverTimestamp(), }); }