MotorSignal-App/website/app/api/group-rides/route.ts

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 });
}
}