import { requireDriverProfile } from "@/lib/driver"; import { sql } from "@/lib/db"; import { DRIVER_BUSY_ARRAY } from "@/lib/ride-lifecycle"; import { boundingBox, haversine } from "@/lib/utils"; import { BROADCAST_RADIUS_M, REQUEST_TTL_SECONDS } from "@/constants/dispatch"; // POST — driver location heartbeat. Each ping updates lat/lng/last_seen and // keeps the driver marked online. The client (use-driver-location) fires this // every few seconds while the driver's online toggle is on; going offline is // an explicit PATCH to /driver/profile, not the absence of pings. export async function POST(req: Request) { const result = await requireDriverProfile(req); if ("error" in result) return result.error; try { const body = await req.json(); const { latitude, longitude, heading, speed_kph } = body; if ( typeof latitude !== "number" || typeof longitude !== "number" || Number.isNaN(latitude) || Number.isNaN(longitude) ) { return Response.json( { error: "latitude and longitude must be numbers." }, { status: 400 }, ); } // Heading and speed are optional and frequently unavailable — a phone // sitting still reports heading -1, and a cached fix may carry neither. // Anything unusable is stored as NULL rather than as a wrong direction, // because a confidently wrong arrow on a rider's map is worse than none. const bearing = typeof heading === "number" && heading >= 0 && heading <= 360 ? Math.round(heading) % 360 : null; const speed = typeof speed_kph === "number" && speed_kph >= 0 && speed_kph < 300 ? Math.round(speed_kph) : null; // A ping refreshes position and liveness only. It deliberately does NOT // set online = TRUE: a ping already in flight when the driver toggles off // would land afterwards and put them back in the match pool, so they'd // keep getting requests they thought they'd opted out of. Going online is // an explicit PATCH to /driver/profile and nothing else. const { driverId } = result; const rows = await sql` UPDATE drivers SET latitude = ${latitude}, longitude = ${longitude}, -- COALESCE, not overwrite: a fix without a usable heading (typical -- at a standstill) shouldn't erase the direction the car was last -- known to be facing, which is still the best guess for how it's -- parked. Speed does overwrite, because "not moving" is real -- information and must be able to reach zero. heading = COALESCE(${bearing}, heading), speed_kph = ${speed}, last_seen = CURRENT_TIMESTAMP WHERE id = ${driverId} RETURNING id, latitude, longitude, heading, speed_kph, last_seen, online `; // The nearest open request this driver could take, returned with the // heartbeat. // // While a driver is online this endpoint is hit every few seconds by a // foreground-service location task that keeps running with the screen // off — so it is the one request we know is still happening when the // dashboard poll has stopped. Piggybacking the nearest job here lets the // app raise a local notification for it without a second round trip, and // without needing remote push credentials. // // Filtered to requests this driver hasn't already offered on, so a driver // who volunteered and is waiting on the rider isn't buzzed about the same // job every five seconds. const box = boundingBox(latitude, longitude, BROADCAST_RADIUS_M); const driver = rows[0] as { online?: boolean } | undefined; const nearby = driver?.online ? await sql<{ ride_id: number; origin_address: string; fare_price: number; origin_latitude: number; origin_longitude: number; }>` SELECT r.ride_id, r.origin_address, r.fare_price, r.origin_latitude, r.origin_longitude FROM rides r WHERE r.status = 'requested' AND r.service = (SELECT service FROM drivers WHERE id = ${driverId}) AND r.created_at > CURRENT_TIMESTAMP - make_interval(secs => ${REQUEST_TTL_SECONDS}) AND r.origin_latitude BETWEEN ${box.minLat} AND ${box.maxLat} AND r.origin_longitude BETWEEN ${box.minLng} AND ${box.maxLng} AND NOT EXISTS ( SELECT 1 FROM ride_offers ro WHERE ro.ride_id = r.ride_id AND ro.driver_id = ${driverId} AND ro.status = 'offered' ) AND NOT EXISTS ( SELECT 1 FROM rides busy WHERE busy.driver_id = ${driverId} AND busy.status = ANY(${DRIVER_BUSY_ARRAY}::text[]) ) ORDER BY r.created_at DESC LIMIT 5 ` : []; // Same great-circle trim the dashboard applies, so the notification and // the list the driver opens agree on what counts as nearby. const pending = nearby .map((r) => ({ ride_id: r.ride_id, origin_address: r.origin_address, fare_price: Number(r.fare_price), distance: haversine( latitude, longitude, Number(r.origin_latitude), Number(r.origin_longitude), ), })) .filter((r) => r.distance <= BROADCAST_RADIUS_M) .sort((a, b) => a.distance - b.distance)[0]; return Response.json({ data: { ...rows[0], pending_request: pending ?? null }, }); } catch (error) { console.error("[DRIVER_LOCATION]: ", error); return Response.json({ error: "Internal Server Error" }, { status: 500 }); } }