294 lines
9.1 KiB
TypeScript
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 }
|
|
);
|
|
}
|
|
}
|