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

1277 lines
40 KiB
TypeScript

import { collection, doc, getDoc, getDocs, query, where, limit, collectionGroup, updateDoc, serverTimestamp, runTransaction, orderBy } 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[];
}
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,
};
}