import { collection, deleteDoc, doc, getDoc, 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)); }