160 lines
4.7 KiB
TypeScript
160 lines
4.7 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<{
|
|
rideId: string;
|
|
}>;
|
|
}
|
|
|
|
export interface GroupRideJoinRequestDto {
|
|
userId: string;
|
|
riderDisplayName: string | null;
|
|
riderHandle: string | null;
|
|
riderAvatarPreset: string | null;
|
|
riderAvatarUrl: string | null;
|
|
createdAt: string;
|
|
}
|
|
|
|
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. Verify Firebase ID token server-side and derive callerUid strictly 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 }
|
|
);
|
|
}
|
|
|
|
// 3. Validate rideId
|
|
const { rideId } = await context.params;
|
|
if (
|
|
!rideId ||
|
|
typeof rideId !== 'string' ||
|
|
rideId.trim().length === 0 ||
|
|
rideId.length > 64 ||
|
|
/\s/.test(rideId)
|
|
) {
|
|
return NextResponse.json(
|
|
{ error: 'invalid_request', detail: 'Invalid rideId parameter.' },
|
|
{ status: 400 }
|
|
);
|
|
}
|
|
const normalizedRideId = rideId.trim();
|
|
|
|
const db = getAdminFirestore();
|
|
|
|
// 4. Fetch ride document
|
|
const rideSnap = await db.collection('group_rides').doc(normalizedRideId).get();
|
|
if (!rideSnap.exists) {
|
|
return NextResponse.json(
|
|
{ error: 'not_found', detail: 'Group ride not found.' },
|
|
{ status: 404 }
|
|
);
|
|
}
|
|
|
|
const rideData = rideSnap.data() || {};
|
|
const organizerUid = rideData.organizerUid;
|
|
const isOrganizer = organizerUid === callerUid;
|
|
|
|
let isClubAdmin = false;
|
|
if (!isOrganizer && typeof rideData.clubId === 'string' && rideData.clubId.trim().length > 0) {
|
|
const clubId = rideData.clubId.trim();
|
|
const memberSnap = await db
|
|
.collection('clubs')
|
|
.doc(clubId)
|
|
.collection('members')
|
|
.doc(callerUid)
|
|
.get();
|
|
|
|
if (memberSnap.exists) {
|
|
const memberData = memberSnap.data();
|
|
if (
|
|
memberData?.status === 'active' &&
|
|
(memberData?.role === 'admin' || memberData?.role === 'owner')
|
|
) {
|
|
isClubAdmin = true;
|
|
}
|
|
}
|
|
}
|
|
|
|
// 5. Require caller to be either organizer or active club administrator
|
|
if (!isOrganizer && !isClubAdmin) {
|
|
return NextResponse.json(
|
|
{ error: 'forbidden', detail: 'Only the organizer or a club administrator can view join requests.' },
|
|
{ status: 403 }
|
|
);
|
|
}
|
|
|
|
// 6. Query pending join requests bounded by limit 50, ordered by createdAt asc
|
|
const requestsSnap = await db
|
|
.collection('group_rides')
|
|
.doc(normalizedRideId)
|
|
.collection('participants')
|
|
.where('status', '==', 'requested')
|
|
.orderBy('createdAt', 'asc')
|
|
.limit(50)
|
|
.get();
|
|
|
|
// 7. Minimal DTO mapping (never expose location, live presence, or telemetry)
|
|
const requests: GroupRideJoinRequestDto[] = requestsSnap.docs.map((doc: QueryDocumentSnapshot) => {
|
|
const d = doc.data();
|
|
const createdAt = typeof d.createdAt === 'string'
|
|
? d.createdAt
|
|
: (d.createdAt?.toDate?.() ? d.createdAt.toDate().toISOString() : '');
|
|
|
|
return {
|
|
userId: d.userId || doc.id,
|
|
riderDisplayName: d.riderDisplayName || null,
|
|
riderHandle: d.riderHandle || null,
|
|
riderAvatarPreset: d.riderAvatarPreset || null,
|
|
riderAvatarUrl: d.riderAvatarUrl || null,
|
|
createdAt,
|
|
};
|
|
});
|
|
|
|
return NextResponse.json(
|
|
{
|
|
rideId: normalizedRideId,
|
|
requests,
|
|
},
|
|
{
|
|
status: 200,
|
|
headers: {
|
|
'Cache-Control': 'no-store, max-age=0',
|
|
},
|
|
}
|
|
);
|
|
} catch (err: any) {
|
|
console.error('[GROUP_RIDE_JOIN_REQUESTS] Internal error:', err?.message || err);
|
|
return NextResponse.json(
|
|
{ error: 'server_error', detail: 'Join requests are temporarily unavailable.' },
|
|
{ status: 500 }
|
|
);
|
|
}
|
|
}
|