1624 lines
50 KiB
TypeScript
1624 lines
50 KiB
TypeScript
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<ManageableClubSummary[]> {
|
||
if (!callerUid) return [];
|
||
|
||
const clubMap = new Map<string, ManageableClubSummary>();
|
||
|
||
// 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<FounderClubContext> {
|
||
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<void> {
|
||
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<void> {
|
||
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<ClubMember[]> {
|
||
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<void> {
|
||
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<void> {
|
||
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<ClubApplication[]> {
|
||
// 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<void> {
|
||
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<void> {
|
||
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<string, any>): 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<ClubRidesResult> {
|
||
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<string, Record<import('../components/founder-portal/types').ClubCapability, boolean>> = {
|
||
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<ClubMember[]> {
|
||
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<void> {
|
||
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() });
|
||
});
|
||
}
|
||
|