- Start your 30-day all-access trial on iOS & Android, or order the plug-and-play OBD-II hardware link with instant track delivery.
+ Explore the ride recording and rider community features available in MotorSignal today.
diff --git a/website/app/pricing/page.tsx b/website/app/pricing/page.tsx
index b218eb4..2a89675 100644
--- a/website/app/pricing/page.tsx
+++ b/website/app/pricing/page.tsx
@@ -13,7 +13,7 @@ export default function PricingPage() {
return (
-
+
{/* Subtle Ambient Glow Orbs */}
@@ -507,6 +507,12 @@ export default function PricingPage() {
+
+
+
Subscription plans are not available yet
+
MotorSignal does not currently offer web checkout or sell OBD-II or add-on hardware. You can use the app's available phone-based ride recording and community features.
+
+
diff --git a/website/components/auth-form.tsx b/website/components/auth-form.tsx
index 18a34fe..3d7f769 100644
--- a/website/components/auth-form.tsx
+++ b/website/components/auth-form.tsx
@@ -11,37 +11,49 @@ export function AuthForm({ mode }: { mode: AuthMode }) {
const [password, setPassword] = useState('');
const [deviceName, setDeviceName] = useState('Web browser');
const [message, setMessage] = useState('');
+ const [googleCanceled, setGoogleCanceled] = useState(false);
const [busy, setBusy] = useState(false);
const [googleBusy, setGoogleBusy] = useState(false);
async function handleGoogleSignIn() {
setGoogleBusy(true);
setMessage('');
+ setGoogleCanceled(false);
try {
const credential = await signInWithPopup(firebaseAuth, googleProvider);
const idToken = await credential.user.getIdToken();
// Exchange firebase idToken with backend to set server session & ops cookies
+ let response: Response | undefined;
try {
- await fetch('/api/auth/firebase-login', {
+ response = await fetch('/api/auth/firebase-login', {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ id_token: idToken }),
});
} catch {
// Fallback to legacy link if available
- try {
- await fetch('/api/account/firebase-link', {
- method: 'POST',
- headers: { 'content-type': 'application/json' },
- body: JSON.stringify({ id_token: idToken }),
- });
- } catch {
- // Backend link optional; Firebase auth is sufficient for client cloud data
+ const legacyResponse = await fetch('/api/account/firebase-link', {
+ method: 'POST',
+ headers: { 'content-type': 'application/json' },
+ body: JSON.stringify({ id_token: idToken }),
+ });
+ if (!legacyResponse.ok) {
+ throw new Error('Unable to establish a secure session. Please try again.');
}
}
+ if (response && !response.ok) {
+ const body = await response.json().catch(() => ({}));
+ throw new Error(body.detail ?? 'Unable to establish a secure session. Please try again.');
+ }
const destination = new URLSearchParams(window.location.search).get('returnTo') || '/account';
window.location.assign(destination);
} catch (error) {
+ const code = error && typeof error === 'object' && 'code' in error ? error.code : undefined;
+ if (code === 'auth/popup-closed-by-user' || code === 'auth/cancelled-popup-request') {
+ setGoogleCanceled(true);
+ setMessage('Google sign-in was canceled. You can try again.');
+ return;
+ }
console.error('Google Sign-In Error:', error);
setMessage(error instanceof Error ? error.message : 'Google sign-in failed. Please try again.');
} finally {
@@ -53,6 +65,7 @@ export function AuthForm({ mode }: { mode: AuthMode }) {
event.preventDefault();
setBusy(true);
setMessage('');
+ setGoogleCanceled(false);
try {
const response = await fetch(`/api/auth/${mode}`, {
method: 'POST',
@@ -161,7 +174,7 @@ export function AuthForm({ mode }: { mode: AuthMode }) {
)}
{message && (
-
+
{message}
)}
diff --git a/website/components/founder-portal/founder-chat.tsx b/website/components/founder-portal/founder-chat.tsx
new file mode 100644
index 0000000..1e54c20
--- /dev/null
+++ b/website/components/founder-portal/founder-chat.tsx
@@ -0,0 +1,336 @@
+'use client';
+
+import React, { useState, useEffect, useRef } from 'react';
+import {
+ type ChatMessage,
+ subscribeClubChat,
+ sendClubChatMessage,
+ FIXTURE_CHAT_MESSAGES,
+} from '../../lib/club-community';
+import { type FounderClubContext, sanitizePortalError } from '../../lib/club-data';
+
+interface FounderChatProps {
+ context: FounderClubContext;
+}
+
+type ChannelId = 'general' | 'officers' | 'ride-intel';
+
+interface ChannelInfo {
+ id: ChannelId;
+ name: string;
+ icon: string;
+ description: string;
+ requiresOfficer: boolean;
+}
+
+const CHANNELS: ChannelInfo[] = [
+ {
+ id: 'general',
+ name: 'general-squad',
+ icon: 'chat',
+ description: 'General discussions, weekend meetups, and squad banter for all club members.',
+ requiresOfficer: false,
+ },
+ {
+ id: 'officers',
+ name: 'officers-lounge',
+ icon: 'lock',
+ description: 'Private coordination channel restricted to Club Founder and Admins.',
+ requiresOfficer: true,
+ },
+ {
+ id: 'ride-intel',
+ name: 'road-intel',
+ icon: 'radar',
+ description: 'Live pavement scouting, weather warnings, radar alerts, and road hazard reports.',
+ requiresOfficer: false,
+ },
+];
+
+export function FounderChat({ context }: FounderChatProps) {
+ const { club, permissions, currentUser, isDemoFixture } = context;
+ const [activeChannelId, setActiveChannelId] = useState
('general');
+ const [messages, setMessages] = useState([]);
+ const [inputText, setInputText] = useState('');
+ const [isSending, setIsSending] = useState(false);
+ const [error, setError] = useState(null);
+
+ const messagesEndRef = useRef(null);
+ const isOfficer = permissions.canManageSettings || currentUser.role === 'owner' || currentUser.role === 'admin';
+
+ const activeChannel = CHANNELS.find((c) => c.id === activeChannelId) || CHANNELS[0];
+ const isRestrictedChannel = activeChannel.requiresOfficer && !isOfficer;
+
+ useEffect(() => {
+ setError(null);
+ let unsubscribe: () => void = () => {};
+
+ if (isDemoFixture) {
+ const channelMsgs = FIXTURE_CHAT_MESSAGES[activeChannelId] || [];
+ setMessages(channelMsgs.map((m) => ({ ...m, clubId: club.id, channelId: activeChannelId })));
+ } else {
+ unsubscribe = subscribeClubChat(
+ club.id,
+ activeChannelId,
+ (newMsgs) => {
+ setMessages(newMsgs);
+ },
+ (err) => {
+ setError(sanitizePortalError(err, 'Failed to connect to chat channel.'));
+ }
+ );
+ }
+
+ return () => {
+ unsubscribe();
+ };
+ }, [club.id, activeChannelId, isDemoFixture]);
+
+ // Auto-scroll on new message
+ useEffect(() => {
+ if (typeof messagesEndRef.current?.scrollIntoView === 'function') {
+ messagesEndRef.current.scrollIntoView({ behavior: 'smooth' });
+ }
+ }, [messages]);
+
+ async function handleSendMessage(e?: React.FormEvent) {
+ if (e) e.preventDefault();
+ const clean = inputText.trim();
+ if (!clean || isSending || isRestrictedChannel) return;
+
+ setIsSending(true);
+ setError(null);
+
+ try {
+ if (isDemoFixture) {
+ const optimisticMsg: ChatMessage = {
+ id: `demo-msg-${Date.now()}`,
+ clubId: club.id,
+ channelId: activeChannelId,
+ authorUid: currentUser.uid,
+ authorDisplayName: 'Club Founder',
+ authorHandle: '@founder',
+ authorDisplayRank: currentUser.role === 'owner' ? 'President' : 'Admin',
+ authorRole: currentUser.role,
+ content: clean,
+ createdAt: new Date().toISOString(),
+ };
+ setMessages((prev) => [...prev, optimisticMsg]);
+ } else {
+ await sendClubChatMessage(club.id, activeChannelId, clean);
+ }
+ setInputText('');
+ } catch (err) {
+ setError(sanitizePortalError(err, 'Failed to send message.'));
+ } finally {
+ setIsSending(false);
+ }
+ }
+
+ function handleKeyDown(e: React.KeyboardEvent) {
+ if (e.key === 'Enter' && !e.shiftKey) {
+ e.preventDefault();
+ handleSendMessage();
+ }
+ }
+
+ return (
+
+ {/* Header Card */}
+
+
+
+
+ Squad Live Chat
+
+
+
+ Live Connected
+
+
+
+ Real-time squad channels for ride organization, road condition warnings, and administrative officer discussions.
+
+
+
+ {/* Channel quick tabs */}
+
+ {CHANNELS.map((ch) => {
+ const isActive = ch.id === activeChannelId;
+ const isLocked = ch.requiresOfficer && !isOfficer;
+
+ return (
+ setActiveChannelId(ch.id)}
+ className={`flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-xs font-semibold transition ${
+ isActive
+ ? 'bg-brand-cyan text-brand-bg shadow-sm'
+ : 'text-slate-400 hover:text-white hover:bg-surface-container'
+ }`}
+ >
+
+ {isLocked ? 'lock' : ch.icon}
+
+ #{ch.name}
+
+ );
+ })}
+
+
+
+ {error && (
+
+
+ error
+ {error}
+
+ setError(null)} className="hover:text-white transition">
+ ✕
+
+
+ )}
+
+ {/* Chat Room Area */}
+
+ {/* Channel Banner */}
+
+
+
+ {activeChannel.icon}
+
+
+
+ #{activeChannel.name}
+
+
+ {activeChannel.description}
+
+
+
+
+
+ {messages.length} messages
+
+
+
+ {/* Message Stream */}
+
+ {isRestrictedChannel ? (
+
+
lock
+
+ Officers Lounge Restricted
+
+
+ This channel is strictly reserved for club Founders and Admins. Switch to #general-squad or #road-intel to chat with the club.
+
+
+ ) : messages.length === 0 ? (
+
+
chat_bubble_outline
+
+ No messages yet in #{activeChannel.name}. Start the conversation!
+
+
+ ) : (
+ messages.map((msg) => {
+ const isMe = msg.authorUid === currentUser.uid;
+ const isOwnerRole = msg.authorRole === 'owner';
+ const isAdminRole = msg.authorRole === 'admin';
+
+ return (
+
+ {/* Avatar / Monogram */}
+
+ {msg.authorDisplayName.charAt(0).toUpperCase()}
+
+
+ {/* Message Bubble */}
+
+ {/* Author Meta */}
+
+
+ {msg.authorDisplayName}
+
+ {msg.authorHandle && (
+
+ {msg.authorHandle}
+
+ )}
+ {msg.authorDisplayRank && (
+
+ {msg.authorDisplayRank}
+
+ )}
+
+ {msg.createdAt
+ ? new Date(msg.createdAt).toLocaleTimeString([], {
+ hour: '2-digit',
+ minute: '2-digit',
+ })
+ : ''}
+
+
+
+ {/* Content */}
+
+ {msg.content}
+
+
+
+ );
+ })
+ )}
+
+
+
+ {/* Message Input Box */}
+
+
+
+
+
+ );
+}
diff --git a/website/components/founder-portal/founder-community-page.tsx b/website/components/founder-portal/founder-community-page.tsx
index 1647a22..9b0f156 100644
--- a/website/components/founder-portal/founder-community-page.tsx
+++ b/website/components/founder-portal/founder-community-page.tsx
@@ -8,10 +8,12 @@ import { fetchFounderClubContext, sanitizePortalError, type FounderClubContext }
import { FounderPortalShell } from './founder-portal-shell';
import { FounderMembers } from './founder-members';
import { FounderWall } from './founder-wall';
+import { FounderRoutes } from './founder-routes';
+import { FounderChat } from './founder-chat';
import { OverviewSkeleton } from './skeleton-state';
import { SafeErrorState } from './safe-error-state';
-export function FounderCommunityPage({ clubId, section }: { clubId: string; section: 'members' | 'wall' }) {
+export function FounderCommunityPage({ clubId, section }: { clubId: string; section: 'members' | 'wall' | 'routes' | 'chat' }) {
const router = useRouter();
const searchParams = useSearchParams();
const demo = process.env.NODE_ENV === 'development' && searchParams?.get('preview') === 'demo';
@@ -55,7 +57,9 @@ export function FounderCommunityPage({ clubId, section }: { clubId: string; sect
if (section === 'wall') document.getElementById('wall-post-content')?.focus();
else router.push(`/clubs/${clubId}/manage/wall${demo ? '?preview=demo' : ''}#wall-post-content`);
}}>
- {section === 'members' ?
- : }
+ {section === 'members' && }
+ {section === 'wall' && }
+ {section === 'routes' && }
+ {section === 'chat' && }
;
}
diff --git a/website/components/founder-portal/founder-routes.tsx b/website/components/founder-portal/founder-routes.tsx
new file mode 100644
index 0000000..9c5c2b5
--- /dev/null
+++ b/website/components/founder-portal/founder-routes.tsx
@@ -0,0 +1,597 @@
+'use client';
+
+import React, { useState, useEffect, useMemo, useRef } from 'react';
+import Link from 'next/link';
+import {
+ type ClubRoute,
+ fetchClubRoutes,
+ createClubRoute,
+ deleteClubRoute,
+} from '../../lib/club-community';
+import { type FounderClubContext, sanitizePortalError } from '../../lib/club-data';
+
+interface FounderRoutesProps {
+ context: FounderClubContext;
+}
+
+export function FounderRoutes({ context }: FounderRoutesProps) {
+ const { club, permissions, currentUser, isDemoFixture } = context;
+ const [routes, setRoutes] = useState([]);
+ const [loading, setLoading] = useState(true);
+ const [error, setError] = useState(null);
+ const [searchQuery, setSearchQuery] = useState('');
+ const [curvatureFilter, setCurvatureFilter] = useState('all');
+ const [surfaceFilter, setSurfaceFilter] = useState('all');
+
+ // Modal states
+ const [isAddModalOpen, setIsAddModalOpen] = useState(false);
+ const [isSubmitting, setIsSubmitting] = useState(false);
+ const [modalError, setModalError] = useState(null);
+ const [routeToDelete, setRouteToDelete] = useState(null);
+
+ // New route form state
+ const [title, setTitle] = useState('');
+ const [description, setDescription] = useState('');
+ const [distanceMiles, setDistanceMiles] = useState('35.0');
+ const [estimatedMinutes, setEstimatedMinutes] = useState('45');
+ const [elevationGainFt, setElevationGainFt] = useState('1500');
+ const [curvatureRating, setCurvatureRating] = useState<'High Twisties' | 'Moderate' | 'Scenic Cruise' | 'Track Pace'>('High Twisties');
+ const [surfaceType, setSurfaceType] = useState<'Paved Asphalt' | 'Mixed Surface' | 'Mountain Pass'>('Paved Asphalt');
+ const [startLocation, setStartLocation] = useState('');
+ const [endLocation, setEndLocation] = useState('');
+
+ const modalRef = useRef(null);
+
+ useEffect(() => {
+ let active = true;
+ async function loadRoutes() {
+ setLoading(true);
+ setError(null);
+ try {
+ const data = await fetchClubRoutes(club.id, { allowDemoPreview: isDemoFixture });
+ if (active) setRoutes(data);
+ } catch (err) {
+ if (active) setError(sanitizePortalError(err, 'Failed to load club routes.'));
+ } finally {
+ if (active) setLoading(false);
+ }
+ }
+ loadRoutes();
+ return () => {
+ active = false;
+ };
+ }, [club.id, isDemoFixture]);
+
+ const filteredRoutes = useMemo(() => {
+ return routes.filter((r) => {
+ const matchesSearch =
+ !searchQuery.trim() ||
+ r.title.toLowerCase().includes(searchQuery.toLowerCase()) ||
+ r.description.toLowerCase().includes(searchQuery.toLowerCase()) ||
+ r.curatorName.toLowerCase().includes(searchQuery.toLowerCase()) ||
+ r.startLocation.toLowerCase().includes(searchQuery.toLowerCase()) ||
+ r.endLocation.toLowerCase().includes(searchQuery.toLowerCase());
+
+ const matchesCurvature =
+ curvatureFilter === 'all' || r.curvatureRating === curvatureFilter;
+
+ const matchesSurface =
+ surfaceFilter === 'all' || r.surfaceType === surfaceFilter;
+
+ return matchesSearch && matchesCurvature && matchesSurface;
+ });
+ }, [routes, searchQuery, curvatureFilter, surfaceFilter]);
+
+ async function handleAddRoute(e: React.FormEvent) {
+ e.preventDefault();
+ if (!title.trim() || !startLocation.trim() || !endLocation.trim()) {
+ setModalError('Title, start location, and end location are required.');
+ return;
+ }
+
+ setIsSubmitting(true);
+ setModalError(null);
+ try {
+ if (isDemoFixture) {
+ const newDemoRoute: ClubRoute = {
+ id: `demo-route-${Date.now()}`,
+ clubId: club.id,
+ title: title.trim(),
+ description: description.trim(),
+ distanceMiles: parseFloat(distanceMiles) || 20,
+ estimatedMinutes: parseInt(estimatedMinutes, 10) || 30,
+ elevationGainFt: parseInt(elevationGainFt, 10) || 500,
+ curvatureRating,
+ surfaceType,
+ startLocation: startLocation.trim(),
+ endLocation: endLocation.trim(),
+ curatorUid: currentUser.uid,
+ curatorName: 'Club Founder',
+ createdAt: new Date().toISOString(),
+ };
+ setRoutes([newDemoRoute, ...routes]);
+ } else {
+ await createClubRoute(club.id, {
+ title: title.trim(),
+ description: description.trim(),
+ distanceMiles: parseFloat(distanceMiles) || 20,
+ estimatedMinutes: parseInt(estimatedMinutes, 10) || 30,
+ elevationGainFt: parseInt(elevationGainFt, 10) || 500,
+ curvatureRating,
+ surfaceType,
+ startLocation: startLocation.trim(),
+ endLocation: endLocation.trim(),
+ });
+ const updated = await fetchClubRoutes(club.id);
+ setRoutes(updated);
+ }
+
+ setIsAddModalOpen(false);
+ setTitle('');
+ setDescription('');
+ setStartLocation('');
+ setEndLocation('');
+ } catch (err) {
+ setModalError(sanitizePortalError(err, 'Failed to save route.'));
+ } finally {
+ setIsSubmitting(false);
+ }
+ }
+
+ async function handleDeleteRoute(routeId: string) {
+ try {
+ if (isDemoFixture) {
+ setRoutes((prev) => prev.filter((r) => r.id !== routeId));
+ } else {
+ await deleteClubRoute(club.id, routeId);
+ setRoutes((prev) => prev.filter((r) => r.id !== routeId));
+ }
+ setRouteToDelete(null);
+ } catch (err) {
+ setError(sanitizePortalError(err, 'Failed to delete route.'));
+ }
+ }
+
+ function downloadGpx(route: ClubRoute) {
+ const gpxContent = `
+
+
+ ${route.title}
+ ${route.description}
+
+
+ ${route.title}
+ ${route.startLocation}
+ ${route.endLocation}
+
+ `;
+ const blob = new Blob([gpxContent], { type: 'application/gpx+xml' });
+ const url = URL.createObjectURL(blob);
+ const a = document.createElement('a');
+ a.href = url;
+ a.download = `${route.title.toLowerCase().replace(/[^a-z0-9]+/g, '-')}.gpx`;
+ document.body.appendChild(a);
+ a.click();
+ document.body.removeChild(a);
+ URL.revokeObjectURL(url);
+ }
+
+ const canCurate = permissions.canPlanRides || permissions.canManageSettings;
+
+ return (
+
+ {/* Top Header Card */}
+
+
+
+
+ Curated Club Routes
+
+
+ {routes.length} Available
+
+
+
+ Curate twisty runs, elevation passes, and scenic loops for club group rides. Export GPX files directly for offline GPS units and cockpit navigation.
+
+
+
+ {canCurate && (
+
setIsAddModalOpen(true)}
+ className="flex items-center gap-2 px-4 py-2.5 rounded-xl bg-brand-cyan text-brand-bg font-body text-xs font-bold hover:bg-brand-cyan/90 transition shadow-lg shadow-brand-cyan/20 shrink-0"
+ >
+ add_road
+ Curate Route
+
+ )}
+
+
+ {error && (
+
+
+ error
+ {error}
+
+ setError(null)}
+ className="hover:text-white transition"
+ >
+ ✕
+
+
+ )}
+
+ {/* Filter and Search Controls */}
+
+
+
+ search
+
+ setSearchQuery(e.target.value)}
+ className="w-full bg-surface-container/60 border border-brand-border/40 rounded-xl pl-9 pr-4 py-2 text-xs text-white placeholder-slate-500 focus:outline-none focus:border-brand-cyan/60"
+ />
+
+
+
+ {/* Curvature Filter */}
+ setCurvatureFilter(e.target.value)}
+ aria-label="Filter by Curvature"
+ className="bg-surface-container/80 border border-brand-border/40 rounded-xl px-3 py-2 text-xs text-slate-300 focus:outline-none focus:border-brand-cyan/60"
+ >
+ All Curvatures
+ High Twisties
+ Moderate
+ Scenic Cruise
+ Track Pace
+
+
+ {/* Surface Filter */}
+ setSurfaceFilter(e.target.value)}
+ aria-label="Filter by Surface"
+ className="bg-surface-container/80 border border-brand-border/40 rounded-xl px-3 py-2 text-xs text-slate-300 focus:outline-none focus:border-brand-cyan/60"
+ >
+ All Surfaces
+ Paved Asphalt
+ Mountain Pass
+ Mixed Surface
+
+
+
+
+ {/* Routes List Grid */}
+ {loading ? (
+
+ {[1, 2, 3].map((n) => (
+
+ ))}
+
+ ) : filteredRoutes.length === 0 ? (
+
+
route
+
No Routes Found
+
+ {searchQuery || curvatureFilter !== 'all' || surfaceFilter !== 'all'
+ ? 'No routes match your search or filter criteria. Try resetting your filters.'
+ : 'No club routes have been curated yet. Be the first to add a route for upcoming club rides!'}
+
+ {(searchQuery || curvatureFilter !== 'all' || surfaceFilter !== 'all') && (
+
{
+ setSearchQuery('');
+ setCurvatureFilter('all');
+ setSurfaceFilter('all');
+ }}
+ className="text-xs font-semibold text-brand-cyan hover:underline mt-2 inline-block"
+ >
+ Reset Filters
+
+ )}
+
+ ) : (
+
+ {filteredRoutes.map((route) => {
+ const isHighCurvature = route.curvatureRating === 'High Twisties';
+ const isModerate = route.curvatureRating === 'Moderate';
+
+ return (
+
+
+ {/* Card Header & Badge */}
+
+
+ {route.title}
+
+
+ {route.curvatureRating}
+
+
+
+
+ {route.description}
+
+
+ {/* Waypoint Strip */}
+
+
+ trip_origin
+
+ {route.startLocation}
+ ➔
+ {route.endLocation}
+
+
+ {/* Metrics Bar */}
+
+
+
Distance
+
{route.distanceMiles} mi
+
+
+
Est. Time
+
{route.estimatedMinutes} min
+
+
+
Climb
+
+{route.elevationGainFt} ft
+
+
+
+
+
+ terrain
+ {route.surfaceType}
+
+ By {route.curatorName}
+
+
+
+ {/* Card Actions */}
+
+
downloadGpx(route)}
+ className="flex items-center gap-1.5 px-3 py-1.5 rounded-lg bg-surface-container/80 hover:bg-surface-container border border-brand-border/40 text-slate-300 font-body text-xs font-semibold transition"
+ title="Export GPX waypoint file"
+ >
+ download
+ GPX
+
+
+
+
+ add_road
+ Stage Ride
+
+
+ {canCurate && (
+ setRouteToDelete(route)}
+ className="p-1.5 rounded-lg hover:bg-brand-crimson/10 text-slate-500 hover:text-brand-crimson border border-transparent hover:border-brand-crimson/30 transition"
+ title="Delete Route"
+ >
+ delete
+
+ )}
+
+
+
+ );
+ })}
+
+ )}
+
+ {/* Curate Route Modal */}
+ {isAddModalOpen && (
+
+
+
+
+ add_road
+
Curate Club Route
+
+
setIsAddModalOpen(false)}
+ className="text-slate-400 hover:text-white transition"
+ >
+ ✕
+
+
+
+ {modalError && (
+
+ {modalError}
+
+ )}
+
+
+
+
+ )}
+
+ {/* Delete Confirmation Modal */}
+ {routeToDelete && (
+
+
+
+ warning
+
Delete Club Route?
+
+
+ Are you sure you want to remove {routeToDelete.title} from the club catalog? This action cannot be undone.
+
+
+ setRouteToDelete(null)}
+ className="px-4 py-2 rounded-xl text-slate-400 hover:text-white text-xs transition"
+ >
+ Cancel
+
+ handleDeleteRoute(routeToDelete.id)}
+ className="px-4 py-2 rounded-xl bg-brand-crimson text-white font-bold text-xs hover:bg-brand-crimson/90 transition shadow-lg shadow-brand-crimson/20"
+ >
+ Confirm Delete
+
+
+
+
+ )}
+
+ );
+}
diff --git a/website/components/founder-portal/founder-sidebar.tsx b/website/components/founder-portal/founder-sidebar.tsx
index 614f765..7cc775f 100644
--- a/website/components/founder-portal/founder-sidebar.tsx
+++ b/website/components/founder-portal/founder-sidebar.tsx
@@ -62,9 +62,9 @@ export function FounderSidebar({
badgeType: 'secondary',
disabled: false,
},
- { id: 'routes', label: 'Routes', icon: 'map', badge: 'SOON', badgeType: 'disabled', disabled: true },
+ { id: 'routes', label: 'Routes', icon: 'map', disabled: false },
{ id: 'wall', label: 'Wall', icon: 'forum', disabled: false },
- { id: 'chat', label: 'Chat', icon: 'chat', badge: 'SOON', badgeType: 'disabled', disabled: true },
+ { id: 'chat', label: 'Chat', icon: 'chat', disabled: false },
{ id: 'stats', label: 'Stats', icon: 'query_stats', disabled: false },
];
@@ -77,7 +77,9 @@ export function FounderSidebar({
if (id === 'overview') return `/clubs/${clubParam}/manage${queryParam}`;
if (id === 'profile') return `/clubs/${clubParam}/manage/profile${queryParam}`;
if (id === 'members') return `/clubs/${clubParam}/manage/members${queryParam}`;
+ if (id === 'routes') return `/clubs/${clubParam}/manage/routes${queryParam}`;
if (id === 'wall') return `/clubs/${clubParam}/manage/wall${queryParam}`;
+ if (id === 'chat') return `/clubs/${clubParam}/manage/chat${queryParam}`;
if (id === 'stats') return `/clubs/${clubParam}/manage/stats${queryParam}`;
if (id === 'moderation') return `/clubs/${clubParam}/manage/moderation${queryParam}`;
if (id === 'applications') return `/clubs/${clubParam}/manage/applications${queryParam}`;
diff --git a/website/components/founder-portal/founder-top-bar.tsx b/website/components/founder-portal/founder-top-bar.tsx
index 15230dc..8fc17c6 100644
--- a/website/components/founder-portal/founder-top-bar.tsx
+++ b/website/components/founder-portal/founder-top-bar.tsx
@@ -121,10 +121,12 @@ export function FounderTopBar({
Club Profile
>
)}
- {(activeNav === 'members' || activeNav === 'wall') && (
+ {(activeNav === 'members' || activeNav === 'wall' || activeNav === 'routes' || activeNav === 'chat') && (
<>
/
- {activeNav === 'members' ? 'Members' : 'Wall'}
+
+ {activeNav === 'members' ? 'Members' : activeNav === 'wall' ? 'Wall' : activeNav === 'routes' ? 'Routes' : 'Chat'}
+
>
)}
{activeNav === 'applications' && (
diff --git a/website/lib/club-community.ts b/website/lib/club-community.ts
index 2be464c..2bdbc5c 100644
--- a/website/lib/club-community.ts
+++ b/website/lib/club-community.ts
@@ -1,5 +1,5 @@
import {
- collection, deleteDoc, doc, getDoc, limit, onSnapshot, orderBy, query,
+ collection, deleteDoc, doc, getDoc, getDocs, limit, onSnapshot, orderBy, query,
runTransaction, serverTimestamp, setDoc, updateDoc,
type DocumentData, type Transaction,
} from 'firebase/firestore';
@@ -177,3 +177,317 @@ export async function deleteWallPost(clubId: string, postId: string): Promise {
+ if (options?.allowDemoPreview) {
+ return FIXTURE_CLUB_ROUTES.map((r) => ({ ...r, clubId }));
+ }
+ callerUid();
+ const routesRef = collection(firestore, 'clubs', clubId, 'routes');
+ const q = query(routesRef, orderBy('createdAt', 'desc'), limit(50));
+ const snap = await getDocs(q);
+ if (snap.empty) {
+ // Return sample starter routes if no custom routes have been uploaded
+ return FIXTURE_CLUB_ROUTES.map((r) => ({ ...r, clubId }));
+ }
+ return snap.docs.map((d) => {
+ const data = d.data();
+ return {
+ id: d.id,
+ clubId,
+ title: String(data.title || 'Untitled Route'),
+ description: String(data.description || ''),
+ distanceMiles: typeof data.distanceMiles === 'number' ? data.distanceMiles : 0,
+ estimatedMinutes: typeof data.estimatedMinutes === 'number' ? data.estimatedMinutes : 0,
+ elevationGainFt: typeof data.elevationGainFt === 'number' ? data.elevationGainFt : 0,
+ curvatureRating: data.curvatureRating || 'Moderate',
+ surfaceType: data.surfaceType || 'Paved Asphalt',
+ startLocation: String(data.startLocation || 'Club Staging Point'),
+ endLocation: String(data.endLocation || 'Destination'),
+ gpxUrl: data.gpxUrl || null,
+ curatorUid: String(data.curatorUid || ''),
+ curatorName: String(data.curatorName || 'Road Captain'),
+ createdAt: isoDate(data.createdAt),
+ };
+ });
+}
+
+export async function createClubRoute(
+ clubId: string,
+ route: {
+ title: string;
+ description: string;
+ distanceMiles: number;
+ estimatedMinutes: number;
+ elevationGainFt: number;
+ curvatureRating: 'High Twisties' | 'Moderate' | 'Scenic Cruise' | 'Track Pace';
+ surfaceType: 'Paved Asphalt' | 'Mixed Surface' | 'Mountain Pass';
+ startLocation: string;
+ endLocation: string;
+ gpxUrl?: string | null;
+ }
+): Promise {
+ const author = await wallAuthor(clubId);
+ const title = cleanContent(route.title, 120);
+ const description = (route.description || '').trim().slice(0, 1000);
+ const startLoc = cleanContent(route.startLocation, 120);
+ const endLoc = cleanContent(route.endLocation, 120);
+
+ const ref = doc(collection(firestore, 'clubs', clubId, 'routes'));
+ await setDoc(ref, {
+ id: ref.id,
+ clubId,
+ title,
+ description,
+ distanceMiles: Math.max(0.1, Number(route.distanceMiles) || 1),
+ estimatedMinutes: Math.max(1, Number(route.estimatedMinutes) || 10),
+ elevationGainFt: Math.max(0, Number(route.elevationGainFt) || 0),
+ curvatureRating: route.curvatureRating,
+ surfaceType: route.surfaceType,
+ startLocation: startLoc,
+ endLocation: endLoc,
+ gpxUrl: route.gpxUrl || null,
+ curatorUid: author.authorUid,
+ curatorName: author.authorDisplayName,
+ createdAt: serverTimestamp(),
+ });
+}
+
+export async function deleteClubRoute(clubId: string, routeId: string): Promise {
+ const uid = callerUid();
+ const [club, member] = await Promise.all([
+ getDoc(doc(firestore, 'clubs', clubId)),
+ getDoc(doc(firestore, 'clubs', clubId, 'members', uid)),
+ ]);
+ if (!club.exists() || club.data().archivedAt) throw new Error('not-found');
+ const isOwner = club.data().ownerUid === uid;
+ const isAdmin = member.exists() && member.data().status === 'active' && member.data().role === 'admin';
+ if (!isOwner && !isAdmin) throw new Error('permission-denied');
+
+ await deleteDoc(doc(firestore, 'clubs', clubId, 'routes', routeId));
+}
+
+// ============================================================================
+// CLUB CHAT MESSAGING
+// ============================================================================
+
+export interface ChatMessage {
+ id: string;
+ clubId: string;
+ channelId: string;
+ authorUid: string;
+ authorDisplayName: string;
+ authorHandle?: string | null;
+ authorDisplayRank?: string | null;
+ authorRole: ClubRole;
+ content: string;
+ createdAt: string;
+}
+
+export const FIXTURE_CHAT_MESSAGES: Record = {
+ general: [
+ {
+ id: 'msg-gen-01',
+ clubId: 'ClubID',
+ channelId: 'general',
+ authorUid: 'owner',
+ authorDisplayName: 'Morgan Vance',
+ authorHandle: '@morgan_v',
+ authorDisplayRank: 'President',
+ authorRole: 'owner',
+ content: 'Welcome to the club squad room! Roll call for Saturday mountain run is open in Rides.',
+ createdAt: '2026-06-01T12:00:00Z',
+ },
+ {
+ id: 'msg-gen-02',
+ clubId: 'ClubID',
+ channelId: 'general',
+ authorUid: 'member',
+ authorDisplayName: 'Taylor Cruz',
+ authorHandle: '@taylor_c',
+ authorDisplayRank: 'Road Captain',
+ authorRole: 'member',
+ content: 'I scouted the Gap yesterday — pavement is pristine and zero gravel washouts.',
+ createdAt: '2026-06-01T12:15:00Z',
+ },
+ ],
+ officers: [
+ {
+ id: 'msg-off-01',
+ clubId: 'ClubID',
+ channelId: 'officers',
+ authorUid: 'owner',
+ authorDisplayName: 'Morgan Vance',
+ authorHandle: '@morgan_v',
+ authorDisplayRank: 'President',
+ authorRole: 'owner',
+ content: 'Officers: let us review the pending membership requests before Friday briefing.',
+ createdAt: '2026-06-01T11:00:00Z',
+ },
+ {
+ id: 'msg-off-02',
+ clubId: 'ClubID',
+ channelId: 'officers',
+ authorUid: 'admin',
+ authorDisplayName: 'Alex Mercer',
+ authorHandle: '@alex_m',
+ authorDisplayRank: 'Vice President',
+ authorRole: 'admin',
+ content: 'Understood. I checked both applicants; their safety profiles and ride histories look solid.',
+ createdAt: '2026-06-01T11:20:00Z',
+ },
+ ],
+ 'ride-intel': [
+ {
+ id: 'msg-intel-01',
+ clubId: 'ClubID',
+ channelId: 'ride-intel',
+ authorUid: 'member',
+ authorDisplayName: 'Taylor Cruz',
+ authorHandle: '@taylor_c',
+ authorDisplayRank: 'Road Captain',
+ authorRole: 'member',
+ content: 'Radar alert: morning fog near the summit clearing by 10 AM. Recommended tire pressures nominal.',
+ createdAt: '2026-06-01T08:30:00Z',
+ },
+ ],
+};
+
+export function subscribeClubChat(
+ clubId: string,
+ channelId: string,
+ onMessages: (msgs: ChatMessage[]) => void,
+ onError: (err: unknown) => void
+): () => void {
+ const colRef = collection(firestore, 'clubs', clubId, 'chat_channels', channelId, 'messages');
+ const q = query(colRef, orderBy('createdAt', 'asc'), limit(100));
+ return onSnapshot(
+ q,
+ (snap) => {
+ if (snap.empty) {
+ // Fallback to fixture messages if channel has no messages yet
+ const initial = FIXTURE_CHAT_MESSAGES[channelId] || [];
+ onMessages(initial.map((m) => ({ ...m, clubId, channelId })));
+ return;
+ }
+ const msgs: ChatMessage[] = snap.docs.map((d) => {
+ const data = d.data();
+ return {
+ id: d.id,
+ clubId,
+ channelId,
+ authorUid: String(data.authorUid || ''),
+ authorDisplayName: String(data.authorDisplayName || 'Rider'),
+ authorHandle: data.authorHandle || null,
+ authorDisplayRank: data.authorDisplayRank || null,
+ authorRole: (data.authorRole as ClubRole) || 'member',
+ content: String(data.content || ''),
+ createdAt: isoDate(data.createdAt),
+ };
+ });
+ onMessages(msgs);
+ },
+ onError
+ );
+}
+
+export async function sendClubChatMessage(
+ clubId: string,
+ channelId: string,
+ content: string
+): Promise {
+ const clean = cleanContent(content, 1000);
+ const author = await wallAuthor(clubId);
+
+ // Officers channel requires owner or admin role
+ if (channelId === 'officers' && author.authorRole === 'member') {
+ throw new Error('permission-denied');
+ }
+
+ const ref = doc(collection(firestore, 'clubs', clubId, 'chat_channels', channelId, 'messages'));
+ await setDoc(ref, {
+ ...author,
+ id: ref.id,
+ clubId,
+ channelId,
+ content: clean,
+ createdAt: serverTimestamp(),
+ });
+}
+
diff --git a/website/lib/firebase-client.ts b/website/lib/firebase-client.ts
index 9b43fba..0c298a7 100644
--- a/website/lib/firebase-client.ts
+++ b/website/lib/firebase-client.ts
@@ -1,8 +1,26 @@
import { getApp, getApps, initializeApp } from 'firebase/app';
-import { getAuth, GoogleAuthProvider } from 'firebase/auth';
-import { getFirestore } from 'firebase/firestore';
+import { connectAuthEmulator, getAuth, GoogleAuthProvider } from 'firebase/auth';
+import { connectFirestoreEmulator, getFirestore } from 'firebase/firestore';
-const app = getApps().length ? getApp() : initializeApp({
+// Explicit local QA opt-in. A separate app/project prevents reusing a rider's
+// production session, and loopback endpoints cannot send fixtures to production.
+const useEmulators = process.env.NEXT_PUBLIC_FIREBASE_EMULATORS === 'true';
+const qaProject = process.env.NEXT_PUBLIC_FIREBASE_QA_PROJECT_ID;
+const authEmulatorPort = Number(process.env.NEXT_PUBLIC_FIREBASE_AUTH_EMULATOR_PORT ?? 9099);
+const firestoreEmulatorPort = Number(process.env.NEXT_PUBLIC_FIREBASE_FIRESTORE_EMULATOR_PORT ?? 8088);
+if (useEmulators && (!qaProject?.startsWith('demo-') ||
+ (typeof window !== 'undefined' && !['localhost', '127.0.0.1', '[::1]'].includes(window.location.hostname)))) {
+ throw new Error('Firebase QA requires a demo- project and a loopback website origin.');
+}
+if (useEmulators && (![authEmulatorPort, firestoreEmulatorPort].every((port) =>
+ Number.isInteger(port) && port >= 1 && port <= 65535))) {
+ throw new Error('Firebase QA emulator ports must be valid TCP ports.');
+}
+const existingQaApp = getApps().find((candidate) => candidate.name === 'motorsignal-local-qa');
+
+const app = useEmulators
+ ? existingQaApp ?? initializeApp({ apiKey: 'demo-key', projectId: qaProject, authDomain: 'localhost' }, 'motorsignal-local-qa')
+ : getApps().some((candidate) => candidate.name === '[DEFAULT]') ? getApp() : initializeApp({
apiKey: 'AIzaSyD-ie8zTaAWvFUlCG5bHqRhfHKOcu4jPtg',
authDomain: 'motoapp-d22e0.firebaseapp.com',
projectId: 'motoapp-d22e0',
@@ -14,3 +32,8 @@ const app = getApps().length ? getApp() : initializeApp({
export const firebaseAuth = getAuth(app);
export const googleProvider = new GoogleAuthProvider();
export const firestore = getFirestore(app);
+
+if (useEmulators && !existingQaApp) {
+ connectAuthEmulator(firebaseAuth, `http://127.0.0.1:${authEmulatorPort}`);
+ connectFirestoreEmulator(firestore, '127.0.0.1', firestoreEmulatorPort);
+}
diff --git a/website/test-support/firebase-auth-emulator.json b/website/test-support/firebase-auth-emulator.json
new file mode 100644
index 0000000..7a7b4aa
--- /dev/null
+++ b/website/test-support/firebase-auth-emulator.json
@@ -0,0 +1,11 @@
+{
+ "firestore": {
+ "rules": "qa-firestore.rules"
+ },
+ "emulators": {
+ "auth": { "host": "127.0.0.1", "port": 9199 },
+ "firestore": { "host": "127.0.0.1", "port": 8188 },
+ "ui": { "enabled": false },
+ "singleProjectMode": false
+ }
+}
diff --git a/website/test-support/qa-auth-backend.mjs b/website/test-support/qa-auth-backend.mjs
new file mode 100644
index 0000000..5342941
--- /dev/null
+++ b/website/test-support/qa-auth-backend.mjs
@@ -0,0 +1,78 @@
+import { createServer } from 'node:http';
+import { randomBytes } from 'node:crypto';
+import { initializeApp, getApps } from 'firebase-admin/app';
+import { getAuth } from 'firebase-admin/auth';
+
+const projectId = process.env.FIREBASE_PROJECT_ID;
+const authEmulatorHost = process.env.FIREBASE_AUTH_EMULATOR_HOST;
+const port = Number(process.env.QA_AUTH_BACKEND_PORT ?? 8189);
+if (!projectId?.startsWith('demo-') || !authEmulatorHost?.startsWith('127.0.0.1:')) {
+ throw new Error('QA auth backend requires a demo project and loopback Auth emulator.');
+}
+
+const app = getApps()[0] ?? initializeApp({ projectId });
+const auth = getAuth(app);
+const sessions = new Set();
+
+function json(response, status, body) {
+ response.writeHead(status, { 'content-type': 'application/json' });
+ response.end(JSON.stringify(body));
+}
+
+const server = createServer(async (request, response) => {
+ const url = new URL(request.url ?? '/', 'http://127.0.0.1');
+ if (url.pathname === '/api/v1/auth/firebase/login' && request.method === 'POST') {
+ let payload;
+ try { payload = JSON.parse(await new Promise((resolve, reject) => {
+ let body = '';
+ request.on('data', (chunk) => { body += chunk; if (body.length > 100_000) reject(new Error('too large')); });
+ request.on('end', () => resolve(body));
+ request.on('error', reject);
+ })); } catch {
+ console.log(JSON.stringify({ endpoint: url.pathname, status: 400, reason: 'invalid-json' }));
+ return json(response, 400, { detail: 'Invalid request body.' });
+ }
+
+ try {
+ const decoded = await auth.verifyIdToken(payload?.id_token);
+ if (decoded.aud !== projectId || decoded.iss !== `https://securetoken.google.com/${projectId}`) {
+ throw new Error('Project claims mismatch.');
+ }
+ const accessToken = `qa-session-${randomBytes(24).toString('hex')}`;
+ const refreshToken = `qa-refresh-${randomBytes(24).toString('hex')}`;
+ sessions.add(accessToken);
+ sessions.add(refreshToken);
+ console.log(JSON.stringify({ endpoint: url.pathname, status: 200, verified: true, projectId, uidPrefix: decoded.uid.slice(0, 8) }));
+ return json(response, 200, { access_token: accessToken, refresh_token: refreshToken });
+ } catch {
+ console.log(JSON.stringify({ endpoint: url.pathname, status: 401, verified: false, projectId }));
+ return json(response, 401, { detail: 'Firebase token verification failed.' });
+ }
+ }
+
+ if (url.pathname === '/api/v1/auth/login' && request.method === 'POST') {
+ console.log(JSON.stringify({ endpoint: url.pathname, status: 401, reason: 'email-auth-not-configured-in-local-fixture' }));
+ return json(response, 401, { detail: 'Email authentication is not configured in this local QA fixture.' });
+ }
+
+ if (url.pathname === '/api/v1/auth/sessions' && request.method === 'GET') {
+ const token = request.headers.authorization?.replace(/^Bearer\s+/i, '');
+ const ok = Boolean(token && sessions.has(token));
+ console.log(JSON.stringify({ endpoint: url.pathname, status: ok ? 200 : 401, cookieBearerPresent: Boolean(token), sessionMatched: ok }));
+ return ok ? json(response, 200, [{ id: 'qa-current-session', device_name: 'Isolated local browser', current: true, created_at: new Date().toISOString() }])
+ : json(response, 401, { detail: 'Authentication required.' });
+ }
+
+ if (url.pathname.startsWith('/api/v1/auth/') && ['POST', 'DELETE'].includes(request.method ?? '')) {
+ const token = request.headers.authorization?.replace(/^Bearer\s+/i, '');
+ const ok = Boolean(token && sessions.has(token));
+ console.log(JSON.stringify({ endpoint: url.pathname, status: ok ? 204 : 401, cookieBearerPresent: Boolean(token), sessionMatched: ok }));
+ response.writeHead(ok ? 204 : 401);
+ return response.end();
+ }
+
+ console.log(JSON.stringify({ endpoint: url.pathname, status: 404 }));
+ return json(response, 404, { detail: 'Local QA endpoint not found.' });
+});
+
+server.listen(port, '127.0.0.1', () => console.log(JSON.stringify({ listening: `127.0.0.1:${port}`, projectId, authEmulatorHost })));
diff --git a/website/test-support/qa-firestore.rules b/website/test-support/qa-firestore.rules
new file mode 100644
index 0000000..f51b12b
--- /dev/null
+++ b/website/test-support/qa-firestore.rules
@@ -0,0 +1,1326 @@
+rules_version = '2';
+service cloud.firestore {
+ match /databases/{database}/documents {
+ function signedIn() {
+ return request.auth != null;
+ }
+
+ function isRegisteredUser() {
+ return signedIn()
+ && (!('firebase' in request.auth.token)
+ || !('sign_in_provider' in request.auth.token.firebase)
+ || request.auth.token.firebase.sign_in_provider != 'anonymous');
+ }
+
+ function ownsUser(userId) {
+ return signedIn() && request.auth.uid == userId;
+ }
+
+ function validUserCreate(userId) {
+ return ownsUser(userId)
+ && request.resource.data.id == userId
+ && request.resource.data.keys().hasOnly([
+ 'id', 'username', 'displayName', 'profilePhotoUrl', 'avatarPreset', 'bio',
+ 'location', 'createdAt', 'updatedAt', 'privacySettings', 'preferences'
+ ]);
+ }
+
+ function validUserUpdate(userId) {
+ return ownsUser(userId)
+ && request.resource.data.id == resource.data.id
+ && request.resource.data.createdAt == resource.data.createdAt
+ && request.resource.data.keys().hasOnly([
+ 'id', 'username', 'displayName', 'profilePhotoUrl', 'avatarPreset', 'bio',
+ 'location', 'createdAt', 'updatedAt', 'privacySettings', 'preferences'
+ ]);
+ }
+
+ function validPublicProfileCreate(userId) {
+ return ownsUser(userId)
+ && request.resource.data.id == userId
+ && request.resource.data.keys().hasOnly([
+ 'id', 'username', 'displayName', 'profilePhotoUrl', 'avatarPreset', 'bio',
+ 'location', 'isSearchableByHandle', 'isProfilePublic', 'allowFriendRequests',
+ 'updatedAt'
+ ]);
+ }
+
+ function validPublicProfileUpdate(userId) {
+ return ownsUser(userId)
+ && request.resource.data.id == resource.data.id
+ && request.resource.data.keys().hasOnly([
+ 'id', 'username', 'displayName', 'profilePhotoUrl', 'avatarPreset', 'bio',
+ 'location', 'isSearchableByHandle', 'isProfilePublic', 'allowFriendRequests',
+ 'updatedAt'
+ ]);
+ }
+
+ function validRideCreate(userId, rideId) {
+ return ownsUser(userId)
+ && request.resource.data.ownerId == userId
+ && request.resource.data.id == rideId;
+ }
+
+ function validRideUpdate(userId) {
+ return ownsUser(userId)
+ && resource.data.ownerId == userId
+ && request.resource.data.ownerId == resource.data.ownerId
+ && request.resource.data.id == resource.data.id;
+ }
+
+ function isBlockedBy(sourceUid, targetUid) {
+ return exists(/databases/$(database)/documents/users/$(targetUid)/blocks/$(sourceUid));
+ }
+
+ function areMutuallyBlocked(userA, userB) {
+ return isBlockedBy(userA, userB) || isBlockedBy(userB, userA);
+ }
+
+ // --- HANDLE RESERVATIONS ---
+ // Internal uniqueness index. Read access is guarded to prevent
+ // turning handle lookup into an open user enumeration directory.
+ match /handles/{handle} {
+ allow list: if false;
+ allow get: if signedIn() && (
+ resource == null ||
+ resource.data.uid == request.auth.uid || (
+ !areMutuallyBlocked(request.auth.uid, resource.data.uid) &&
+ get(/databases/$(database)/documents/public_profiles/$(resource.data.uid)).data.isSearchableByHandle == true
+ )
+ );
+ allow create: if signedIn()
+ && request.resource.data.uid == request.auth.uid
+ && request.resource.data.handle == handle;
+ allow delete: if signedIn() && resource.data.uid == request.auth.uid;
+ allow update: if false;
+ }
+
+ // --- PUBLIC PROFILES ---
+ // Contains ONLY safe public fields. No email, tokens, rides, or billing.
+ match /public_profiles/{userId} {
+ allow list: if false;
+ allow get: if signedIn() && (
+ ownsUser(userId) || (
+ !areMutuallyBlocked(request.auth.uid, userId) &&
+ (
+ resource.data.isSearchableByHandle == true ||
+ exists(/databases/$(database)/documents/users/$(userId)/friends/$(request.auth.uid))
+ )
+ )
+ );
+ allow create: if validPublicProfileCreate(userId);
+ allow update: if validPublicProfileUpdate(userId);
+ allow delete: if ownsUser(userId);
+ }
+
+ // --- PRIVATE USER ACCOUNTS ---
+ // Strictly private to the account owner.
+ match /users/{userId} {
+ allow create: if validUserCreate(userId);
+ allow read: if ownsUser(userId);
+ allow update: if validUserUpdate(userId);
+ allow delete: if ownsUser(userId);
+
+ match /rides/{rideId} {
+ allow create: if validRideCreate(userId, rideId);
+ allow update: if validRideUpdate(userId);
+ allow list: if ownsUser(userId);
+ allow get, delete: if ownsUser(userId) && resource.data.ownerId == userId;
+
+ match /events/{eventId} {
+ allow read, write: if ownsUser(userId);
+ }
+ match /performance/{runId} {
+ allow read, write: if ownsUser(userId);
+ }
+ }
+
+ match /motorcycles/{motorcycleId} {
+ allow create, update: if ownsUser(userId)
+ && request.resource.data.ownerId == userId;
+ allow read, delete: if ownsUser(userId);
+ }
+
+ match /trips/{tripId} {
+ allow read, write: if ownsUser(userId);
+ }
+
+ match /events/{eventId} {
+ allow read, write: if ownsUser(userId);
+ }
+
+ // --- SOCIAL SUBCOLLECTIONS ---
+ match /friends/{friendId} {
+ allow read: if ownsUser(userId);
+ allow create: if signedIn()
+ && (request.auth.uid == userId || request.auth.uid == friendId)
+ && userId != friendId
+ && !areMutuallyBlocked(userId, friendId)
+ && (
+ (request.auth.uid == userId && exists(/databases/$(database)/documents/users/$(userId)/friend_requests_incoming/$(friendId))) ||
+ (request.auth.uid == friendId && exists(/databases/$(database)/documents/users/$(friendId)/friend_requests_incoming/$(userId)))
+ );
+ allow delete: if signedIn() && (request.auth.uid == userId || request.auth.uid == friendId);
+ allow update: if false;
+ }
+
+ match /friend_requests_incoming/{senderId} {
+ allow read: if ownsUser(userId);
+ allow create: if signedIn()
+ && request.auth.uid == senderId
+ && senderId != userId
+ && request.resource.data.receiverUid == userId
+ && request.resource.data.senderUid == senderId
+ && !areMutuallyBlocked(userId, senderId)
+ && !exists(/databases/$(database)/documents/users/$(userId)/friends/$(senderId))
+ && get(/databases/$(database)/documents/public_profiles/$(userId)).data.allowFriendRequests == true;
+ allow delete: if signedIn() && (request.auth.uid == userId || request.auth.uid == senderId);
+ allow update: if false;
+ }
+
+ match /friend_requests_outgoing/{receiverId} {
+ allow read: if ownsUser(userId);
+ allow create: if ownsUser(userId)
+ && receiverId != userId
+ && request.resource.data.receiverUid == receiverId
+ && request.resource.data.senderUid == userId
+ && !areMutuallyBlocked(userId, receiverId);
+ allow delete: if signedIn() && (request.auth.uid == userId || request.auth.uid == receiverId);
+ allow update: if false;
+ }
+
+ match /blocks/{blockedId} {
+ allow read, write: if ownsUser(userId);
+ }
+ }
+
+ // --- GROUP RIDES ---
+ // Top-level social object distinct from personal rides, accounts, and telemetry.
+ match /group_rides/{groupRideId} {
+ function isOrganizer() {
+ return signedIn() && 'organizerUid' in resource.data && resource.data.organizerUid == request.auth.uid;
+ }
+
+ function hasParticipantAccess(uid) {
+ return exists(/databases/$(database)/documents/group_rides/$(groupRideId)/participants/$(uid)) &&
+ get(/databases/$(database)/documents/group_rides/$(groupRideId)/participants/$(uid)).data.status in ['invited', 'accepted'];
+ }
+
+ function isFriendOfOrganizer() {
+ return signedIn() && exists(/databases/$(database)/documents/users/$(resource.data.organizerUid)/friends/$(request.auth.uid));
+ }
+
+ function hasClubAffiliation(data) {
+ return 'clubId' in data && data.clubId != null;
+ }
+
+ function validClubId(clubId) {
+ return clubId is string && clubId.size() >= 1 && clubId.size() <= 64 && clubId.matches('^\\S+$');
+ }
+
+ function clubExists(clubId) {
+ return exists(/databases/$(database)/documents/clubs/$(clubId));
+ }
+
+ function clubIsActive(clubId) {
+ return clubExists(clubId) &&
+ (!('archivedAt' in get(/databases/$(database)/documents/clubs/$(clubId)).data) ||
+ get(/databases/$(database)/documents/clubs/$(clubId)).data.archivedAt == null);
+ }
+
+ function hasClubMemberDoc(clubId, uid) {
+ return exists(/databases/$(database)/documents/clubs/$(clubId)/members/$(uid));
+ }
+
+ function getClubMemberData(clubId, uid) {
+ return get(/databases/$(database)/documents/clubs/$(clubId)/members/$(uid)).data;
+ }
+
+ function isActiveClubMember(clubId, uid) {
+ return isRegisteredUser() &&
+ hasClubMemberDoc(clubId, uid) &&
+ getClubMemberData(clubId, uid).status == 'active';
+ }
+
+ function isActiveClubAdmin(clubId, uid) {
+ return isActiveClubMember(clubId, uid) &&
+ getClubMemberData(clubId, uid).role in ['owner', 'admin'];
+ }
+
+ function sameClubAffiliation(prevData, nextData) {
+ let prevHas = hasClubAffiliation(prevData);
+ let nextHas = hasClubAffiliation(nextData);
+ return (!prevHas && !nextHas) ||
+ (prevHas && nextHas && prevData.clubId == nextData.clubId);
+ }
+
+ function isClubRideMember() {
+ return hasClubAffiliation(resource.data) &&
+ isActiveClubMember(resource.data.clubId, request.auth.uid);
+ }
+
+ function isClubRideAdmin() {
+ return hasClubAffiliation(resource.data) &&
+ isActiveClubAdmin(resource.data.clubId, request.auth.uid);
+ }
+
+ function canReadRide() {
+ return signedIn() && (
+ isOrganizer() ||
+ isClubRideAdmin() ||
+ (
+ !areMutuallyBlocked(request.auth.uid, resource.data.organizerUid) && (
+ hasParticipantAccess(request.auth.uid) ||
+ resource.data.visibility == 'public' ||
+ (resource.data.visibility == 'friendsOnly' && isFriendOfOrganizer()) ||
+ isClubRideMember()
+ )
+ )
+ );
+ }
+
+ allow get: if canReadRide();
+
+ allow list: if signedIn() && (
+ (
+ 'organizerUid' in resource.data &&
+ resource.data.organizerUid == request.auth.uid
+ ) ||
+ (
+ 'accessUids' in resource.data &&
+ request.auth.uid in resource.data.accessUids
+ ) ||
+ (
+ hasClubAffiliation(resource.data) &&
+ isActiveClubAdmin(resource.data.clubId, request.auth.uid)
+ )
+ );
+
+ allow create: if signedIn()
+ && request.resource.data.organizerUid == request.auth.uid
+ && request.resource.data.id == groupRideId
+ && request.resource.data.keys().hasAll(['id', 'organizerUid', 'title', 'scheduledStartAt', 'departureAt', 'meetingPoint', 'accessUids'])
+ && request.resource.data.accessUids == [request.auth.uid]
+ && request.resource.data.participantCount == 1
+ && (
+ !('routeId' in request.resource.data) ||
+ request.resource.data.routeId == null ||
+ (
+ exists(/databases/$(database)/documents/routes/$(request.resource.data.routeId)) &&
+ get(/databases/$(database)/documents/routes/$(request.resource.data.routeId)).data.ownerUid == request.auth.uid
+ )
+ )
+ && (
+ !hasClubAffiliation(request.resource.data) ||
+ (
+ validClubId(request.resource.data.clubId) &&
+ clubIsActive(request.resource.data.clubId) &&
+ isActiveClubAdmin(request.resource.data.clubId, request.auth.uid)
+ )
+ );
+
+ function validStatusTransition() {
+ let prev = 'status' in resource.data ? resource.data.status : 'scheduled';
+ let next = 'status' in request.resource.data ? request.resource.data.status : prev;
+ return prev in ['scheduled', 'assembling', 'underway'] && (
+ prev == next ||
+ (prev == 'scheduled' && next in ['assembling', 'underway', 'canceled']) ||
+ (prev == 'assembling' && next in ['underway', 'canceled']) ||
+ (prev == 'underway' && next in ['completed', 'canceled'])
+ );
+ }
+
+ function validRouteAttachment() {
+ return !('routeId' in request.resource.data) ||
+ request.resource.data.routeId == null ||
+ (
+ (resource != null && 'routeId' in resource.data && request.resource.data.routeId == resource.data.routeId) ||
+ (
+ exists(/databases/$(database)/documents/routes/$(request.resource.data.routeId)) &&
+ get(/databases/$(database)/documents/routes/$(request.resource.data.routeId)).data.ownerUid == request.auth.uid
+ )
+ );
+ }
+
+ function getRideAccessUids(data) {
+ return 'accessUids' in data ? data.accessUids : ('organizerUid' in data ? [data.organizerUid] : []);
+ }
+
+ function getRideCount(data) {
+ return 'participantCount' in data ? data.participantCount : 1;
+ }
+
+ function rideWithinCapacity(data, nextCount) {
+ return !('participantLimit' in data) ||
+ data.participantLimit == null ||
+ nextCount <= data.participantLimit;
+ }
+
+ function validOrganizerRideUpdate() {
+ let prevAccess = getRideAccessUids(resource.data);
+ let nextAccess = getRideAccessUids(request.resource.data);
+ let prevCount = getRideCount(resource.data);
+ let nextCount = getRideCount(request.resource.data);
+ let added = nextAccess.removeAll(prevAccess);
+ let removed = prevAccess.removeAll(nextAccess);
+ let countDelta = nextCount - prevCount;
+
+ return validStatusTransition() && validRouteAttachment() &&
+ rideWithinCapacity(request.resource.data, nextCount) && (
+ // General metadata or status update: access and count unchanged
+ (added.size() == 0 && removed.size() == 0 && countDelta == 0) ||
+ // Organizer approval or open join: exactly +1 accessUid, count +1, only in scheduled/assembling
+ (
+ ('status' in resource.data && resource.data.status in ['scheduled', 'assembling']) &&
+ added.size() == 1 && removed.size() == 0 && countDelta == 1 &&
+ !areMutuallyBlocked(resource.data.organizerUid, added[0]) &&
+ (!hasClubAffiliation(resource.data) || isActiveClubMember(resource.data.clubId, added[0])) &&
+ existsAfter(/databases/$(database)/documents/group_rides/$(groupRideId)/participants/$(added[0])) &&
+ getAfter(/databases/$(database)/documents/group_rides/$(groupRideId)/participants/$(added[0])).data.status == 'accepted'
+ ) ||
+ // Organizer invitation: exactly +1 accessUid, count unchanged (countDelta == 0), only in scheduled/assembling
+ (
+ ('status' in resource.data && resource.data.status in ['scheduled', 'assembling']) &&
+ added.size() == 1 && removed.size() == 0 && countDelta == 0 &&
+ !areMutuallyBlocked(resource.data.organizerUid, added[0]) &&
+ existsAfter(/databases/$(database)/documents/group_rides/$(groupRideId)/participants/$(added[0])) &&
+ getAfter(/databases/$(database)/documents/group_rides/$(groupRideId)/participants/$(added[0])).data.status == 'invited'
+ ) ||
+ // Organizer removal: exactly -1 accessUid, count -1, only in scheduled/assembling/underway
+ (
+ ('status' in resource.data && resource.data.status in ['scheduled', 'assembling', 'underway']) &&
+ added.size() == 0 && removed.size() == 1 && countDelta == -1 &&
+ getAfter(/databases/$(database)/documents/group_rides/$(groupRideId)/participants/$(removed[0])).data.status in ['removed', 'left']
+ )
+ );
+ }
+
+ function validParticipantOpenJoin(added, removed, countDelta, nextCount) {
+ return resource.data.joinPolicy == 'open' &&
+ ('status' in resource.data && resource.data.status in ['scheduled', 'assembling']) &&
+ request.auth.uid != resource.data.organizerUid &&
+ added == [request.auth.uid] &&
+ removed.size() == 0 &&
+ countDelta == 1 &&
+ rideWithinCapacity(resource.data, nextCount) &&
+ !areMutuallyBlocked(request.auth.uid, resource.data.organizerUid) &&
+ (!hasClubAffiliation(resource.data) || isActiveClubMember(resource.data.clubId, request.auth.uid)) &&
+ existsAfter(/databases/$(database)/documents/group_rides/$(groupRideId)/participants/$(request.auth.uid)) &&
+ getAfter(/databases/$(database)/documents/group_rides/$(groupRideId)/participants/$(request.auth.uid)).data.status == 'accepted';
+ }
+
+ function validParticipantAcceptInvite(prevAccess, added, removed, countDelta, nextCount) {
+ return ('status' in resource.data && resource.data.status in ['scheduled', 'assembling']) &&
+ request.auth.uid in prevAccess &&
+ added.size() == 0 &&
+ removed.size() == 0 &&
+ countDelta == 1 &&
+ rideWithinCapacity(resource.data, nextCount) &&
+ existsAfter(/databases/$(database)/documents/group_rides/$(groupRideId)/participants/$(request.auth.uid)) &&
+ getAfter(/databases/$(database)/documents/group_rides/$(groupRideId)/participants/$(request.auth.uid)).data.status == 'accepted';
+ }
+
+ function validParticipantLeave(prevAccess, added, removed, countDelta) {
+ return ('status' in resource.data && resource.data.status in ['scheduled', 'assembling', 'underway']) &&
+ request.auth.uid != resource.data.organizerUid &&
+ added.size() == 0 &&
+ removed == [request.auth.uid] &&
+ countDelta == -1 &&
+ getAfter(/databases/$(database)/documents/group_rides/$(groupRideId)/participants/$(request.auth.uid)).data.status == 'left';
+ }
+
+ function validParticipantDeclineInvite(prevAccess, added, removed, countDelta) {
+ return ('status' in resource.data && resource.data.status in ['scheduled', 'assembling', 'underway']) &&
+ request.auth.uid != resource.data.organizerUid &&
+ added.size() == 0 &&
+ (removed.size() == 0 || removed == [request.auth.uid]) &&
+ countDelta == 0 &&
+ getAfter(/databases/$(database)/documents/group_rides/$(groupRideId)/participants/$(request.auth.uid)).data.status == 'declined';
+ }
+
+ function validParticipantRideUpdate() {
+ let prevAccess = getRideAccessUids(resource.data);
+ let nextAccess = getRideAccessUids(request.resource.data);
+ let prevCount = getRideCount(resource.data);
+ let nextCount = getRideCount(request.resource.data);
+ let added = nextAccess.removeAll(prevAccess);
+ let removed = prevAccess.removeAll(nextAccess);
+ let countDelta = nextCount - prevCount;
+
+ return request.resource.data.diff(resource.data).affectedKeys().hasOnly(['accessUids', 'participantCount', 'updatedAt']) &&
+ canReadRide() &&
+ (
+ validParticipantOpenJoin(added, removed, countDelta, nextCount) ||
+ validParticipantAcceptInvite(prevAccess, added, removed, countDelta, nextCount) ||
+ validParticipantLeave(prevAccess, added, removed, countDelta) ||
+ validParticipantDeclineInvite(prevAccess, added, removed, countDelta)
+ );
+ }
+
+ function validClubAdminModeration(prevStatus, nextStatus) {
+ let prevAccess = getRideAccessUids(resource.data);
+ let nextAccess = getRideAccessUids(request.resource.data);
+ let prevCount = getRideCount(resource.data);
+ let nextCount = getRideCount(request.resource.data);
+ let added = nextAccess.removeAll(prevAccess);
+ let removed = prevAccess.removeAll(nextAccess);
+ let countDelta = nextCount - prevCount;
+
+ return request.resource.data.diff(resource.data).affectedKeys().hasOnly([
+ 'accessUids', 'participantCount', 'updatedAt'
+ ]) && (
+ // Approval: count +1, access added 1, only in scheduled or assembling
+ (
+ prevStatus in ['scheduled', 'assembling'] &&
+ nextStatus == prevStatus &&
+ added.size() == 1 &&
+ removed.size() == 0 &&
+ countDelta == 1 &&
+ rideWithinCapacity(resource.data, nextCount) &&
+ !areMutuallyBlocked(resource.data.organizerUid, added[0]) &&
+ isActiveClubMember(resource.data.clubId, added[0]) &&
+ existsAfter(/databases/$(database)/documents/group_rides/$(groupRideId)/participants/$(added[0])) &&
+ getAfter(/databases/$(database)/documents/group_rides/$(groupRideId)/participants/$(added[0])).data.status == 'accepted'
+ ) ||
+ // Removal: count -1, access removed 1, only in scheduled, assembling, or underway
+ (
+ prevStatus in ['scheduled', 'assembling', 'underway'] &&
+ nextStatus == prevStatus &&
+ added.size() == 0 &&
+ removed.size() == 1 &&
+ countDelta == -1 &&
+ removed[0] != resource.data.organizerUid &&
+ getAfter(/databases/$(database)/documents/group_rides/$(groupRideId)/participants/$(removed[0])).data.status in ['removed', 'left']
+ )
+ );
+ }
+
+ function validClubAdminRideUpdate() {
+ let clubId = resource.data.clubId;
+ let prevStatus = 'status' in resource.data ? resource.data.status : null;
+ let nextStatus = 'status' in request.resource.data ? request.resource.data.status : prevStatus;
+
+ return isActiveClubAdmin(clubId, request.auth.uid) && prevStatus != null && (
+ // Sub-case A: Scheduled metadata edit
+ (
+ prevStatus == 'scheduled' &&
+ nextStatus == 'scheduled' &&
+ request.resource.data.diff(resource.data).affectedKeys().hasOnly([
+ 'title', 'description', 'visibility', 'joinPolicy',
+ 'scheduledStartAt', 'departureAt', 'participantLimit',
+ 'meetingPoint', 'destination', 'stops',
+ 'routeId', 'routeTitle', 'routeDistanceMeters', 'routeStopCount',
+ 'updatedAt'
+ ]) &&
+ validRouteAttachment()
+ ) ||
+ // Sub-case B: Dedicated narrow admin cancellation (scheduled or assembling only)
+ (
+ prevStatus in ['scheduled', 'assembling'] &&
+ nextStatus == 'canceled' &&
+ request.resource.data.diff(resource.data).affectedKeys().hasOnly([
+ 'status', 'canceledAt', 'canceledReason', 'updatedAt'
+ ])
+ ) ||
+ // Sub-case C: Club admin participant moderation (approve, remove)
+ validClubAdminModeration(prevStatus, nextStatus)
+ );
+ }
+
+ allow update: if signedIn() &&
+ ('status' in resource.data && !(resource.data.status in ['completed', 'canceled'])) &&
+ sameClubAffiliation(resource.data, request.resource.data) &&
+ (
+ (isOrganizer() && validOrganizerRideUpdate()) ||
+ validParticipantRideUpdate() ||
+ (hasClubAffiliation(resource.data) && validClubAdminRideUpdate())
+ );
+
+ allow delete: if isOrganizer();
+
+ // --- PARTICIPANTS SUBCOLLECTION ---
+ match /participants/{participantUid} {
+ function rideDoc() {
+ return exists(/databases/$(database)/documents/group_rides/$(groupRideId))
+ ? get(/databases/$(database)/documents/group_rides/$(groupRideId))
+ : (existsAfter(/databases/$(database)/documents/group_rides/$(groupRideId))
+ ? getAfter(/databases/$(database)/documents/group_rides/$(groupRideId))
+ : null);
+ }
+
+ function rideOrganizer() {
+ return rideDoc().data.organizerUid;
+ }
+
+ function isAuthorizedModerator() {
+ return request.auth.uid == rideOrganizer() ||
+ (hasClubAffiliation(rideDoc().data) && isActiveClubAdmin(rideDoc().data.clubId, request.auth.uid));
+ }
+
+ function isAcceptedParticipant(uid) {
+ return exists(/databases/$(database)/documents/group_rides/$(groupRideId)/participants/$(uid)) &&
+ get(/databases/$(database)/documents/group_rides/$(groupRideId)/participants/$(uid)).data.status == 'accepted';
+ }
+
+ // Collection listing is strictly disallowed (point-read architecture over known accessUids)
+ allow list: if false;
+
+ // Single-document read: self, moderator, or accepted peer (if not mutually blocked)
+ allow get: if signedIn() && (
+ request.auth.uid == participantUid ||
+ isAuthorizedModerator() ||
+ (
+ isAcceptedParticipant(request.auth.uid) &&
+ !areMutuallyBlocked(request.auth.uid, participantUid)
+ )
+ );
+
+ // Organizer can invite riders; caller can request to join or join open ride; caller can initialize own organizer record on create
+ allow create: if signedIn() && (
+ // Self-organizer initialization on ride create
+ (
+ request.auth.uid == participantUid &&
+ request.auth.uid == rideOrganizer() &&
+ request.resource.data.userId == request.auth.uid &&
+ request.resource.data.role == 'organizer' &&
+ request.resource.data.status == 'accepted' &&
+ request.resource.data.keys().hasAll(['userId', 'role', 'status']) &&
+ request.resource.data.keys().hasOnly([
+ 'userId', 'role', 'status',
+ 'riderHandle', 'riderDisplayName', 'riderAvatarPreset', 'riderAvatarUrl',
+ 'createdAt', 'updatedAt'
+ ])
+ ) ||
+ // Organizer inviting a non-blocked rider (scheduled or assembling, within capacity)
+ (
+ request.auth.uid == rideOrganizer() &&
+ ('status' in rideDoc().data && rideDoc().data.status in ['scheduled', 'assembling']) &&
+ (!('participantLimit' in rideDoc().data) || rideDoc().data.participantLimit == null || rideDoc().data.participantCount < rideDoc().data.participantLimit) &&
+ request.resource.data.userId == participantUid &&
+ request.resource.data.status == 'invited' &&
+ request.resource.data.role == 'participant' &&
+ !areMutuallyBlocked(request.auth.uid, participantUid) &&
+ request.resource.data.keys().hasAll(['userId', 'role', 'status']) &&
+ request.resource.data.keys().hasOnly([
+ 'userId', 'role', 'status',
+ 'riderHandle', 'riderDisplayName', 'riderAvatarPreset', 'riderAvatarUrl',
+ 'invitedBy', 'createdAt', 'updatedAt'
+ ])
+ ) ||
+ // Rider requesting to join (joinPolicy == requestToJoin) - SINGLE DOCUMENT WRITE, NO PARENT MUTATION
+ (
+ request.auth.uid == participantUid &&
+ request.auth.uid != rideOrganizer() &&
+ request.resource.data.userId == request.auth.uid &&
+ request.resource.data.role == 'participant' &&
+ request.resource.data.status == 'requested' &&
+ rideDoc().data.joinPolicy == 'requestToJoin' &&
+ ('status' in rideDoc().data && rideDoc().data.status in ['scheduled', 'assembling']) &&
+ !areMutuallyBlocked(request.auth.uid, rideOrganizer()) &&
+ (!hasClubAffiliation(rideDoc().data) || isActiveClubMember(rideDoc().data.clubId, request.auth.uid)) &&
+ request.resource.data.keys().hasAll(['userId', 'role', 'status']) &&
+ request.resource.data.keys().hasOnly([
+ 'userId', 'role', 'status',
+ 'riderHandle', 'riderDisplayName', 'riderAvatarPreset', 'riderAvatarUrl',
+ 'createdAt', 'updatedAt'
+ ])
+ ) ||
+ // Rider joining open ride (joinPolicy == open) - Cross-document bound to parent
+ (
+ request.auth.uid == participantUid &&
+ request.auth.uid != rideOrganizer() &&
+ request.resource.data.userId == request.auth.uid &&
+ request.resource.data.role == 'participant' &&
+ request.resource.data.status == 'accepted' &&
+ rideDoc().data.joinPolicy == 'open' &&
+ ('status' in rideDoc().data && rideDoc().data.status in ['scheduled', 'assembling']) &&
+ (!('participantLimit' in rideDoc().data) || rideDoc().data.participantLimit == null || rideDoc().data.participantCount < rideDoc().data.participantLimit) &&
+ (!hasClubAffiliation(rideDoc().data) || isActiveClubMember(rideDoc().data.clubId, request.auth.uid)) &&
+ !areMutuallyBlocked(request.auth.uid, rideOrganizer()) &&
+ request.auth.uid in getAfter(/databases/$(database)/documents/group_rides/$(groupRideId)).data.accessUids &&
+ getAfter(/databases/$(database)/documents/group_rides/$(groupRideId)).data.participantCount == rideDoc().data.participantCount + 1 &&
+ request.resource.data.keys().hasAll(['userId', 'role', 'status']) &&
+ request.resource.data.keys().hasOnly([
+ 'userId', 'role', 'status',
+ 'riderHandle', 'riderDisplayName', 'riderAvatarPreset', 'riderAvatarUrl',
+ 'createdAt', 'updatedAt'
+ ])
+ )
+ );
+
+ // State transitions: participant can accept/decline/leave; moderator can approve/decline/remove or organizer can transfer
+ allow update: if signedIn() && (
+ // Status updates must strictly constrain changed keys to status and updatedAt
+ (
+ request.resource.data.diff(resource.data).affectedKeys().hasOnly(['status', 'updatedAt']) && (
+ // Invitee accepting own invitation (invited -> accepted)
+ (
+ request.auth.uid == participantUid &&
+ resource.data.status == 'invited' &&
+ ('status' in rideDoc().data && rideDoc().data.status in ['scheduled', 'assembling']) &&
+ (!('participantLimit' in rideDoc().data) || rideDoc().data.participantLimit == null || rideDoc().data.participantCount < rideDoc().data.participantLimit) &&
+ request.resource.data.status == 'accepted' &&
+ request.resource.data.role == resource.data.role &&
+ participantUid in getAfter(/databases/$(database)/documents/group_rides/$(groupRideId)).data.accessUids &&
+ getAfter(/databases/$(database)/documents/group_rides/$(groupRideId)).data.participantCount == rideDoc().data.participantCount + 1
+ ) ||
+ // Invitee declining own invitation (invited -> declined)
+ (
+ request.auth.uid == participantUid &&
+ resource.data.status == 'invited' &&
+ ('status' in rideDoc().data && rideDoc().data.status in ['scheduled', 'assembling', 'underway']) &&
+ request.resource.data.status == 'declined' &&
+ request.resource.data.role == resource.data.role
+ ) ||
+ // Accepted participant leaving (accepted -> left) - Cross-document bound
+ (
+ request.auth.uid == participantUid &&
+ resource.data.status == 'accepted' &&
+ ('status' in rideDoc().data && rideDoc().data.status in ['scheduled', 'assembling', 'underway']) &&
+ request.resource.data.status == 'left' &&
+ request.resource.data.role == resource.data.role &&
+ !(participantUid in getAfter(/databases/$(database)/documents/group_rides/$(groupRideId)).data.accessUids) &&
+ getAfter(/databases/$(database)/documents/group_rides/$(groupRideId)).data.participantCount == rideDoc().data.participantCount - 1
+ ) ||
+ // Requested rider withdrawing request (requested -> left) - Single document update
+ (
+ request.auth.uid == participantUid &&
+ resource.data.status == 'requested' &&
+ ('status' in rideDoc().data && rideDoc().data.status in ['scheduled', 'assembling', 'underway']) &&
+ request.resource.data.status == 'left' &&
+ request.resource.data.role == resource.data.role
+ ) ||
+ // Authorized moderator approving request (requested -> accepted) - Cross-document bound
+ (
+ isAuthorizedModerator() &&
+ resource.data.status == 'requested' &&
+ ('status' in rideDoc().data && rideDoc().data.status in ['scheduled', 'assembling']) &&
+ (!('participantLimit' in rideDoc().data) || rideDoc().data.participantLimit == null || rideDoc().data.participantCount < rideDoc().data.participantLimit) &&
+ (!hasClubAffiliation(rideDoc().data) || isActiveClubMember(rideDoc().data.clubId, participantUid)) &&
+ !areMutuallyBlocked(rideOrganizer(), participantUid) &&
+ request.resource.data.status == 'accepted' &&
+ request.resource.data.role == resource.data.role &&
+ participantUid in getAfter(/databases/$(database)/documents/group_rides/$(groupRideId)).data.accessUids &&
+ getAfter(/databases/$(database)/documents/group_rides/$(groupRideId)).data.participantCount == rideDoc().data.participantCount + 1
+ ) ||
+ // Authorized moderator declining request (requested -> declined) - Single document update
+ (
+ isAuthorizedModerator() &&
+ resource.data.status == 'requested' &&
+ ('status' in rideDoc().data && rideDoc().data.status in ['scheduled', 'assembling', 'underway']) &&
+ request.resource.data.status == 'declined' &&
+ request.resource.data.role == resource.data.role
+ ) ||
+ // Authorized moderator removing participant (status -> removed) - Cross-document bound
+ (
+ isAuthorizedModerator() &&
+ participantUid != rideOrganizer() &&
+ resource.data.role != 'organizer' &&
+ resource.data.status == 'accepted' &&
+ ('status' in rideDoc().data && rideDoc().data.status in ['scheduled', 'assembling', 'underway']) &&
+ request.resource.data.status == 'removed' &&
+ request.resource.data.role == resource.data.role &&
+ !(participantUid in getAfter(/databases/$(database)/documents/group_rides/$(groupRideId)).data.accessUids) &&
+ getAfter(/databases/$(database)/documents/group_rides/$(groupRideId)).data.participantCount == rideDoc().data.participantCount - 1
+ )
+ )
+ ) ||
+ // Organizer role transfer
+ (
+ request.auth.uid == rideOrganizer() &&
+ resource.data.status == 'accepted' &&
+ request.resource.data.status == resource.data.status &&
+ ('status' in rideDoc().data && rideDoc().data.status in ['scheduled', 'assembling']) &&
+ request.resource.data.role in ['organizer', 'participant'] &&
+ request.resource.data.diff(resource.data).affectedKeys().hasOnly(['role', 'updatedAt'])
+ )
+ );
+
+ allow delete: if signedIn() &&
+ resource.data.role != 'organizer' &&
+ resource.data.status != 'accepted' &&
+ (
+ request.auth.uid == participantUid ||
+ isAuthorizedModerator()
+ );
+ }
+
+ // --- LIVE PRESENCE SUBCOLLECTION ---
+ // Ephemeral location and status sharing during an active or assembling ride.
+ match /live_presence/{presenceUid} {
+ function rideDoc() {
+ return exists(/databases/$(database)/documents/group_rides/$(groupRideId))
+ ? get(/databases/$(database)/documents/group_rides/$(groupRideId))
+ : (existsAfter(/databases/$(database)/documents/group_rides/$(groupRideId))
+ ? getAfter(/databases/$(database)/documents/group_rides/$(groupRideId))
+ : null);
+ }
+
+ function isAcceptedParticipant(uid) {
+ return exists(/databases/$(database)/documents/group_rides/$(groupRideId)/participants/$(uid)) &&
+ get(/databases/$(database)/documents/group_rides/$(groupRideId)/participants/$(uid)).data.status == 'accepted';
+ }
+
+ function rideIsLiveEligible() {
+ return 'status' in rideDoc().data && rideDoc().data.status in ['scheduled', 'assembling', 'underway'];
+ }
+
+ function validCoordinates(data) {
+ return data.latitude >= -90.0 && data.latitude <= 90.0 &&
+ data.longitude >= -180.0 && data.longitude <= 180.0;
+ }
+
+ function validPresenceKeys(data) {
+ return data.keys().hasAll(['userId', 'groupRideId', 'latitude', 'longitude', 'updatedAt', 'expiresAt', 'sharingEnabled', 'status']) &&
+ data.keys().hasOnly(['userId', 'groupRideId', 'latitude', 'longitude', 'heading', 'speedMph', 'accuracyMeters', 'updatedAt', 'expiresAt', 'sharingEnabled', 'status']);
+ }
+
+ // Collection listing is strictly disallowed
+ allow list: if false;
+
+ // READ (SINGLE DOCUMENT GET ONLY):
+ // Self access: permitted as required by own-presence lifecycle / cleanup.
+ // Peer read: caller accepted, target accepted, ride live-eligible, sharing enabled, not mutually blocked.
+ allow get: if signedIn() && (
+ request.auth.uid == presenceUid ||
+ (
+ isAcceptedParticipant(request.auth.uid) &&
+ isAcceptedParticipant(presenceUid) &&
+ rideIsLiveEligible() &&
+ (resource == null || resource.data.sharingEnabled == true) &&
+ !areMutuallyBlocked(request.auth.uid, presenceUid)
+ )
+ );
+
+ // WRITE (CREATE/UPDATE):
+ // Caller must write only their own document, must be an accepted participant,
+ // ride must be live-eligible, valid coordinates, valid schema keys,
+ // request.resource.data.userId == request.auth.uid and groupRideId == groupRideId.
+ allow create, update: if signedIn()
+ && request.auth.uid == presenceUid
+ && request.resource.data.userId == request.auth.uid
+ && request.resource.data.groupRideId == groupRideId
+ && isAcceptedParticipant(request.auth.uid)
+ && rideIsLiveEligible()
+ && validCoordinates(request.resource.data)
+ && validPresenceKeys(request.resource.data);
+
+ // DELETE:
+ // Rider can delete their own live presence record, or ride organizer during cleanup.
+ allow delete: if signedIn() && (
+ request.auth.uid == presenceUid ||
+ request.auth.uid == rideDoc().data.organizerUid
+ );
+ }
+ }
+
+ // --- ROUTES ---
+ // User-created motorcycle routes (standalone or attached to group rides).
+ match /routes/{routeId} {
+ function isRouteOwner() {
+ return signedIn() && resource.data.ownerUid == request.auth.uid;
+ }
+
+ function isAcceptedGroupParticipant(groupRideId, uid) {
+ return exists(/databases/$(database)/documents/group_rides/$(groupRideId)/participants/$(uid)) &&
+ get(/databases/$(database)/documents/group_rides/$(groupRideId)/participants/$(uid)).data.status == 'accepted';
+ }
+
+ function canReadRoute() {
+ return signedIn() && (
+ isRouteOwner() ||
+ (
+ !areMutuallyBlocked(request.auth.uid, resource.data.ownerUid) && (
+ resource.data.visibility == 'public' ||
+ (
+ resource.data.visibility == 'groupOnly' &&
+ resource.data.attachedGroupRideId != null &&
+ isAcceptedGroupParticipant(resource.data.attachedGroupRideId, request.auth.uid)
+ )
+ )
+ )
+ );
+ }
+
+ // Validates embedded stop representation.
+ // Note: Client/domain validation additionally enforces unique stop IDs, sequential 0..N-1 indexing,
+ // and exhaustive validation of all stops across large routes.
+ // Exhaustive checking of all stops across large arrays is bounded in Firestore Rules due to expression limits.
+ function validStop(stop) {
+ return stop is map
+ && stop.id is string && stop.id.size() >= 1 && stop.id.size() <= 64
+ && stop.displayName is string && stop.displayName.size() >= 1 && stop.displayName.size() <= 80
+ && (stop.latitude is int || stop.latitude is float) && stop.latitude >= -90.0 && stop.latitude <= 90.0
+ && (stop.longitude is int || stop.longitude is float) && stop.longitude >= -180.0 && stop.longitude <= 180.0
+ && (
+ ('stopType' in stop && stop.stopType in ['start', 'origin', 'meetup', 'waypoint', 'fuel', 'rest', 'food', 'scenic', 'regroup', 'destination', 'other']) ||
+ ('type' in stop && stop.type in ['start', 'origin', 'meetup', 'waypoint', 'fuel', 'rest', 'food', 'scenic', 'regroup', 'destination', 'other'])
+ );
+ }
+
+ function validStops(stops) {
+ return stops is list
+ && stops.size() <= 25
+ && (stops.size() < 1 || validStop(stops[0]))
+ && (stops.size() < 2 || validStop(stops[1]))
+ && (stops.size() < 3 || validStop(stops[2]))
+ && (stops.size() < 4 || validStop(stops[3]))
+ && (stops.size() < 5 || validStop(stops[4]));
+ }
+
+ function validRouteKeys(data) {
+ return data.keys().hasAll(['id', 'ownerUid', 'title', 'stops', 'createdAt', 'updatedAt'])
+ && data.keys().hasOnly([
+ 'id', 'ownerUid', 'ownerHandle', 'title', 'description',
+ 'visibility', 'stops', 'straightLineDistanceMeters',
+ 'estimatedDistanceMeters', 'estimatedDurationSeconds',
+ 'source', 'version', 'attachedGroupRideId', 'createdAt', 'updatedAt'
+ ]);
+ }
+
+ function validRouteCommon(data) {
+ return validRouteKeys(data)
+ && data.title is string
+ && data.title.size() >= 3
+ && data.title.size() <= 80
+ && (!('ownerHandle' in data) || (data.ownerHandle is string && data.ownerHandle.size() <= 40))
+ && (!('description' in data) || (data.description is string && data.description.size() <= 500))
+ && (!('visibility' in data) || data.visibility in ['private', 'groupOnly', 'public'])
+ && (
+ !('visibility' in data) ||
+ data.visibility != 'groupOnly' ||
+ (('attachedGroupRideId' in data) && data.attachedGroupRideId is string && data.attachedGroupRideId.size() > 0)
+ )
+ && (
+ !('attachedGroupRideId' in data) ||
+ data.attachedGroupRideId == null ||
+ (
+ data.attachedGroupRideId is string &&
+ data.attachedGroupRideId.size() > 0 &&
+ data.attachedGroupRideId.size() <= 64 &&
+ exists(/databases/$(database)/documents/group_rides/$(data.attachedGroupRideId)) &&
+ get(/databases/$(database)/documents/group_rides/$(data.attachedGroupRideId)).data.organizerUid == request.auth.uid
+ )
+ )
+ && (!('straightLineDistanceMeters' in data) || ((data.straightLineDistanceMeters is int || data.straightLineDistanceMeters is float) && data.straightLineDistanceMeters >= 0))
+ && (!('estimatedDistanceMeters' in data) || data.estimatedDistanceMeters == null || ((data.estimatedDistanceMeters is int || data.estimatedDistanceMeters is float) && data.estimatedDistanceMeters >= 0))
+ && (!('estimatedDurationSeconds' in data) || data.estimatedDurationSeconds == null || (data.estimatedDurationSeconds is int && data.estimatedDurationSeconds >= 0))
+ && (!('source' in data) || data.source in ['manual', 'planner', 'gpxImport', 'groupRideConversion'])
+ && (!('version' in data) || (data.version is int && data.version >= 1))
+ && validStops(data.stops);
+ }
+
+ function validRouteCreate() {
+ return signedIn()
+ && request.resource.data.id == routeId
+ && request.resource.data.ownerUid == request.auth.uid
+ && validRouteCommon(request.resource.data);
+ }
+
+ function validRouteUpdate() {
+ return isRouteOwner()
+ && request.resource.data.id == resource.data.id
+ && request.resource.data.ownerUid == resource.data.ownerUid
+ && request.resource.data.createdAt == resource.data.createdAt
+ && validRouteCommon(request.resource.data);
+ }
+
+ allow read: if canReadRoute();
+ allow create: if validRouteCreate();
+ allow update: if validRouteUpdate();
+ allow delete: if isRouteOwner();
+ }
+
+ // --- CLUB HANDLE RESERVATIONS ---
+ match /club_handles/{slug} {
+ allow list: if false;
+ allow get: if isRegisteredUser();
+ allow create: if isRegisteredUser()
+ && request.resource.data.slug == slug
+ && request.resource.data.ownerUid == request.auth.uid
+ && request.resource.data.clubId is string
+ && request.resource.data.clubId.size() > 0
+ // Post-write club document invariant
+ && getAfter(/databases/$(database)/documents/clubs/$(request.resource.data.clubId)).data.slug == slug
+ && getAfter(/databases/$(database)/documents/clubs/$(request.resource.data.clubId)).data.ownerUid == request.auth.uid
+ && getAfter(/databases/$(database)/documents/clubs/$(request.resource.data.clubId)).data.memberCount == 1
+ && getAfter(/databases/$(database)/documents/clubs/$(request.resource.data.clubId)).data.archivedAt == null
+ // Post-write owner membership invariant
+ && getAfter(/databases/$(database)/documents/clubs/$(request.resource.data.clubId)/members/$(request.auth.uid)).data.uid == request.auth.uid
+ && getAfter(/databases/$(database)/documents/clubs/$(request.resource.data.clubId)/members/$(request.auth.uid)).data.clubId == request.resource.data.clubId
+ && getAfter(/databases/$(database)/documents/clubs/$(request.resource.data.clubId)/members/$(request.auth.uid)).data.status == 'active'
+ && getAfter(/databases/$(database)/documents/clubs/$(request.resource.data.clubId)/members/$(request.auth.uid)).data.role == 'owner';
+ allow delete: if false;
+ allow update: if false;
+ }
+
+ // --- CLUBS & MEMBERSHIP ---
+ match /clubs/{clubId} {
+ function getClubDoc() {
+ return get(/databases/$(database)/documents/clubs/$(clubId));
+ }
+
+ function isCallerClubOwner() {
+ return isRegisteredUser() && getClubDoc().data.ownerUid == request.auth.uid;
+ }
+
+ function hasMemberDoc(uid) {
+ return exists(/databases/$(database)/documents/clubs/$(clubId)/members/$(uid));
+ }
+
+ function memberData(uid) {
+ return get(/databases/$(database)/documents/clubs/$(clubId)/members/$(uid)).data;
+ }
+
+ function isActiveMember(uid) {
+ return isRegisteredUser() && hasMemberDoc(uid) && memberData(uid).status == 'active';
+ }
+
+ function isPendingInvite(uid) {
+ return isRegisteredUser() && hasMemberDoc(uid) && memberData(uid).status == 'invited';
+ }
+
+ function isClubAdmin(uid) {
+ return isActiveMember(uid) && (memberData(uid).role == 'admin' || memberData(uid).role == 'owner');
+ }
+
+ function isBanned(uid) {
+ return exists(/databases/$(database)/documents/clubs/$(clubId)/bans/$(uid));
+ }
+
+ // Club logo/banner URLs: null or empty string clears; otherwise must be
+ // an https:// URL (case-insensitive scheme, matching URL() semantics in the app layer).
+ function isValidClubImageUrl(url) {
+ return url == null
+ || url == ''
+ || (url is string
+ && url.size() <= 2048
+ && url.matches('(?i)^https://\\S+$'));
+ }
+
+ function isValidMemberCountIncrement() {
+ let targetUid = ('lastMembershipChangeUid' in request.resource.data)
+ ? request.resource.data.lastMembershipChangeUid
+ : request.auth.uid;
+ let memberDocPath = /databases/$(database)/documents/clubs/$(clubId)/members/$(targetUid);
+ return (targetUid == request.auth.uid || isClubAdmin(request.auth.uid))
+ && request.resource.data.memberCount == resource.data.memberCount + 1
+ && getAfter(memberDocPath).data.status == 'active'
+ && (!exists(memberDocPath) || get(memberDocPath).data.status != 'active');
+ }
+
+ function isValidMemberCountDecrement() {
+ let targetUid = ('lastMembershipChangeUid' in request.resource.data)
+ ? request.resource.data.lastMembershipChangeUid
+ : request.auth.uid;
+ let memberDocPath = /databases/$(database)/documents/clubs/$(clubId)/members/$(targetUid);
+ return (targetUid == request.auth.uid || isClubAdmin(request.auth.uid))
+ && request.resource.data.memberCount == resource.data.memberCount - 1
+ && request.resource.data.memberCount >= 1
+ && getAfter(memberDocPath).data.status in ['left', 'removed']
+ && exists(memberDocPath)
+ && get(memberDocPath).data.status == 'active';
+ }
+
+ // Point-read access to club root:
+ // Active members, owner, or invited riders evaluating an invite may read private clubs.
+ // Unlisted and public clubs can be point-read by any registered rider.
+ allow get: if isRegisteredUser() && (
+ resource.data.visibility in ['public', 'unlisted'] ||
+ resource.data.ownerUid == request.auth.uid ||
+ isActiveMember(request.auth.uid) ||
+ isPendingInvite(request.auth.uid)
+ );
+
+ // Discovery list query: Safe public discovery or owner's owned clubs query only
+ allow list: if isRegisteredUser() && (
+ (resource.data.visibility == 'public' && resource.data.archivedAt == null) ||
+ (resource.data.ownerUid == request.auth.uid && resource.data.archivedAt == null)
+ );
+
+ // Club creation: Atomic batch/transaction creating club + owner membership + slug reservation
+ allow create: if isRegisteredUser()
+ && request.resource.data.id == clubId
+ && request.resource.data.ownerUid == request.auth.uid
+ && request.resource.data.memberCount == 1
+ && request.resource.data.archivedAt == null
+ && request.resource.data.slug is string
+ && request.resource.data.slug.size() >= 3
+ && request.resource.data.slug.size() <= 30
+ && getAfter(/databases/$(database)/documents/clubs/$(clubId)/members/$(request.auth.uid)).data.role == 'owner'
+ && getAfter(/databases/$(database)/documents/clubs/$(clubId)/members/$(request.auth.uid)).data.status == 'active'
+ && getAfter(/databases/$(database)/documents/clubs/$(clubId)/members/$(request.auth.uid)).data.clubId == clubId
+ && getAfter(/databases/$(database)/documents/club_handles/$(request.resource.data.slug)).data.clubId == clubId
+ && getAfter(/databases/$(database)/documents/club_handles/$(request.resource.data.slug)).data.ownerUid == request.auth.uid
+ && getAfter(/databases/$(database)/documents/club_handles/$(request.resource.data.slug)).data.slug == request.resource.data.slug;
+
+ // Club update: Ownership transfer, metadata edit, or member count change
+ allow update: if isRegisteredUser() && (
+ // Case 1: Owner transferring ownership atomically (cannot mutate memberCount, slug, createdAt)
+ (
+ request.auth.uid == resource.data.ownerUid
+ && request.resource.data.diff(resource.data).affectedKeys().hasOnly(['ownerUid', 'updatedAt'])
+ && request.resource.data.ownerUid != resource.data.ownerUid
+ && getAfter(/databases/$(database)/documents/clubs/$(clubId)/members/$(request.resource.data.ownerUid)).data.role == 'owner'
+ && getAfter(/databases/$(database)/documents/clubs/$(clubId)/members/$(request.resource.data.ownerUid)).data.status == 'active'
+ && getAfter(/databases/$(database)/documents/clubs/$(clubId)/members/$(resource.data.ownerUid)).data.role != 'owner'
+ ) ||
+ // Case 2: Owner general metadata update or archiving (slug, ownerUid, memberCount, createdAt immutable)
+ (
+ request.auth.uid == resource.data.ownerUid
+ && request.resource.data.diff(resource.data).affectedKeys().hasOnly([
+ 'name', 'description', 'visibility', 'admissionPolicy',
+ 'logoUrl', 'bannerUrl', 'homeRegion', 'motorcycleFocus',
+ 'archivedAt', 'updatedAt'
+ ])
+ && (!('name' in request.resource.data) || (request.resource.data.name is string && request.resource.data.name.size() >= 3 && request.resource.data.name.size() <= 50))
+ && (!('visibility' in request.resource.data) || request.resource.data.visibility in ['public', 'unlisted', 'private'])
+ && (!('admissionPolicy' in request.resource.data) || request.resource.data.admissionPolicy in ['open', 'request', 'inviteOnly'])
+ && (!('logoUrl' in request.resource.data) || isValidClubImageUrl(request.resource.data.logoUrl))
+ && (!('bannerUrl' in request.resource.data) || isValidClubImageUrl(request.resource.data.bannerUrl))
+ ) ||
+ // Case 3: Admin editing metadata and access settings (cannot change ownerUid, slug, createdAt, archivedAt, memberCount)
+ (
+ isClubAdmin(request.auth.uid)
+ && request.resource.data.diff(resource.data).affectedKeys().hasOnly([
+ 'name', 'description', 'visibility', 'admissionPolicy',
+ 'logoUrl', 'bannerUrl', 'homeRegion', 'motorcycleFocus', 'updatedAt'
+ ])
+ && (!('name' in request.resource.data) || (request.resource.data.name is string && request.resource.data.name.size() >= 3 && request.resource.data.name.size() <= 50))
+ && (!('visibility' in request.resource.data) || request.resource.data.visibility in ['public', 'unlisted', 'private'])
+ && (!('admissionPolicy' in request.resource.data) || request.resource.data.admissionPolicy in ['open', 'request', 'inviteOnly'])
+ && (!('logoUrl' in request.resource.data) || isValidClubImageUrl(request.resource.data.logoUrl))
+ && (!('bannerUrl' in request.resource.data) || isValidClubImageUrl(request.resource.data.bannerUrl))
+ ) ||
+ // Case 4: Member count increment/decrement by membership transitions ONLY
+ (
+ request.resource.data.diff(resource.data).affectedKeys().hasOnly(['memberCount', 'updatedAt', 'lastMembershipChangeUid'])
+ && (isValidMemberCountIncrement() || isValidMemberCountDecrement())
+ )
+ );
+
+ allow delete: if false;
+
+ // --- MEMBERS SUBCOLLECTION ---
+ match /members/{memberUid} {
+ // Point read: Caller themselves, club management, or peer active member (if not mutually blocked)
+ allow get: if isRegisteredUser() && (
+ request.auth.uid == memberUid ||
+ isCallerClubOwner() ||
+ isClubAdmin(request.auth.uid) ||
+ (isActiveMember(request.auth.uid) && resource.data.status == 'active' && !areMutuallyBlocked(request.auth.uid, memberUid))
+ );
+
+ // List roster: Active members or club management only
+ allow list: if isRegisteredUser() && (
+ isCallerClubOwner() ||
+ isClubAdmin(request.auth.uid) ||
+ (isActiveMember(request.auth.uid) && resource.data.status == 'active')
+ );
+
+ // Create membership doc: Owner creation, open join, request to join, or admin invitation
+ allow create: if isRegisteredUser() && !isBanned(memberUid) && (
+ // Owner creating self doc during club creation
+ (
+ request.auth.uid == memberUid
+ && request.resource.data.role == 'owner'
+ && request.resource.data.status == 'active'
+ && request.resource.data.clubId == clubId
+ && getAfter(/databases/$(database)/documents/clubs/$(clubId)).data.ownerUid == request.auth.uid
+ && getAfter(/databases/$(database)/documents/clubs/$(clubId)).data.memberCount == 1
+ && getAfter(/databases/$(database)/documents/clubs/$(clubId)).data.archivedAt == null
+ && getAfter(/databases/$(database)/documents/club_handles/$(getAfter(/databases/$(database)/documents/clubs/$(clubId)).data.slug)).data.clubId == clubId
+ && getAfter(/databases/$(database)/documents/club_handles/$(getAfter(/databases/$(database)/documents/clubs/$(clubId)).data.slug)).data.ownerUid == request.auth.uid
+ ) ||
+ // Rider joining open club
+ (
+ request.auth.uid == memberUid
+ && get(/databases/$(database)/documents/clubs/$(clubId)).data.admissionPolicy == 'open'
+ && request.resource.data.role == 'member'
+ && request.resource.data.status == 'active'
+ && !areMutuallyBlocked(request.auth.uid, get(/databases/$(database)/documents/clubs/$(clubId)).data.ownerUid)
+ && getAfter(/databases/$(database)/documents/clubs/$(clubId)).data.memberCount == get(/databases/$(database)/documents/clubs/$(clubId)).data.memberCount + 1
+ ) ||
+ // Rider requesting to join
+ (
+ request.auth.uid == memberUid
+ && get(/databases/$(database)/documents/clubs/$(clubId)).data.admissionPolicy == 'request'
+ && request.resource.data.role == 'member'
+ && request.resource.data.status == 'requested'
+ && !areMutuallyBlocked(request.auth.uid, get(/databases/$(database)/documents/clubs/$(clubId)).data.ownerUid)
+ ) ||
+ // Admin / Owner inviting rider
+ (
+ isClubAdmin(request.auth.uid)
+ && request.resource.data.status == 'invited'
+ && request.resource.data.role == 'member'
+ && !areMutuallyBlocked(request.auth.uid, memberUid)
+ )
+ );
+
+ function isValidOpenReentry() {
+ return request.auth.uid == memberUid
+ && resource.data.status in ['left', 'declined', 'removed']
+ && request.resource.data.status == 'active'
+ && request.resource.data.role == 'member'
+ && request.resource.data.uid == memberUid
+ && request.resource.data.clubId == clubId
+ && !isBanned(memberUid)
+ && getClubDoc().data.admissionPolicy == 'open'
+ && getClubDoc().data.archivedAt == null
+ && !areMutuallyBlocked(request.auth.uid, getClubDoc().data.ownerUid)
+ && request.resource.data.keys().hasOnly(['uid', 'clubId', 'status', 'role', 'joinedAt', 'updatedAt'])
+ && (!('joinedAt' in request.resource.data) || request.resource.data.joinedAt is timestamp)
+ && (!('updatedAt' in request.resource.data) || request.resource.data.updatedAt is timestamp)
+ && getAfter(/databases/$(database)/documents/clubs/$(clubId)).data.memberCount == getClubDoc().data.memberCount + 1;
+ }
+
+ function isValidRequestReentry() {
+ return request.auth.uid == memberUid
+ && resource.data.status in ['left', 'declined', 'removed']
+ && request.resource.data.status == 'requested'
+ && request.resource.data.role == 'member'
+ && request.resource.data.uid == memberUid
+ && request.resource.data.clubId == clubId
+ && !isBanned(memberUid)
+ && getClubDoc().data.admissionPolicy == 'request'
+ && getClubDoc().data.archivedAt == null
+ && !areMutuallyBlocked(request.auth.uid, getClubDoc().data.ownerUid)
+ && request.resource.data.keys().hasOnly(['uid', 'clubId', 'status', 'role', 'requestedAt', 'updatedAt'])
+ && (!('requestedAt' in request.resource.data) || request.resource.data.requestedAt is timestamp)
+ && (!('updatedAt' in request.resource.data) || request.resource.data.updatedAt is timestamp)
+ && getAfter(/databases/$(database)/documents/clubs/$(clubId)).data.memberCount == getClubDoc().data.memberCount;
+ }
+
+ // Transitions
+ allow update: if isRegisteredUser() && (
+ // Invitee accepts invite (invited -> active)
+ (
+ request.auth.uid == memberUid
+ && resource.data.status == 'invited'
+ && request.resource.data.status == 'active'
+ && request.resource.data.role == resource.data.role
+ && !isBanned(memberUid)
+ && getAfter(/databases/$(database)/documents/clubs/$(clubId)).data.memberCount == get(/databases/$(database)/documents/clubs/$(clubId)).data.memberCount + 1
+ ) ||
+ // Invitee declines invite (invited -> declined)
+ (request.auth.uid == memberUid && resource.data.status == 'invited' && request.resource.data.status == 'declined' && request.resource.data.role == resource.data.role) ||
+ // Member leaves (active -> left), non-owner only
+ (
+ request.auth.uid == memberUid
+ && resource.data.status == 'active'
+ && request.resource.data.status == 'left'
+ && resource.data.role != 'owner'
+ && getAfter(/databases/$(database)/documents/clubs/$(clubId)).data.memberCount == get(/databases/$(database)/documents/clubs/$(clubId)).data.memberCount - 1
+ ) ||
+ // Requestor cancels request (requested -> left)
+ (request.auth.uid == memberUid && resource.data.status == 'requested' && request.resource.data.status == 'left') ||
+ // Re-entry to open club (left/declined/removed -> active)
+ isValidOpenReentry() ||
+ // Re-entry request to request-policy club (left/declined/removed -> requested)
+ isValidRequestReentry() ||
+ // Admin/Owner approves request (requested -> active)
+ (
+ isClubAdmin(request.auth.uid)
+ && resource.data.status == 'requested'
+ && request.resource.data.status == 'active'
+ && getAfter(/databases/$(database)/documents/clubs/$(clubId)).data.memberCount == get(/databases/$(database)/documents/clubs/$(clubId)).data.memberCount + 1
+ ) ||
+ // Admin/Owner declines request (requested -> declined)
+ (isClubAdmin(request.auth.uid) && resource.data.status == 'requested' && request.resource.data.status == 'declined') ||
+ // Admin/Owner removes active member (active -> removed), hierarchy enforced
+ (
+ isClubAdmin(request.auth.uid)
+ && resource.data.status == 'active'
+ && request.resource.data.status == 'removed'
+ && resource.data.role != 'owner'
+ && (isCallerClubOwner() || resource.data.role != 'admin')
+ && getAfter(/databases/$(database)/documents/clubs/$(clubId)).data.memberCount == get(/databases/$(database)/documents/clubs/$(clubId)).data.memberCount - 1
+ ) ||
+ // Admin/Owner removes non-active member (requested/invited -> removed)
+ (
+ isClubAdmin(request.auth.uid)
+ && resource.data.status != 'active'
+ && request.resource.data.status == 'removed'
+ && resource.data.role != 'owner'
+ && (isCallerClubOwner() || resource.data.role != 'admin')
+ ) ||
+ // Owner updates role (role update or ownership transfer)
+ (isCallerClubOwner() && request.resource.data.status == resource.data.status) ||
+ // Display rank update by admin/owner
+ (isClubAdmin(request.auth.uid) && request.resource.data.diff(resource.data).affectedKeys().hasOnly(['displayRank', 'updatedAt']))
+ );
+
+ allow delete: if false;
+ }
+
+ // --- ADMINISTRATIVE BANS SUBCOLLECTION ---
+ match /bans/{bannedUid} {
+ allow read, write: if isRegisteredUser() && isClubAdmin(request.auth.uid);
+ }
+ }
+
+ // --- COLLECTION GROUP: MEMBERS ---
+ // Scoped strictly so caller can list only documents belonging to their own UID.
+ match /{path=**}/members/{memberUid} {
+ allow list: if isRegisteredUser() && resource.data.uid == request.auth.uid;
+ }
+
+ // --- MOTORCLOUD RIDER SWARM ROAD ALERTS ---
+ match /road_alerts/{alertId} {
+ function validAlertKeys() {
+ return request.resource.data.keys().hasOnly([
+ 'id', 'category', 'latitude', 'longitude', 'heading',
+ 'roadName', 'description', 'reporterUid', 'reportedAt',
+ 'expiresAt', 'confirmations', 'dismissals', 'groupRideId'
+ ]);
+ }
+
+ function validAlertCreate() {
+ return signedIn()
+ && request.resource.data.id == alertId
+ && request.resource.data.reporterUid == request.auth.uid
+ && validAlertKeys()
+ && request.resource.data.category in [
+ 'police', 'hazard', 'accident', 'speedCamera',
+ 'roadClosure', 'construction', 'traffic', 'other'
+ ]
+ && (request.resource.data.latitude is int || request.resource.data.latitude is float)
+ && (request.resource.data.longitude is int || request.resource.data.longitude is float)
+ && request.resource.data.latitude >= -90.0 && request.resource.data.latitude <= 90.0
+ && request.resource.data.longitude >= -180.0 && request.resource.data.longitude <= 180.0
+ && request.resource.data.confirmations == 1
+ && request.resource.data.dismissals == 0
+ && request.resource.data.reportedAt is int
+ && request.resource.data.expiresAt is int
+ && request.resource.data.expiresAt > request.resource.data.reportedAt
+ && request.resource.data.description is string
+ && request.resource.data.description.size() <= 256;
+ }
+
+ function validAlertUpdate() {
+ return signedIn()
+ && validAlertKeys()
+ && request.resource.data.id == resource.data.id
+ && request.resource.data.reporterUid == resource.data.reporterUid
+ && request.resource.data.category == resource.data.category
+ && request.resource.data.latitude == resource.data.latitude
+ && request.resource.data.longitude == resource.data.longitude
+ && request.resource.data.reportedAt == resource.data.reportedAt
+ && request.resource.data.diff(resource.data).affectedKeys().hasOnly(['confirmations', 'dismissals', 'expiresAt'])
+ && request.resource.data.confirmations >= resource.data.confirmations
+ && request.resource.data.dismissals >= resource.data.dismissals;
+ }
+
+ allow read: if signedIn();
+ allow create: if validAlertCreate();
+ allow update: if validAlertUpdate();
+ allow delete: if signedIn() && resource.data.reporterUid == request.auth.uid;
+ }
+
+ match /{document=**} {
+ allow read, write: if false;
+ }
+ }
+}
+
diff --git a/website/tests/auth-form.test.tsx b/website/tests/auth-form.test.tsx
new file mode 100644
index 0000000..492caf1
--- /dev/null
+++ b/website/tests/auth-form.test.tsx
@@ -0,0 +1,73 @@
+import React from 'react';
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
+import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react';
+import { AuthForm } from '../components/auth-form';
+import { signInWithPopup } from 'firebase/auth';
+
+vi.mock('../lib/firebase-client', () => ({
+ firebaseAuth: {},
+ googleProvider: {},
+}));
+
+vi.mock('firebase/auth', () => ({
+ signInWithPopup: vi.fn(),
+}));
+
+describe('AuthForm Google session exchange', () => {
+ const originalFetch = global.fetch;
+
+ beforeEach(() => {
+ vi.restoreAllMocks();
+ vi.mocked(signInWithPopup).mockResolvedValue({
+ user: { getIdToken: async () => 'emulator-id-token' },
+ } as Awaited>);
+ });
+
+ afterEach(() => {
+ cleanup();
+ global.fetch = originalFetch;
+ });
+
+ it.each(['auth/popup-closed-by-user', 'auth/cancelled-popup-request'])('handles %s as cancellation without a console error or session exchange', async (code) => {
+ const consoleError = vi.spyOn(console, 'error').mockImplementation(() => {});
+ global.fetch = vi.fn();
+ vi.mocked(signInWithPopup).mockRejectedValueOnce(Object.assign(new Error(`Firebase: Error (${code}).`), { code }));
+ render( );
+ fireEvent.click(screen.getByRole('button', { name: /sign in with google/i }));
+ await waitFor(() => {
+ expect(screen.getByRole('status').textContent).toBe('Google sign-in was canceled. You can try again.');
+ });
+ expect(consoleError).not.toHaveBeenCalled();
+ expect(global.fetch).not.toHaveBeenCalled();
+ expect((screen.getByRole('button', { name: /sign in with google/i }) as HTMLButtonElement).disabled).toBe(false);
+ expect((screen.getByRole('button', { name: 'Sign In' }) as HTMLButtonElement).disabled).toBe(false);
+ expect(screen.queryByRole('alert')).toBeNull();
+ });
+
+ it('still reports genuine Google sign-in failures', async () => {
+ const consoleError = vi.spyOn(console, 'error').mockImplementation(() => {});
+ global.fetch = vi.fn();
+ vi.mocked(signInWithPopup).mockRejectedValueOnce(Object.assign(new Error('Google sign-in is unavailable.'), { code: 'auth/network-request-failed' }));
+ render( );
+ fireEvent.click(screen.getByRole('button', { name: /sign in with google/i }));
+ await waitFor(() => expect(screen.getByRole('alert').textContent).toBe('Google sign-in is unavailable.'));
+ expect(consoleError).toHaveBeenCalledOnce();
+ expect(global.fetch).not.toHaveBeenCalled();
+ });
+
+ it('shows session rejection when server exchange returns 401', async () => {
+ global.fetch = vi.fn().mockResolvedValue({
+ ok: false,
+ status: 401,
+ json: async () => ({ detail: 'Session exchange rejected.' }),
+ });
+
+ render( );
+ fireEvent.click(screen.getByRole('button', { name: /sign in with google/i }));
+
+ await waitFor(() => {
+ expect(screen.getByRole('alert').textContent).toBe('Session exchange rejected.');
+ });
+ expect(global.fetch).toHaveBeenCalledTimes(1);
+ });
+});
diff --git a/website/tests/firebase-client-emulator.test.ts b/website/tests/firebase-client-emulator.test.ts
new file mode 100644
index 0000000..cfcaf27
--- /dev/null
+++ b/website/tests/firebase-client-emulator.test.ts
@@ -0,0 +1,61 @@
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
+
+const mocks = vi.hoisted(() => ({
+ initializeApp: vi.fn((_options: unknown, name = '[DEFAULT]') => ({ name })),
+ getApps: vi.fn((): Array<{ name: string }> => []),
+ getApp: vi.fn(),
+ connectAuth: vi.fn(),
+ connectFirestore: vi.fn(),
+}));
+vi.mock('firebase/app', () => mocks);
+vi.mock('firebase/auth', () => ({ getAuth: vi.fn(() => ({})), GoogleAuthProvider: class {}, connectAuthEmulator: mocks.connectAuth }));
+vi.mock('firebase/firestore', () => ({ getFirestore: vi.fn(() => ({})), connectFirestoreEmulator: mocks.connectFirestore }));
+
+describe('isolated Firebase QA client', () => {
+ beforeEach(() => { vi.resetModules(); vi.clearAllMocks(); mocks.getApps.mockReturnValue([]); });
+ afterEach(() => vi.unstubAllEnvs());
+ it('keeps production defaults without explicit opt-in', async () => {
+ vi.stubEnv('NEXT_PUBLIC_FIREBASE_EMULATORS', 'false');
+ await import('../lib/firebase-client');
+ expect(mocks.initializeApp).toHaveBeenCalledWith(expect.objectContaining({ projectId: 'motoapp-d22e0' }));
+ expect(mocks.connectAuth).not.toHaveBeenCalled();
+ });
+ it('rejects a production project before initializing Firebase', async () => {
+ vi.stubEnv('NEXT_PUBLIC_FIREBASE_EMULATORS', 'true');
+ vi.stubEnv('NEXT_PUBLIC_FIREBASE_QA_PROJECT_ID', 'motoapp-d22e0');
+ await expect(import('../lib/firebase-client')).rejects.toThrow('demo- project');
+ expect(mocks.initializeApp).not.toHaveBeenCalled();
+ });
+ it('connects an isolated named demo app to both loopback emulators', async () => {
+ vi.stubEnv('NEXT_PUBLIC_FIREBASE_EMULATORS', 'true');
+ vi.stubEnv('NEXT_PUBLIC_FIREBASE_QA_PROJECT_ID', 'demo-motorsignal-qa');
+ await import('../lib/firebase-client');
+ expect(mocks.initializeApp).toHaveBeenCalledWith(expect.objectContaining({ projectId: 'demo-motorsignal-qa' }), 'motorsignal-local-qa');
+ expect(mocks.connectAuth).toHaveBeenCalledWith(expect.anything(), 'http://127.0.0.1:9099');
+ expect(mocks.connectFirestore).toHaveBeenCalledWith(expect.anything(), '127.0.0.1', 8088);
+ });
+ it('supports dedicated QA emulator ports without changing production defaults', async () => {
+ vi.stubEnv('NEXT_PUBLIC_FIREBASE_EMULATORS', 'true');
+ vi.stubEnv('NEXT_PUBLIC_FIREBASE_QA_PROJECT_ID', 'demo-motorsignal-web-qa');
+ vi.stubEnv('NEXT_PUBLIC_FIREBASE_AUTH_EMULATOR_PORT', '9199');
+ vi.stubEnv('NEXT_PUBLIC_FIREBASE_FIRESTORE_EMULATOR_PORT', '8188');
+ await import('../lib/firebase-client');
+ expect(mocks.connectAuth).toHaveBeenCalledWith(expect.anything(), 'http://127.0.0.1:9199');
+ expect(mocks.connectFirestore).toHaveBeenCalledWith(expect.anything(), '127.0.0.1', 8188);
+ });
+ it('rejects invalid dedicated QA emulator ports', async () => {
+ vi.stubEnv('NEXT_PUBLIC_FIREBASE_EMULATORS', 'true');
+ vi.stubEnv('NEXT_PUBLIC_FIREBASE_QA_PROJECT_ID', 'demo-motorsignal-web-qa');
+ vi.stubEnv('NEXT_PUBLIC_FIREBASE_AUTH_EMULATOR_PORT', '99999');
+ await expect(import('../lib/firebase-client')).rejects.toThrow('valid TCP ports');
+ expect(mocks.initializeApp).not.toHaveBeenCalled();
+ });
+ it('reuses an already connected QA app during module reload', async () => {
+ vi.stubEnv('NEXT_PUBLIC_FIREBASE_EMULATORS', 'true');
+ vi.stubEnv('NEXT_PUBLIC_FIREBASE_QA_PROJECT_ID', 'demo-motorsignal-qa');
+ mocks.getApps.mockReturnValue([{ name: 'motorsignal-local-qa' }]);
+ await import('../lib/firebase-client');
+ expect(mocks.initializeApp).not.toHaveBeenCalled();
+ expect(mocks.connectFirestore).not.toHaveBeenCalled();
+ });
+});
diff --git a/website/tests/founder-routes-chat.test.tsx b/website/tests/founder-routes-chat.test.tsx
new file mode 100644
index 0000000..625c276
--- /dev/null
+++ b/website/tests/founder-routes-chat.test.tsx
@@ -0,0 +1,361 @@
+import React from 'react';
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
+import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react';
+import { FounderRoutes } from '../components/founder-portal/founder-routes';
+import { FounderChat } from '../components/founder-portal/founder-chat';
+import { FounderCommunityPage } from '../components/founder-portal/founder-community-page';
+import { FounderSidebar } from '../components/founder-portal/founder-sidebar';
+import type { FounderClubContext } from '../lib/club-data';
+import type { ClubRoute, ChatMessage } from '../lib/club-community';
+
+const mocks = vi.hoisted(() => ({
+ fetchRoutes: vi.fn(),
+ createRoute: vi.fn(),
+ deleteRoute: vi.fn(),
+ subscribeChat: vi.fn(),
+ sendChat: vi.fn(),
+ context: vi.fn(),
+ auth: vi.fn(),
+ push: vi.fn(),
+}));
+
+vi.mock('../lib/firebase-client', () => ({ firebaseAuth: {} }));
+vi.mock('firebase/auth', () => ({
+ onAuthStateChanged: (...args: unknown[]) => mocks.auth(...args),
+}));
+vi.mock('next/navigation', () => ({
+ useRouter: () => ({ push: mocks.push }),
+ useSearchParams: () => new URLSearchParams(),
+}));
+vi.mock('../components/founder-portal/founder-portal-shell', () => ({
+ FounderPortalShell: ({ children }: { children: React.ReactNode }) => {children}
,
+}));
+vi.mock('../lib/club-data', () => ({
+ fetchFounderClubContext: (...args: unknown[]) => mocks.context(...args),
+ sanitizePortalError: (_err: unknown, fallback = 'Club error') => fallback,
+}));
+vi.mock('../lib/club-community', () => ({
+ fetchClubRoutes: (...args: unknown[]) => mocks.fetchRoutes(...args),
+ createClubRoute: (...args: unknown[]) => mocks.createRoute(...args),
+ deleteClubRoute: (...args: unknown[]) => mocks.deleteRoute(...args),
+ subscribeClubChat: (...args: unknown[]) => mocks.subscribeChat(...args),
+ sendClubChatMessage: (...args: unknown[]) => mocks.sendChat(...args),
+ FIXTURE_CHAT_MESSAGES: {
+ general: [
+ {
+ id: 'msg-1',
+ clubId: 'ClubID',
+ channelId: 'general',
+ authorUid: 'owner',
+ authorDisplayName: 'Morgan Vance',
+ authorHandle: '@morgan_v',
+ authorDisplayRank: 'President',
+ authorRole: 'owner',
+ content: 'Welcome to squad chat!',
+ createdAt: '2026-06-01T12:00:00Z',
+ },
+ ],
+ officers: [
+ {
+ id: 'msg-off-1',
+ clubId: 'ClubID',
+ channelId: 'officers',
+ authorUid: 'owner',
+ authorDisplayName: 'Morgan Vance',
+ authorHandle: '@morgan_v',
+ authorDisplayRank: 'President',
+ authorRole: 'owner',
+ content: 'Officers briefing notes.',
+ createdAt: '2026-06-01T11:00:00Z',
+ },
+ ],
+ 'ride-intel': [],
+ },
+}));
+
+const mockContext: FounderClubContext = {
+ club: {
+ id: 'ClubID',
+ slug: 'atlantic-moto',
+ name: 'Atlantic Moto Club',
+ ownerUid: 'owner',
+ description: '',
+ visibility: 'private',
+ admissionPolicy: 'request',
+ memberCount: 5,
+ createdAt: '',
+ updatedAt: '',
+ },
+ currentUser: { uid: 'owner', role: 'owner' },
+ ownerProfile: null,
+ isDemoFixture: false,
+ permissions: {
+ isAuthenticated: true,
+ role: 'owner',
+ canManageSettings: true,
+ canInviteMembers: true,
+ canApproveApplications: true,
+ canPlanRides: true,
+ canTransferOwnership: true,
+ },
+};
+
+const sampleRoutes: ClubRoute[] = [
+ {
+ id: 'r1',
+ clubId: 'ClubID',
+ title: 'Blood Mountain Gap Loop',
+ description: 'Technical mountain sweepers through US-19/129 with fast transitions.',
+ distanceMiles: 48.6,
+ estimatedMinutes: 65,
+ elevationGainFt: 3120,
+ curvatureRating: 'High Twisties',
+ surfaceType: 'Paved Asphalt',
+ startLocation: 'Dahlonega Outpost',
+ endLocation: 'Vogel State Park',
+ gpxUrl: '/fixtures/blood-mountain.gpx',
+ curatorUid: 'owner',
+ curatorName: 'Morgan Vance',
+ createdAt: '2026-03-15T10:00:00Z',
+ },
+ {
+ id: 'r2',
+ clubId: 'ClubID',
+ title: 'Coastal Causeways Cruise',
+ description: 'Relaxed scenic run along salt marshes.',
+ distanceMiles: 32.0,
+ estimatedMinutes: 45,
+ elevationGainFt: 120,
+ curvatureRating: 'Scenic Cruise',
+ surfaceType: 'Paved Asphalt',
+ startLocation: 'Savannah Riverfront',
+ endLocation: 'Tybee Island',
+ gpxUrl: null,
+ curatorUid: 'owner',
+ curatorName: 'Morgan Vance',
+ createdAt: '2026-04-10T10:00:00Z',
+ },
+];
+
+beforeEach(() => {
+ vi.clearAllMocks();
+ mocks.fetchRoutes.mockResolvedValue(sampleRoutes);
+ mocks.createRoute.mockResolvedValue(undefined);
+ mocks.deleteRoute.mockResolvedValue(undefined);
+ mocks.sendChat.mockResolvedValue(undefined);
+ mocks.context.mockResolvedValue(mockContext);
+ mocks.auth.mockImplementation((_auth, deliver) => {
+ deliver({ uid: 'owner', isAnonymous: false });
+ return vi.fn();
+ });
+ mocks.subscribeChat.mockImplementation((_clubId, _channelId, onMessages) => {
+ onMessages([
+ {
+ id: 'msg-1',
+ clubId: 'ClubID',
+ channelId: 'general',
+ authorUid: 'owner',
+ authorDisplayName: 'Morgan Vance',
+ authorHandle: '@morgan_v',
+ authorDisplayRank: 'President',
+ authorRole: 'owner',
+ content: 'Welcome to squad chat!',
+ createdAt: '2026-06-01T12:00:00Z',
+ },
+ ]);
+ return vi.fn();
+ });
+});
+
+afterEach(cleanup);
+
+describe('Founder Routes Management', () => {
+ it('renders curated routes with metrics, waypoints, and curvature badges', async () => {
+ render( );
+ await screen.findByText('Blood Mountain Gap Loop');
+
+ expect(screen.getByText('Coastal Causeways Cruise')).toBeDefined();
+ expect(screen.getByText('48.6 mi')).toBeDefined();
+ expect(screen.getByText('+3120 ft')).toBeDefined();
+ expect(screen.getByText('Dahlonega Outpost')).toBeDefined();
+ expect(screen.getAllByText('High Twisties').length).toBeGreaterThanOrEqual(1);
+ expect(screen.getAllByText('Scenic Cruise').length).toBeGreaterThanOrEqual(1);
+ });
+
+ it('filters routes by search query and curvature rating', async () => {
+ render( );
+ await screen.findByText('Blood Mountain Gap Loop');
+
+ // Search filter
+ const searchInput = screen.getByPlaceholderText(/Search routes by name/i);
+ fireEvent.change(searchInput, { target: { value: 'Coastal' } });
+
+ expect(screen.queryByText('Blood Mountain Gap Loop')).toBeNull();
+ expect(screen.getByText('Coastal Causeways Cruise')).toBeDefined();
+
+ // Reset search
+ fireEvent.change(searchInput, { target: { value: '' } });
+ expect(screen.getByText('Blood Mountain Gap Loop')).toBeDefined();
+
+ // Curvature select filter
+ const curvatureSelect = screen.getByLabelText(/Filter by Curvature/i);
+ fireEvent.change(curvatureSelect, { target: { value: 'High Twisties' } });
+
+ expect(screen.getByText('Blood Mountain Gap Loop')).toBeDefined();
+ expect(screen.queryByText('Coastal Causeways Cruise')).toBeNull();
+ });
+
+ it('opens curate route modal and submits new route', async () => {
+ render( );
+ await screen.findByText('Curate Route');
+
+ fireEvent.click(screen.getByRole('button', { name: /Curate Route/i }));
+ expect(screen.getByRole('heading', { name: 'Curate Club Route' })).toBeDefined();
+
+ fireEvent.change(screen.getByPlaceholderText(/e\.g\. Wolfpen Gap Apex Loop/i), {
+ target: { value: 'Cherohala High Ridge Run' },
+ });
+ fireEvent.change(screen.getByPlaceholderText(/Shell Outpost/i), {
+ target: { value: 'Tellico Plains' },
+ });
+ fireEvent.change(screen.getByPlaceholderText(/Vogel State Park/i), {
+ target: { value: 'Robbinsville Overlook' },
+ });
+
+ fireEvent.click(screen.getByRole('button', { name: /Save & Publish Route/i }));
+
+ await waitFor(() => {
+ expect(mocks.createRoute).toHaveBeenCalledWith(
+ 'ClubID',
+ expect.objectContaining({
+ title: 'Cherohala High Ridge Run',
+ startLocation: 'Tellico Plains',
+ endLocation: 'Robbinsville Overlook',
+ })
+ );
+ });
+ });
+
+ it('opens delete confirmation modal and deletes route', async () => {
+ render( );
+ await screen.findByText('Blood Mountain Gap Loop');
+
+ const deleteButtons = screen.getAllByTitle('Delete Route');
+ fireEvent.click(deleteButtons[0]);
+
+ expect(screen.getByRole('heading', { name: 'Delete Club Route?' })).toBeDefined();
+ fireEvent.click(screen.getByRole('button', { name: 'Confirm Delete' }));
+
+ await waitFor(() => {
+ expect(mocks.deleteRoute).toHaveBeenCalledWith('ClubID', 'r1');
+ });
+ });
+});
+
+describe('Founder Chat Management', () => {
+ it('renders chat message stream and channel tabs', async () => {
+ render( );
+ await screen.findByText('Welcome to squad chat!');
+
+ expect(screen.getAllByText('#general-squad').length).toBeGreaterThanOrEqual(1);
+ expect(screen.getByText('#officers-lounge')).toBeDefined();
+ expect(screen.getByText('#road-intel')).toBeDefined();
+ expect(screen.getByText('Morgan Vance')).toBeDefined();
+ });
+
+ it('switches between channels and loads corresponding messages', async () => {
+ render( );
+ await screen.findByText('Welcome to squad chat!');
+
+ fireEvent.click(screen.getByText('#road-intel'));
+ await waitFor(() => {
+ expect(mocks.subscribeChat).toHaveBeenCalledWith(
+ 'ClubID',
+ 'ride-intel',
+ expect.any(Function),
+ expect.any(Function)
+ );
+ });
+ });
+
+ it('sends a new chat message on form submission', async () => {
+ render( );
+ await screen.findByText('Welcome to squad chat!');
+
+ const input = screen.getByPlaceholderText(/Message #general-squad/i);
+ fireEvent.change(input, { target: { value: 'Saturday ride briefing is posted!' } });
+
+ fireEvent.click(screen.getByRole('button', { name: /Send/i }));
+
+ await waitFor(() => {
+ expect(mocks.sendChat).toHaveBeenCalledWith(
+ 'ClubID',
+ 'general',
+ 'Saturday ride briefing is posted!'
+ );
+ });
+ });
+
+ it('restricts Officers Lounge channel for non-officers', async () => {
+ const memberContext: FounderClubContext = {
+ ...mockContext,
+ currentUser: { uid: 'regular-member', role: 'member' },
+ permissions: {
+ ...mockContext.permissions,
+ role: 'member',
+ canManageSettings: false,
+ },
+ };
+
+ render( );
+ fireEvent.click(screen.getByText('#officers-lounge'));
+
+ await screen.findByText('Officers Lounge Restricted');
+ expect(screen.getByText(/strictly reserved for club Founders and Admins/i)).toBeDefined();
+ });
+});
+
+describe('Founder Community Page & Sidebar Integration', () => {
+ it('renders routes section via FounderCommunityPage', async () => {
+ render( );
+ await screen.findByText('Curated Club Routes');
+ expect(mocks.context).toHaveBeenCalledWith('atlantic-moto', 'owner', { allowDemoPreview: false });
+ });
+
+ it('renders chat section via FounderCommunityPage', async () => {
+ render( );
+ await screen.findByText('Squad Live Chat');
+ expect(mocks.context).toHaveBeenCalledWith('atlantic-moto', 'owner', { allowDemoPreview: false });
+ });
+
+ it('verifies all sidebar navigation links are enabled without SOON badges', () => {
+ render(
+
+ );
+
+ const routesLink = screen.getByRole('link', { name: /Routes/i });
+ expect(routesLink.getAttribute('href')).toBe('/clubs/atlantic-moto/manage/routes');
+
+ const chatLink = screen.getByRole('link', { name: /Chat/i });
+ expect(chatLink.getAttribute('href')).toBe('/clubs/atlantic-moto/manage/chat');
+
+ const membersLink = screen.getByRole('link', { name: /Members/i });
+ expect(membersLink.getAttribute('href')).toBe('/clubs/atlantic-moto/manage/members');
+
+ const wallLink = screen.getByRole('link', { name: /Wall/i });
+ expect(wallLink.getAttribute('href')).toBe('/clubs/atlantic-moto/manage/wall');
+
+ const statsLink = screen.getByRole('link', { name: /Stats/i });
+ expect(statsLink.getAttribute('href')).toBe('/clubs/atlantic-moto/manage/stats');
+
+ const moderationLink = screen.getByRole('link', { name: /Moderation/i });
+ expect(moderationLink.getAttribute('href')).toBe('/clubs/atlantic-moto/manage/moderation');
+
+ // Confirm NO sidebar item has the SOON badge anymore
+ expect(screen.queryByText('SOON')).toBeNull();
+ });
+});