import { sql } from "@/lib/db"; import { requireRideParticipant, rideIsActive } from "@/lib/ride-participants"; // In-app WebRTC audio call signaling, carried over the same DB-backed polling // pattern as chat (no WebSocket). Non-trickle ICE: each side gathers all // candidates locally and bundles them into a single SDP offer/answer stored as // text, so the whole handshake is a few polled round-trips. // // POST { sdp_offer } -> caller starts a call (status=ringing) // GET -> poll: callee reads the offer, both read // the answer + status; lazily sweeps stale // ringing calls to 'missed'. // PATCH { action, sdp_answer? } -> answer / decline / end // A ringing call older than this with no answer is treated as missed. Swept // lazily inside GET, the way the broadcast advances on the ride-status poll. const RINGING_TTL_SECONDS = 30; type CallRow = { id: number; ride_id: number; caller_type: "rider" | "driver"; status: "ringing" | "answered" | "ended" | "declined" | "missed"; sdp_offer: string | null; sdp_answer: string | null; started_at: string | null; ended_at: string | null; created_at: string; }; // POST — initiate a call. Rejects if the ride isn't active or a call is already // in flight for it, so two calls can't stack on one ride. export async function POST(req: Request, { id }: { id: string }) { const rideId = Number(id); if (!Number.isInteger(rideId)) { return Response.json({ error: "Invalid ride id." }, { status: 400 }); } const participant = await requireRideParticipant(req, rideId); if ("error" in participant) return participant.error; let body: { sdp_offer?: string }; try { body = await req.json(); } catch { return Response.json({ error: "Invalid JSON body." }, { status: 400 }); } const sdpOffer = body.sdp_offer; if (!sdpOffer || typeof sdpOffer !== "string") { return Response.json({ error: "Missing sdp_offer." }, { status: 400 }); } try { if (!(await rideIsActive(rideId))) { return Response.json( { error: "This ride is no longer active." }, { status: 409 }, ); } // Snapshot both parties onto the call row so authorization is one // equality check on poll and the call survives a driver reassignment. const ride = await sql<{ user_id: string; driver_id: number }>` SELECT user_id, driver_id FROM rides WHERE ride_id = ${rideId} AND driver_id IS NOT NULL `; if (!ride[0]) { return Response.json( { error: "This ride has no driver assigned." }, { status: 409 }, ); } // Only one non-terminal call per ride at a time. const inFlight = await sql<{ n: number }>` SELECT COUNT(*)::int AS n FROM calls WHERE ride_id = ${rideId} AND status IN ('ringing','answered') `; if ((inFlight[0]?.n ?? 0) > 0) { return Response.json( { error: "A call is already in progress for this ride." }, { status: 409 }, ); } const inserted = await sql<{ id: number }>` INSERT INTO calls (ride_id, user_id, driver_id, caller_type, status, sdp_offer) VALUES ( ${rideId}, ${ride[0].user_id}, ${ride[0].driver_id}, ${participant.role}, 'ringing', ${sdpOffer} ) RETURNING id `; return Response.json({ data: { callId: inserted[0].id } }, { status: 201 }); } catch (error) { console.error("[POST_CALL]: ", error); return Response.json({ error: "Internal Server Error." }, { status: 500 }); } } // GET — poll the call for this ride. Returns the latest non-terminal call (or // the most recent terminal one so the caller sees ended/declined/missed), with // `is_caller` so each side knows whether it placed the call. export async function GET(req: Request, { id }: { id: string }) { const rideId = Number(id); if (!Number.isInteger(rideId)) { return Response.json({ error: "Invalid ride id." }, { status: 400 }); } const participant = await requireRideParticipant(req, rideId); if ("error" in participant) return participant.error; try { // Lazy missed-call sweep: a ringing call nobody answered in time is // marked missed so the caller's screen can stop ringing. await sql` UPDATE calls SET status = 'missed', ended_at = CURRENT_TIMESTAMP WHERE ride_id = ${rideId} AND status = 'ringing' AND created_at < CURRENT_TIMESTAMP - make_interval(secs => ${RINGING_TTL_SECONDS}) `; const rows = await sql` SELECT id, ride_id, caller_type, status, sdp_offer, sdp_answer, started_at, ended_at, created_at FROM calls WHERE ride_id = ${rideId} ORDER BY created_at DESC LIMIT 1 `; const call = rows[0] ?? null; return Response.json({ data: call ? { ...call, is_caller: call.caller_type === participant.role } : null, }); } catch (error) { console.error("[GET_CALL]: ", error); return Response.json({ error: "Internal Server Error." }, { status: 500 }); } } // PATCH — answer (callee only), decline (callee only), or end (either). export async function PATCH(req: Request, { id }: { id: string }) { const rideId = Number(id); if (!Number.isInteger(rideId)) { return Response.json({ error: "Invalid ride id." }, { status: 400 }); } const participant = await requireRideParticipant(req, rideId); if ("error" in participant) return participant.error; let body: { action?: string; sdp_answer?: string }; try { body = await req.json(); } catch { return Response.json({ error: "Invalid JSON body." }, { status: 400 }); } const action = body.action; if (action !== "answer" && action !== "decline" && action !== "end") { return Response.json( { error: "action must be 'answer', 'decline', or 'end'." }, { status: 400 }, ); } try { // Answer/decline are the callee's moves; end is either party's. const isCaller = (callerType: string) => callerType === participant.role; const rows = await sql<{ caller_type: string; status: string }>` SELECT caller_type, status FROM calls WHERE ride_id = ${rideId} AND status IN ('ringing','answered') ORDER BY created_at DESC LIMIT 1 `; const call = rows[0]; if (!call) { return Response.json( { error: "No active call for this ride." }, { status: 409 }, ); } if (action === "answer") { if (isCaller(call.caller_type)) { return Response.json( { error: "Caller cannot answer their own call." }, { status: 403 }, ); } if (call.status !== "ringing") { return Response.json( { error: "Call is no longer ringing." }, { status: 409 }, ); } const sdpAnswer = body.sdp_answer; if (!sdpAnswer || typeof sdpAnswer !== "string") { return Response.json({ error: "Missing sdp_answer." }, { status: 400 }); } await sql` UPDATE calls SET status = 'answered', sdp_answer = ${sdpAnswer}, started_at = CURRENT_TIMESTAMP WHERE ride_id = ${rideId} AND status = 'ringing' `; return Response.json({ data: { action: "answered" } }); } if (action === "decline") { if (isCaller(call.caller_type)) { return Response.json( { error: "Caller cannot decline their own call." }, { status: 403 }, ); } await sql` UPDATE calls SET status = 'declined', ended_at = CURRENT_TIMESTAMP WHERE ride_id = ${rideId} AND status = 'ringing' `; return Response.json({ data: { action: "declined" } }); } // end — either party, while ringing or answered. await sql` UPDATE calls SET status = 'ended', ended_at = CURRENT_TIMESTAMP WHERE ride_id = ${rideId} AND status IN ('ringing','answered') `; return Response.json({ data: { action: "ended" } }); } catch (error) { console.error("[PATCH_CALL]: ", error); return Response.json({ error: "Internal Server Error." }, { status: 500 }); } }