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:
co-authored by
Claude Opus 5
parent
1d84003e0a
commit
8807ff41c5
+122
-90
@@ -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;
|
||||
}
|
||||
};
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user