147 lines
7.0 KiB
TypeScript
147 lines
7.0 KiB
TypeScript
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 data.clubId == null && 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<string, unknown>
|
|
: {};
|
|
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 });
|
|
}
|
|
}
|