Files
waseel/lib/dispatch.ts
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

140 lines
4.9 KiB
TypeScript

// 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 { 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";
// Fares are stored in cents; the notification shows what the rider is paying.
const formatFare = (cents: number): string => `$${(cents / 100).toFixed(2)}`;
type NearbyDriverRow = {
id: number;
latitude: number;
longitude: number;
};
/**
* 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 {
// 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);
// 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;
const drivers = await driversForRequest(rideId);
if (drivers.length === 0) return 0;
const { origin_address: origin, fare_price: fare } = claimed[0];
// 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 },
});
}
return drivers.length;
} catch (error) {
console.error("[BROADCAST_REQUEST]: ", error);
return 0;
}
};