Files
waseel/app/(api)/ride/[id]/call+api.ts
T
KrikoriosandClaude Opus 5 8807ff41c5 Waseel: driver capture, chat/calls, dispatch, and session fixes
Driver onboarding now photographs the licence, ID card and vehicle
registration and reads the credential fields off them, plus a camera-only
profile selfie riders check the arriving driver against. Adds in-app chat
and WebRTC calls, push-backed ride offers, ratings, cancellation and
payment sheets, settlement, and the owner dashboard endpoints behind them.

Camera permission on Android:
  - Declare CAMERA and READ_MEDIA_IMAGES in the manifest. expo-image-picker's
    own plugin never declares CAMERA, and Android denies a request for an
    undeclared permission instantly and silently — no dialog is ever shown,
    which is indistinguishable from the app not asking at all.
  - Handle canAskAgain: once Android stops showing the dialog, repeating why
    we need it is a dead end, so offer Open Settings instead (lib/capture-
    permission.ts), matching what the location flow already did.

Session: a 401 on a request that carried a token now ends the session
instead of being reinterpreted per-screen — driver-home had been reading it
as "this user has no driver profile" and showing an onboarding form to an
already-onboarded driver. Requests without a token are exempt so a failed
sign-in doesn't sign you out, and the notification is latched per token so
concurrent polls tear the session down once. (root) gains the auth guard
that turns that into the sign-in screen; app/index.tsx only guarded the way
in, leaving a session that ended mid-screen with nowhere to go.

Also ignore .uploads/ — it holds driver licence, ID and vehicle scans plus
profile photos, which are personal data and must not be committed.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-26 02:17:55 +03:00

244 lines
8.1 KiB
TypeScript

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