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>
This commit is contained in:
Krikorios
2026-08-26 02:17:55 +03:00
co-authored by Claude Opus 5
parent 1d84003e0a
commit 8807ff41c5
111 changed files with 14568 additions and 1411 deletions
+272 -38
View File
@@ -1,11 +1,22 @@
import { requireAuth } from "@/lib/jwt";
import { sql, query } from "@/lib/db";
import { matchNextDriver } from "@/lib/dispatch";
import { sql } from "@/lib/db";
import { broadcastRequest } from "@/lib/dispatch";
import { requireDriverProfile } from "@/lib/driver";
import {
isCancellationReason,
DRIVER_CANCELLABLE_ARRAY,
RIDER_CANCELLABLE_ARRAY,
} from "@/lib/ride-lifecycle";
import { REQUEST_TTL_SECONDS } from "@/constants/dispatch";
import { COMMISSION_RATE } from "@/lib/pricing";
// GET — single ride by id, the rider's status-poll endpoint. If the ride is
// still 'requested' with no offer in flight, kick auto-match before reading
// so the rider's poll itself drives matching forward (no background worker).
// GET — single ride by id, the rider's status-poll endpoint.
//
// While the ride is still open this also returns the drivers who have offered
// on it, which is what the rider chooses from. The poll re-drives the
// broadcast too (a no-op once announced), so a request whose announcement lost
// its race with the push service still reaches drivers on the next tick —
// there is no background worker to do it.
export async function GET(request: Request, { id }: { id: string }) {
const auth = requireAuth(request);
if ("error" in auth) return auth.error;
@@ -23,9 +34,13 @@ export async function GET(request: Request, { id }: { id: string }) {
return Response.json({ error: "Ride not found." }, { status: 404 });
}
// Lazy match: try to offer the ride to a driver if it's still requested.
// Lazy dispatch: announce the request if that hasn't happened yet, and
// give up on it if it has run past its window. Awaited, because the row
// this request is about to read is the one the sweep may rewrite — a
// rider whose request just expired should be told, not shown a list of
// drivers they can no longer pick.
if (ride[0].status === "requested") {
void matchNextDriver(rideId);
await broadcastRequest(rideId);
}
const rows = await sql`
@@ -43,8 +58,22 @@ export async function GET(request: Request, { id }: { id: string }) {
r.status,
r.service,
r.created_at,
r.accepted_at,
r.arrived_at,
r.started_at,
r.completed_at,
r.cancelled_at,
r.cancelled_by,
r.cancellation_reason,
r.cash_collected_at,
-- The rider's copy of the pickup code. Only ever sent to the ride's
-- own rider (this route is rider-scoped), and only while it still
-- matters: once the trip has started the code is spent.
CASE WHEN r.status IN ('accepted', 'arrived') THEN r.pickup_code END
AS pickup_code,
-- Has this rider already rated the ride? Drives the rating card.
(SELECT rr.rating FROM ride_ratings rr
WHERE rr.ride_id = r.ride_id AND rr.rater_type = 'rider') AS my_rating,
json_build_object(
'id', d.id,
'first_name', d.first_name,
@@ -53,6 +82,7 @@ export async function GET(request: Request, { id }: { id: string }) {
'profile_image_url', d.profile_image_url,
'car_image_url', d.car_image_url,
'rating', d.rating,
'rating_count', d.rating_count,
'service', d.service,
'car_model', d.car_model,
'latitude', d.latitude,
@@ -63,7 +93,38 @@ export async function GET(request: Request, { id }: { id: string }) {
WHERE r.ride_id = ${rideId}
`;
return Response.json({ data: rows[0] });
// The drivers who have volunteered, newest first. Only while the request
// is open: once it is assigned, the losing offers are nobody's business
// and the winning one is just "your driver". Coordinates are deliberately
// not included — a rider comparing offers needs how far away each driver
// is, not where they are, and only the chosen driver's position is theirs
// to watch.
const offers =
rows[0]?.status === "requested"
? await sql`
SELECT
ro.id AS offer_id, ro.offered_at, ro.pickup_distance_m,
d.id AS driver_id, d.first_name, d.last_name,
d.profile_image_url, d.car_image_url, d.car_model, d.car_seats,
d.rating, d.rating_count, d.service
FROM ride_offers ro
JOIN drivers d ON d.id = ro.driver_id
WHERE ro.ride_id = ${rideId} AND ro.status = 'offered'
ORDER BY ro.pickup_distance_m NULLS LAST, ro.offered_at
`
: [];
return Response.json({
data: {
...rows[0],
offers,
// The server's clock and the request window, so the "still looking"
// countdown the rider watches is the one the server actually enforces
// rather than whatever their phone thinks the time is.
now: new Date().toISOString(),
request_ttl_seconds: REQUEST_TTL_SECONDS,
},
});
} catch (error) {
console.error("[GET_RIDE]: ", error);
return Response.json({ error: "Internal Server Error" }, { status: 500 });
@@ -71,17 +132,27 @@ export async function GET(request: Request, { id }: { id: string }) {
}
// PATCH — ride lifecycle transitions.
// Rider: { status: 'cancelled' } — only from 'requested' or 'accepted', and
// only on their own ride.
// Driver: { status: 'en_route' | 'completed' } — only on the ride they own
// (driver_id = their profile), from the right prior state.
// Rider: { status: 'cancelled', reason? } — before the trip starts, on
// their own ride.
// Driver: { status: 'arrived' } accepted -> arrived
// { status: 'en_route', pickup_code } arrived -> en_route
// { status: 'completed', cash_collected? } en_route -> completed
// { status: 'cancelled', reason? } before the trip starts
// Every transition is a single guarded UPDATE: the prior state is part of the
// WHERE clause, so a double-tap or a stale client can't skip a step or
// resurrect a finished ride, and two racing writers can't both win.
export async function PATCH(request: Request, { id }: { id: string }) {
const rideId = Number(id);
if (!Number.isInteger(rideId)) {
return Response.json({ error: "Invalid ride id." }, { status: 400 });
}
let body: { status?: string };
let body: {
status?: string;
reason?: string;
pickup_code?: string;
cash_collected?: boolean;
};
try {
body = await request.json();
} catch {
@@ -89,60 +160,223 @@ export async function PATCH(request: Request, { id }: { id: string }) {
}
const next = body.status;
// A reason is optional, but if one is sent it has to be a known code — the
// admin portal counts these, and free text would make them uncountable.
const reason = body.reason;
if (reason !== undefined && !isCancellationReason(reason)) {
return Response.json(
{ error: "Unknown cancellation reason." },
{ status: 400 },
);
}
try {
// Rider cancel — authenticate by ownership of the ride.
// Cancel — either the rider (any time before the trip starts) or the
// assigned driver (same window). Rider path is tried first: a user who is
// also a driver should cancel their own ride as a rider, not be misrouted
// to the driver branch.
if (next === "cancelled") {
const auth = requireAuth(request);
if ("error" in auth) return auth.error;
const rows = await sql<{ status: string }>`
const riderCancel = await sql<{ status: string }>`
UPDATE rides
SET status = 'cancelled', cancelled_at = CURRENT_TIMESTAMP
SET status = 'cancelled',
cancelled_at = CURRENT_TIMESTAMP,
cancelled_by = 'rider',
cancellation_reason = ${reason ?? null}
WHERE ride_id = ${rideId}
AND user_id = ${auth.userId}
AND status IN ('requested', 'accepted')
AND status = ANY(${RIDER_CANCELLABLE_ARRAY}::text[])
RETURNING status
`;
if (!rows[0]) {
return Response.json(
{ error: "Ride cannot be cancelled." },
{ status: 409 },
);
if (riderCancel[0]) {
// Free the driver's offer so dispatch doesn't keep a phantom offer in
// flight for a ride that no longer exists.
await sql`
UPDATE ride_offers
SET status = 'cancelled', responded_at = CURRENT_TIMESTAMP
WHERE ride_id = ${rideId} AND status = 'offered'
`;
return Response.json({ data: { status: riderCancel[0].status } });
}
return Response.json({ data: { status: rows[0].status } });
const driver = await requireDriverProfile(request);
if (!("error" in driver)) {
const driverCancel = await sql<{ status: string }>`
UPDATE rides
SET status = 'cancelled',
cancelled_at = CURRENT_TIMESTAMP,
cancelled_by = 'driver',
cancellation_reason = ${reason ?? null}
WHERE ride_id = ${rideId}
AND driver_id = ${driver.driverId}
AND status = ANY(${DRIVER_CANCELLABLE_ARRAY}::text[])
RETURNING status
`;
if (driverCancel[0]) {
return Response.json({ data: { status: driverCancel[0].status } });
}
}
return Response.json(
{ error: "Ride cannot be cancelled." },
{ status: 409 },
);
}
// Driver transitions — must be the driver assigned to the ride.
if (next === "en_route" || next === "completed") {
if (next === "arrived" || next === "en_route" || next === "completed") {
const result = await requireDriverProfile(request);
if ("error" in result) return result.error;
const { driverId } = result;
const priorStatus = next === "en_route" ? "accepted" : "en_route";
const setClause =
next === "completed"
? "status = $1, completed_at = CURRENT_TIMESTAMP, driver_id = $2"
: "status = $1, driver_id = $2";
const rows = await query<{ status: string }>(
`UPDATE rides SET ${setClause}
WHERE ride_id = $3 AND driver_id = $2 AND status = $4
RETURNING status`,
[next, driverId, rideId, priorStatus],
);
// Driver is at the pickup point. Purely informational for the rider,
// but it's the signal that turns "on the way" into "your car is here".
if (next === "arrived") {
const rows = await sql<{ status: string }>`
UPDATE rides
SET status = 'arrived', arrived_at = CURRENT_TIMESTAMP
WHERE ride_id = ${rideId}
AND driver_id = ${driverId}
AND status = 'accepted'
RETURNING status
`;
if (!rows[0]) {
return Response.json(
{ error: "Ride cannot transition to that state." },
{ status: 409 },
);
}
return Response.json({ data: { status: rows[0].status } });
}
// Start the trip. The pickup code is the handshake that proves the
// person in the car is the rider who ordered it — checked inside the
// UPDATE so a wrong code can't start the trip even under a race.
if (next === "en_route") {
const code = String(body.pickup_code ?? "").trim();
if (!code) {
return Response.json(
{ error: "Pickup code required.", code: "PICKUP_CODE_REQUIRED" },
{ status: 400 },
);
}
const rows = await sql<{ status: string }>`
UPDATE rides
SET status = 'en_route', started_at = CURRENT_TIMESTAMP
WHERE ride_id = ${rideId}
AND driver_id = ${driverId}
AND status IN ('accepted', 'arrived')
AND pickup_code = ${code}
RETURNING status
`;
if (!rows[0]) {
// Distinguish "wrong code" from "wrong state" — the driver needs to
// know whether to re-ask the rider or reload the screen.
const current = await sql<{
status: string;
pickup_code: string | null;
}>`
SELECT status, pickup_code FROM rides
WHERE ride_id = ${rideId} AND driver_id = ${driverId}
`;
if (
current[0] &&
["accepted", "arrived"].includes(current[0].status) &&
current[0].pickup_code !== code
) {
return Response.json(
{
error: "That code doesn't match.",
code: "PICKUP_CODE_INVALID",
},
{ status: 403 },
);
}
return Response.json(
{ error: "Ride cannot transition to that state." },
{ status: 409 },
);
}
return Response.json({ data: { status: rows[0].status } });
}
// Complete. For a cash ride the driver also confirms they collected the
// fare, which is what moves the money from "owed" to "settled" — a cash
// ride left at payment_status='cash' is an unreconciled debt, and the
// admin portal reports on exactly that gap.
const settleCash = body.cash_collected === true;
// Stamp the fare split at completion. Computed from the row's own
// fare_price inside the UPDATE so it can't disagree with what was
// charged, and recorded with the rate used so a later rate change never
// rewrites what this driver was owed today.
//
// The ::numeric casts are load-bearing. Parameters are sent untyped, so
// Postgres infers each one from context — and next to an integer column
// it infers `fare_price * $n` as integer multiplication, then refuses to
// parse "0.2" as an integer. Every completion failed on that, which is
// what left drivers unable to end a trip at all.
const rows = await sql<{ status: string; payment_status: string }>`
UPDATE rides
SET status = 'completed',
completed_at = CURRENT_TIMESTAMP,
commission_rate = ${COMMISSION_RATE}::numeric,
platform_fee_cents = ROUND(fare_price * ${COMMISSION_RATE}::numeric),
driver_payout_cents =
fare_price - ROUND(fare_price * ${COMMISSION_RATE}::numeric),
payment_status = CASE
WHEN payment_status = 'cash' AND ${settleCash}::boolean
THEN 'cash_collected'
ELSE payment_status
END,
cash_collected_at = CASE
WHEN payment_status = 'cash' AND ${settleCash}::boolean
THEN CURRENT_TIMESTAMP
ELSE cash_collected_at
END,
-- Whoever physically holds their own share is settled immediately;
-- only the other side is left owed. A card ride means the company
-- has its fee and owes the driver; a collected cash fare means the
-- driver has their payout and owes the company. See
-- lib/settlement.ts, which is where this rule is defined.
platform_fee_settled_at = CASE
WHEN payment_status = 'paid' THEN CURRENT_TIMESTAMP
ELSE platform_fee_settled_at
END,
driver_payout_settled_at = CASE
WHEN payment_status = 'cash' AND ${settleCash}::boolean
THEN CURRENT_TIMESTAMP
ELSE driver_payout_settled_at
END
WHERE ride_id = ${rideId}
AND driver_id = ${driverId}
AND status = 'en_route'
RETURNING status, payment_status
`;
if (!rows[0]) {
return Response.json(
{ error: "Ride cannot transition to that state." },
{ status: 409 },
);
}
return Response.json({ data: { status: rows[0].status } });
return Response.json({
data: {
status: rows[0].status,
payment_status: rows[0].payment_status,
},
});
}
return Response.json({ error: "Unknown status transition." }, { status: 400 });
return Response.json(
{ error: "Unknown status transition." },
{ status: 400 },
);
} catch (error) {
console.error("[PATCH_RIDE]: ", error);
return Response.json({ error: "Internal Server Error" }, { status: 500 });
}
}
}