MotorSignal-App/website/app/api/group-rides/[rideId]/join-requests/route.ts

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