// 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 => { 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` 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 => { 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; } };