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>
383 lines
14 KiB
TypeScript
383 lines
14 KiB
TypeScript
import { requireAuth } from "@/lib/jwt";
|
|
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.
|
|
//
|
|
// 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;
|
|
|
|
const rideId = Number(id);
|
|
if (!Number.isInteger(rideId)) {
|
|
return Response.json({ error: "Invalid ride id." }, { status: 400 });
|
|
}
|
|
|
|
try {
|
|
const ride = await sql`
|
|
SELECT status FROM rides WHERE ride_id = ${rideId} AND user_id = ${auth.userId}
|
|
`;
|
|
if (!ride[0]) {
|
|
return Response.json({ error: "Ride not found." }, { status: 404 });
|
|
}
|
|
|
|
// 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") {
|
|
await broadcastRequest(rideId);
|
|
}
|
|
|
|
const rows = await sql`
|
|
SELECT
|
|
r.ride_id,
|
|
r.origin_address,
|
|
r.destination_address,
|
|
r.origin_latitude,
|
|
r.origin_longitude,
|
|
r.destination_latitude,
|
|
r.destination_longitude,
|
|
r.ride_time,
|
|
r.fare_price,
|
|
r.payment_status,
|
|
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,
|
|
'last_name', d.last_name,
|
|
'car_seats', d.car_seats,
|
|
'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,
|
|
'longitude', d.longitude
|
|
) AS driver
|
|
FROM rides r
|
|
LEFT JOIN drivers d ON d.id = r.driver_id
|
|
WHERE r.ride_id = ${rideId}
|
|
`;
|
|
|
|
// 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 });
|
|
}
|
|
}
|
|
|
|
// PATCH — ride lifecycle transitions.
|
|
// 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;
|
|
reason?: string;
|
|
pickup_code?: string;
|
|
cash_collected?: boolean;
|
|
};
|
|
try {
|
|
body = await request.json();
|
|
} catch {
|
|
return Response.json({ error: "Invalid JSON body." }, { status: 400 });
|
|
}
|
|
|
|
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 {
|
|
// 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 riderCancel = await sql<{ status: string }>`
|
|
UPDATE rides
|
|
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 = ANY(${RIDER_CANCELLABLE_ARRAY}::text[])
|
|
RETURNING status
|
|
`;
|
|
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 } });
|
|
}
|
|
|
|
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 === "arrived" || next === "en_route" || next === "completed") {
|
|
const result = await requireDriverProfile(request);
|
|
if ("error" in result) return result.error;
|
|
|
|
const { driverId } = result;
|
|
|
|
// 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,
|
|
payment_status: rows[0].payment_status,
|
|
},
|
|
});
|
|
}
|
|
|
|
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 });
|
|
}
|
|
}
|