MotorSignal-App/website/lib/club-community.ts

494 lines
18 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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(),
});
}