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
+122 -90
View File
@@ -1,107 +1,139 @@
// Uber-style auto-match dispatch. A requested ride has no driver; this engine
// offers it to the nearest eligible driver of the matching service. Drivers
// accept/decline; a decline (or a 15s offer expiry) triggers the next-nearest
// match. There is no background worker — matchNextDriver is called lazily from
// the rider status poll and the driver poll, so matching progresses on every
// request cycle.
// Broadcast dispatch. A new request is put in front of every eligible driver
// near the pickup at once; each of them may volunteer for it (a row in
// ride_offers) and the rider picks between whoever did.
//
// The engine's whole job is therefore the announcement. There is no queue to
// advance, no timer to chase a declining driver with, and no background
// worker: drivers discover requests through their dashboard poll and their
// location heartbeat, and this module exists to make sure a phone that is
// face-down in a pocket still buzzes when a job appears nearby.
import { transaction } from "@/lib/db";
import { haversine } from "@/lib/utils";
import { sql } from "@/lib/db";
import { sendPushToDriver } from "@/lib/push";
import { boundingBox, haversine } from "@/lib/utils";
import { expireStaleRequests } from "@/lib/ride-lifecycle";
import {
BROADCAST_RADIUS_M,
DRIVER_STALE_SECONDS,
OFFER_CHANNEL_ID,
} from "@/constants/dispatch";
// A driver has this long to respond to an offer before it expires and the next
// driver is offered. Tuned short so a rider searching for a driver isn't left
// hanging on a phone that's face-down on a seat.
const OFFER_TTL_SECONDS = 15;
// A driver whose last location ping is older than this is treated as offline
// even if their `online` flag is still true (they closed the app without
// toggling off).
const DRIVER_STALE_SECONDS = 60;
// Fares are stored in cents; the notification shows what the rider is paying.
const formatFare = (cents: number): string => `$${(cents / 100).toFixed(2)}`;
type EligibleDriver = {
type NearbyDriverRow = {
id: number;
latitude: number;
longitude: number;
};
// Offer `rideId` to the nearest eligible driver, if no offer is already in
// flight for it. Idempotent: safe to call on every poll. Returns the driver id
// that was offered, or null if no driver was available.
export const matchNextDriver = async (
rideId: number,
): Promise<number | null> => {
/**
* Drivers who should see `rideId` right now: right service, vetted, online,
* fresh position, not already on a ride, and within the broadcast radius of
* the pickup.
*
* Exported because the driver's own poll asks the mirror-image question —
* "which open requests are near me?" — and the two must agree. If a driver
* could be pushed a request their dashboard then filtered out, they'd get a
* notification for a job that isn't there when they open the app.
*/
export const driversForRequest = async (rideId: number): Promise<number[]> => {
const rides = await sql<{
lat: number;
lng: number;
service: string;
status: string;
}>`
SELECT origin_latitude AS lat, origin_longitude AS lng, service, status
FROM rides WHERE ride_id = ${rideId}
`;
const ride = rides[0];
if (!ride || ride.status !== "requested") return [];
// A coarse bounding box does the work in the index, then a great-circle
// pass trims the corners — same two-step the rider's map search uses.
const box = boundingBox(ride.lat, ride.lng, BROADCAST_RADIUS_M);
const candidates = await sql<NearbyDriverRow>`
SELECT d.id, d.latitude, d.longitude
FROM drivers d
WHERE d.service = ${ride.service}
AND d.online = TRUE
AND d.approval_status = 'approved'
AND d.user_id IS NOT NULL
AND d.latitude IS NOT NULL
AND d.longitude IS NOT NULL
AND d.last_seen > CURRENT_TIMESTAMP - make_interval(secs => ${DRIVER_STALE_SECONDS})
AND d.latitude BETWEEN ${box.minLat} AND ${box.maxLat}
AND d.longitude BETWEEN ${box.minLng} AND ${box.maxLng}
AND NOT EXISTS (
SELECT 1 FROM rides r
WHERE r.driver_id = d.id
AND r.status IN ('accepted', 'arrived', 'en_route')
)
`;
return candidates
.filter(
(d) =>
haversine(ride.lat, ride.lng, d.latitude, d.longitude) <=
BROADCAST_RADIUS_M,
)
.map((d) => d.id);
};
/**
* Announce `rideId` to every eligible driver nearby.
*
* Idempotent, and deliberately so: it is called from the rider's status poll
* as well as from ride creation, and a request that buzzed forty phones once
* must not buzz them again every three seconds. `broadcast_at` is the latch —
* claimed with a guarded UPDATE so two concurrent callers can't both win it.
*
* Returns how many drivers were notified (0 if the announcement was already
* made, or nobody was in range).
*/
export const broadcastRequest = async (rideId: number): Promise<number> => {
try {
return await transaction(async (tx) => {
// Lock the ride row so concurrent matchers serialize on it.
const rides = await tx<{ status: string; service: string }>`
SELECT status, service FROM rides WHERE ride_id = ${rideId} FOR UPDATE
`;
const ride = rides[0];
if (!ride || ride.status !== "requested") return null;
// Give up on requests that have run past their window before announcing
// one — this is one of the lazy paths that stands in for a worker.
await expireStaleRequests(rideId);
// Expire any offers that have been sitting past their TTL.
await tx`
UPDATE ride_offers
SET status = 'expired', responded_at = CURRENT_TIMESTAMP
WHERE ride_id = ${rideId}
AND status = 'offered'
AND offered_at < CURRENT_TIMESTAMP - make_interval(secs => ${OFFER_TTL_SECONDS})
`;
// Claim the announcement. Whoever gets the row does the pushing.
const claimed = await sql<{
origin_address: string;
fare_price: number;
service: string;
}>`
UPDATE rides
SET broadcast_at = CURRENT_TIMESTAMP
WHERE ride_id = ${rideId}
AND status = 'requested'
AND broadcast_at IS NULL
RETURNING origin_address, fare_price, service
`;
if (!claimed[0]) return 0;
// If there is still an active (unexpired) offer in flight, leave it —
// don't stack a second offer on top.
const inFlight = await tx<{ n: number }>`
SELECT COUNT(*)::int AS n FROM ride_offers
WHERE ride_id = ${rideId} AND status = 'offered'
`;
if ((inFlight[0]?.n ?? 0) > 0) return null;
const drivers = await driversForRequest(rideId);
if (drivers.length === 0) return 0;
const rideOrigin = await tx<{ lat: number; lng: number }>`
SELECT origin_latitude AS lat, origin_longitude AS lng
FROM rides WHERE ride_id = ${rideId}
`;
const origin = rideOrigin[0];
if (!origin) return null;
const { origin_address: origin, fare_price: fare } = claimed[0];
// Eligible: right service, online, fresh, a real account, not on an
// active ride, and not already offered/declined for THIS ride.
const candidates = await tx<EligibleDriver>`
SELECT d.id, d.latitude, d.longitude
FROM drivers d
WHERE d.service = ${ride.service}
AND d.online = TRUE
AND d.user_id IS NOT NULL
AND d.latitude IS NOT NULL
AND d.longitude IS NOT NULL
AND d.last_seen > CURRENT_TIMESTAMP - make_interval(secs => ${DRIVER_STALE_SECONDS})
AND NOT EXISTS (
SELECT 1 FROM rides r
WHERE r.driver_id = d.id AND r.status IN ('accepted', 'en_route')
)
AND NOT EXISTS (
SELECT 1 FROM ride_offers ro
WHERE ro.ride_id = ${rideId} AND ro.driver_id = d.id
)
`;
if (candidates.length === 0) return null;
// Nearest by great-circle distance to the pickup point.
candidates.sort((a, b) => {
const da = haversine(origin.lat, origin.lng, a.latitude, a.longitude);
const db = haversine(origin.lat, origin.lng, b.latitude, b.longitude);
return da - db;
// Not awaited: dispatch must not stall on Expo's service, and a driver
// still finds the request through the dashboard poll and the location
// heartbeat regardless.
for (const driverId of drivers) {
void sendPushToDriver(driverId, {
title: "New ride request nearby",
body: `${formatFare(Number(fare))} · pickup at ${origin}`,
channelId: OFFER_CHANNEL_ID,
data: { type: "ride_request", rideId },
});
const nearest = candidates[0];
}
await tx`
INSERT INTO ride_offers (ride_id, driver_id, status)
VALUES (${rideId}, ${nearest.id}, 'offered')
`;
return nearest.id;
});
return drivers.length;
} catch (error) {
console.error("[MATCH_NEXT_DRIVER]: ", error);
return null;
console.error("[BROADCAST_REQUEST]: ", error);
return 0;
}
};
};