import { NextRequest, NextResponse } from 'next/server'; import type { QueryDocumentSnapshot } from 'firebase-admin/firestore'; import { getAdminAuth, getAdminFirestore } from '../../../../../lib/firebase-admin'; export const dynamic = 'force-dynamic'; interface RouteContext { params: Promise<{ clubId: string; }>; } export interface ClubRideDiscoveryItem { rideId: string; clubId: string; title: string; description: string | null; organizerDisplayName: string | null; organizerHandle: string | null; scheduledStartAt: string; departureAt: string | null; joinPolicy: string; participantCount: number; participantLimit: number | null; status: 'scheduled' | 'assembling'; meetingLocationLabel: string | null; routeTitle: string | null; routeDistanceMeters: number | null; paceClassification: string | null; } export async function GET(request: NextRequest, context: RouteContext) { try { // 1. Require and extract Bearer token const authHeader = request.headers.get('authorization'); if (!authHeader || !authHeader.startsWith('Bearer ')) { return NextResponse.json( { error: 'unauthorized', detail: 'Missing or malformed Authorization header.' }, { status: 401 } ); } const idToken = authHeader.slice(7).trim(); if (!idToken) { return NextResponse.json( { error: 'unauthorized', detail: 'Missing authentication token.' }, { status: 401 } ); } // 2 & 3. Verify Firebase ID token server-side and derive callerUid exclusively from token let callerUid: string; try { const decoded = await getAdminAuth().verifyIdToken(idToken); callerUid = decoded.uid; } catch { return NextResponse.json( { error: 'unauthorized', detail: 'Invalid or expired authentication token.' }, { status: 401 } ); } // 4. Validate clubId const { clubId } = await context.params; if ( !clubId || typeof clubId !== 'string' || clubId.trim().length === 0 || clubId.length > 64 || /\s/.test(clubId) ) { return NextResponse.json( { error: 'invalid_request', detail: 'Invalid clubId parameter.' }, { status: 400 } ); } const normalizedClubId = clubId.trim(); const db = getAdminFirestore(); // 5. Verify club exists and is unarchived const clubSnap = await db.collection('clubs').doc(normalizedClubId).get(); if (!clubSnap.exists) { return NextResponse.json( { error: 'not_found', detail: 'Club not found.' }, { status: 404 } ); } const clubData = clubSnap.data(); if (clubData?.archivedAt != null) { return NextResponse.json( { error: 'not_found', detail: 'Club is archived.' }, { status: 404 } ); } // 6. Verify caller is an active club member const memberSnap = await db .collection('clubs') .doc(normalizedClubId) .collection('members') .doc(callerUid) .get(); if (!memberSnap.exists) { return NextResponse.json( { error: 'forbidden', detail: 'Active club membership required.' }, { status: 403 } ); } const memberData = memberSnap.data(); if (memberData?.status !== 'active') { return NextResponse.json( { error: 'forbidden', detail: 'Active club membership required.' }, { status: 403 } ); } // 7. Server-Side Mutual-Block Pre-Fetch (avoiding N+1 queries) // 7a. Fetch all users blocked by the caller (single query upfront) const callerBlocksSnap = await db .collection('users') .doc(callerUid) .collection('blocks') .get(); const blockedByCaller = new Set( callerBlocksSnap.docs.map((d: QueryDocumentSnapshot) => d.id) ); // 8. Paginate candidate discoverable upcoming rides until 30 eligible rides collected or query exhausted const TARGET_COUNT = 30; const PAGE_SIZE = 30; const eligibleDocs: QueryDocumentSnapshot[] = []; const checkedOrganizers = new Map(); // orgUid -> isBlocked let lastDoc: QueryDocumentSnapshot | null = null; while (eligibleDocs.length < TARGET_COUNT) { let query = db .collection('group_rides') .where('clubId', '==', normalizedClubId) .where('status', 'in', ['scheduled', 'assembling']) .orderBy('scheduledStartAt', 'asc') .limit(PAGE_SIZE); if (lastDoc) { query = query.startAfter(lastDoc); } const ridesSnap = await query.get(); if (ridesSnap.empty) { break; } lastDoc = ridesSnap.docs[ridesSnap.docs.length - 1]; // Filter candidate rides where caller blocks organizer const candidateDocs = ridesSnap.docs.filter((doc: QueryDocumentSnapshot) => { const organizerUid = doc.data().organizerUid; return typeof organizerUid === 'string' && !blockedByCaller.has(organizerUid); }); // Deduplicate distinct non-caller organizer UIDs needing reverse block checks const uncheckedOrganizerUids = Array.from( new Set( candidateDocs .map((d: QueryDocumentSnapshot) => d.data().organizerUid) .filter((uid: unknown): uid is string => typeof uid === 'string' && uid.length > 0 && uid !== callerUid && !checkedOrganizers.has(uid) ) ) ); if (uncheckedOrganizerUids.length > 0) { const reverseBlockChecks = await Promise.all( uncheckedOrganizerUids.map(async (orgUid: string) => { const blockSnap = await db .collection('users') .doc(orgUid) .collection('blocks') .doc(callerUid) .get(); return { orgUid, isBlocked: blockSnap.exists }; }) ); for (const check of reverseBlockChecks) { checkedOrganizers.set(check.orgUid, check.isBlocked); } } // Filter out rides where organizer blocks caller for (const doc of candidateDocs) { const organizerUid = doc.data().organizerUid; if (typeof organizerUid === 'string') { if (organizerUid === callerUid || !checkedOrganizers.get(organizerUid)) { eligibleDocs.push(doc); if (eligibleDocs.length >= TARGET_COUNT) { break; } } } } if (ridesSnap.docs.length < PAGE_SIZE) { // Query exhausted break; } } if (eligibleDocs.length === 0) { return NextResponse.json( { clubId: normalizedClubId, rides: [], }, { status: 200, headers: { 'Cache-Control': 'no-store, max-age=0', }, } ); } // 9. Response Data Minimization & Physical-Location Privacy // Never expose accessUids, exact coordinates, full route geometry, or live presence const rides: ClubRideDiscoveryItem[] = eligibleDocs.map((doc: QueryDocumentSnapshot) => { const d = doc.data(); const scheduledStartAt = typeof d.scheduledStartAt === 'string' ? d.scheduledStartAt : (d.scheduledStartAt?.toDate?.() ? d.scheduledStartAt.toDate().toISOString() : ''); const departureAt = typeof d.departureAt === 'string' ? d.departureAt : (d.departureAt?.toDate?.() ? d.departureAt.toDate().toISOString() : null); // Coarse human-readable location label only; never exact GPS latitude/longitude or raw street address let meetingLocationLabel: string | null = null; if ( d.meetingPoint && typeof d.meetingPoint === 'object' && typeof d.meetingPoint.displayName === 'string' && d.meetingPoint.displayName.trim().length > 0 ) { meetingLocationLabel = d.meetingPoint.displayName.trim(); } return { rideId: doc.id, clubId: normalizedClubId, title: d.title || 'Untitled Ride', description: d.description || null, organizerDisplayName: d.organizerDisplayName || null, organizerHandle: d.organizerHandle || null, scheduledStartAt, departureAt, joinPolicy: d.joinPolicy || 'inviteOnly', participantCount: typeof d.participantCount === 'number' ? d.participantCount : 1, participantLimit: typeof d.participantLimit === 'number' ? d.participantLimit : null, status: d.status, meetingLocationLabel, routeTitle: d.routeTitle || null, routeDistanceMeters: typeof d.routeDistanceMeters === 'number' ? d.routeDistanceMeters : null, paceClassification: d.paceClassification || null, }; }); return NextResponse.json( { clubId: normalizedClubId, rides, }, { status: 200, headers: { 'Cache-Control': 'no-store, max-age=0', }, } ); } catch (err: any) { console.error('[CLUB_RIDES_DISCOVERY] Internal error:', err?.message || err); return NextResponse.json( { error: 'server_error', detail: 'Club ride discovery is temporarily unavailable.' }, { status: 500 } ); } }