import { FieldPath, type QueryDocumentSnapshot } from 'firebase-admin/firestore'; import { NextRequest, NextResponse } from 'next/server'; import { getAdminAuth, getAdminFirestore } from '../../../lib/firebase-admin'; export const dynamic = 'force-dynamic'; const DEFAULT_PAGE_SIZE = 30; const MAX_PAGE_SIZE = 50; function isoDate(value: unknown): string | null { if (typeof value === 'string') { const date = new Date(value); return Number.isNaN(date.getTime()) ? null : date.toISOString(); } if (value && typeof (value as { toDate?: unknown }).toDate === 'function') { const date = (value as { toDate: () => Date }).toDate(); return Number.isNaN(date.getTime()) ? null : date.toISOString(); } return null; } export async function GET(request: NextRequest) { try { const authorization = request.headers.get('authorization'); if (!authorization?.startsWith('Bearer ')) { return NextResponse.json({ error: 'unauthorized' }, { status: 401 }); } let callerUid: string; try { callerUid = (await getAdminAuth().verifyIdToken(authorization.slice(7).trim())).uid; } catch { return NextResponse.json({ error: 'unauthorized' }, { status: 401 }); } const params = request.nextUrl.searchParams; const requestedSize = params.get('pageSize'); const pageSize = requestedSize === null ? DEFAULT_PAGE_SIZE : Number(requestedSize); const cursor = params.get('cursor'); if (!Number.isInteger(pageSize) || pageSize < 1 || pageSize > MAX_PAGE_SIZE || (cursor !== null && (!cursor || cursor.length > 64 || /\s/.test(cursor)))) { return NextResponse.json({ error: 'invalid_request' }, { status: 400 }); } const db = getAdminFirestore(); const ridesCollection = db.collection('group_rides'); let ridesQuery = ridesCollection .where('visibility', '==', 'public') .where('status', 'in', ['scheduled', 'assembling']) .orderBy('scheduledStartAt', 'asc') .orderBy(FieldPath.documentId(), 'asc'); if (cursor) { const cursorDoc = await ridesCollection.doc(cursor).get(); if (!cursorDoc.exists) { return NextResponse.json({ error: 'invalid_cursor' }, { status: 400 }); } ridesQuery = ridesQuery.startAfter(cursorDoc); } const scanLimit = pageSize * 2; const rideSnapshot = await ridesQuery.limit(scanLimit).get(); const candidates = rideSnapshot.docs.filter((ride: QueryDocumentSnapshot) => { const data = ride.data(); const scheduledStartAt = isoDate(data.scheduledStartAt); return !('clubId' in data) && scheduledStartAt !== null && new Date(scheduledStartAt).getTime() > Date.now() && typeof data.organizerUid === 'string' && data.organizerUid.length > 0; }); const callerBlocksSnapshot = await db .collection('users').doc(callerUid).collection('blocks').get(); const blockedByCaller = new Set(callerBlocksSnapshot.docs.map((blocked) => blocked.id)); const eligible = candidates.filter((ride) => !blockedByCaller.has(ride.data().organizerUid)); const organizersToCheck = [...new Set(eligible .map((ride) => ride.data().organizerUid as string) .filter((uid) => uid !== callerUid))]; const reverseBlocks = await Promise.all(organizersToCheck.map(async (organizerUid) => { const blocked = await db.collection('users').doc(organizerUid) .collection('blocks').doc(callerUid).get(); return [organizerUid, blocked.exists] as const; })); const blockedCallers = new Set(reverseBlocks.filter(([, blocked]) => blocked).map(([uid]) => uid)); const visible = eligible.filter((ride) => !blockedCallers.has(ride.data().organizerUid)); const page = visible.slice(0, pageSize); const participantSnapshots = await Promise.all(page.map((ride) => ride.ref.collection('participants').doc(callerUid).get())); const rides = page.map((ride, index) => { const data = ride.data(); const meetingPoint = data.meetingPoint && typeof data.meetingPoint === 'object' ? data.meetingPoint as Record : {}; const participantStatus = participantSnapshots[index].data()?.status; const isOrganizer = data.organizerUid === callerUid; const userStatus = isOrganizer || participantStatus === 'accepted' || participantStatus === 'invited' ? 'joined' : participantStatus === 'requested' || participantStatus === 'waitlisted' ? participantStatus : 'none'; const scheduledStartAt = isoDate(data.scheduledStartAt)!; return { id: ride.id, title: typeof data.title === 'string' ? data.title : 'Group ride', description: typeof data.description === 'string' ? data.description : '', region: typeof meetingPoint.address === 'string' ? meetingPoint.address : typeof meetingPoint.displayName === 'string' ? meetingPoint.displayName : 'Regional ride', meetingPoint: typeof meetingPoint.displayName === 'string' ? meetingPoint.displayName : typeof meetingPoint.address === 'string' ? meetingPoint.address : 'Rally point shared by organizer', scheduledStartAt, paceClassification: typeof data.paceClassification === 'string' ? data.paceClassification : 'Brisk Pace', discipline: typeof data.discipline === 'string' ? data.discipline : 'Sport / Canyon', formationProfile: typeof data.formationProfile === 'string' ? data.formationProfile : 'Staggered Dual Column [B]', joinPolicy: data.joinPolicy === 'open' || data.joinPolicy === 'inviteOnly' ? data.joinPolicy : 'requestToJoin', leadHandle: typeof data.organizerDisplayName === 'string' ? data.organizerDisplayName : typeof data.organizerHandle === 'string' ? data.organizerHandle : 'Rider', leadAvatarText: String(data.organizerDisplayName || data.organizerHandle || 'R').slice(0, 1).toUpperCase(), participantCount: typeof data.participantCount === 'number' ? data.participantCount : 1, participantLimit: typeof data.participantLimit === 'number' ? data.participantLimit : 12, distance: typeof data.routeDistanceMeters === 'number' && data.routeDistanceMeters > 0 ? `${(data.routeDistanceMeters / 1609.34).toFixed(1)} Miles` : typeof data.distance === 'string' ? data.distance : 'Open Route', routeSnippet: typeof data.routeTitle === 'string' ? data.routeTitle : '', userStatus, isOrganizer, status: data.status, rideCode: ride.id, }; }); const hasMoreCandidates = rideSnapshot.docs.length === scanLimit; const hasMoreVisible = visible.length > pageSize; const nextCursor = hasMoreVisible ? page[page.length - 1]?.id : hasMoreCandidates ? rideSnapshot.docs[rideSnapshot.docs.length - 1]?.id : undefined; return NextResponse.json( { rides, nextCursor: nextCursor ?? null }, { headers: { 'Cache-Control': 'no-store, max-age=0' } } ); } catch (error) { console.error('[GROUP_RIDE_DISCOVERY] Failed to list public rides:', error); return NextResponse.json({ error: 'server_error' }, { status: 500 }); } }