MotorSignal-App/website/app/api/clubs/[clubId]/rides/route.ts

294 lines
9.1 KiB
TypeScript

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<string>(
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<string, boolean>(); // 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 }
);
}
}