494 lines
18 KiB
TypeScript
494 lines
18 KiB
TypeScript
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<WallComment, 'postId'> {
|
||
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<ClubMember[]> {
|
||
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<void> {
|
||
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<WallComment, 'id' | 'postId' | 'content' | 'createdAt'> = {
|
||
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<void> {
|
||
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<void> {
|
||
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<void> {
|
||
callerUid();
|
||
await updateDoc(doc(firestore, 'clubs', clubId, 'wall_posts', postId), { isPinned });
|
||
}
|
||
|
||
export async function deleteWallPost(clubId: string, postId: string): Promise<void> {
|
||
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<ClubRoute[]> {
|
||
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<void> {
|
||
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<void> {
|
||
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<string, ChatMessage[]> = {
|
||
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<void> {
|
||
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(),
|
||
});
|
||
}
|
||
|