1215 lines
38 KiB
TypeScript
1215 lines
38 KiB
TypeScript
import { collection, doc, getDoc, getDocs, query, where, limit, collectionGroup, updateDoc, serverTimestamp, runTransaction } from 'firebase/firestore';
|
|
import { 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[];
|
|
}
|
|
|
|
/**
|
|
* Supplies the Founder Portal Club Rides scaffold.
|
|
*
|
|
* CURRENT PRODUCTION CONTRACT:
|
|
* - GroupRide.clubId is persisted by the Flutter application.
|
|
* - Firestore intentionally does not authorize a broad client-side clubId list query,
|
|
* because discovery must preserve mutual-block privacy.
|
|
* - Production callers must not infer Club membership as GroupRide participation or
|
|
* live-location access.
|
|
* - The explicit development preview remains fixture-backed.
|
|
*/
|
|
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');
|
|
}
|
|
|
|
throw new Error(
|
|
'Club ride discovery is not available until a privacy-preserving production read path is implemented.'
|
|
);
|
|
}
|
|
|