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

1624 lines
50 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters

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

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