import { requireDriverProfile } from "@/lib/driver"; import { sql } from "@/lib/db"; import { DRIVER_BUSY_ARRAY, expireStaleRequests } from "@/lib/ride-lifecycle"; import { boundingBox, haversine } from "@/lib/utils"; import { BROADCAST_RADIUS_M, REQUEST_TTL_SECONDS } from "@/constants/dispatch"; import { splitFare } from "@/lib/pricing"; // GET — the driver's world in one poll: // requests: open ride requests broadcast near this driver, each carrying // how far the pickup is, what the driver would earn, and whether // they have already offered on it. // active : the ride this driver is currently on (accepted -> en_route). // recent : rides completed today, for the earnings summary. // // Requests are found by distance from the driver's own last position, using // the same radius lib/dispatch broadcasts over — the two questions ("who // should be told about this request?" and "what is open near me?") have to // agree, or a driver gets pushed a job their dashboard then hides. export async function GET(req: Request) { const result = await requireDriverProfile(req); if ("error" in result) return result.error; try { const { driverId } = result; // This poll is one of the lazy paths that stands in for a background // worker, so it also buries requests nobody was picked for. Awaited: the // list read below should not include a request that just died. await expireStaleRequests(); // The driver's own position and state. A driver with no fix yet can't be // told what's near them, and one who is offline shouldn't be shown work. const [me] = await sql<{ latitude: number | null; longitude: number | null; service: string; online: boolean; }>` SELECT latitude, longitude, service, online FROM drivers WHERE id = ${driverId} `; const canSeeRequests = me?.online === true && me.latitude !== null && me.longitude !== null; // Coarse box in the index, great-circle pass afterwards — the same // two-step every other proximity query in this codebase uses. const box = canSeeRequests ? boundingBox(me.latitude!, me.longitude!, BROADCAST_RADIUS_M) : null; const openRequests = box ? await sql` SELECT r.ride_id, r.origin_address, r.destination_address, r.origin_latitude, r.origin_longitude, r.destination_latitude, r.destination_longitude, r.ride_time, r.fare_price, r.service, r.created_at, u.name AS rider_name, u.rating AS rider_rating, mine.id AS my_offer_id, (SELECT COUNT(*)::int FROM ride_offers ro WHERE ro.ride_id = r.ride_id AND ro.status = 'offered') AS offer_count FROM rides r LEFT JOIN users u ON u.id = r.user_id LEFT JOIN ride_offers mine ON mine.ride_id = r.ride_id AND mine.driver_id = ${driverId} AND mine.status = 'offered' WHERE r.status = 'requested' AND r.service = ${me.service} 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} ORDER BY r.created_at DESC ` : []; // Distance is computed here rather than in SQL so the filter and the // number the driver reads on the card are the same calculation. const requests = (openRequests as unknown as OpenRequestRow[]) .map((row) => ({ ...row, pickup_distance_m: Math.round( haversine( me.latitude!, me.longitude!, Number(row.origin_latitude), Number(row.origin_longitude), ), ), })) .filter((row) => row.pickup_distance_m <= BROADCAST_RADIUS_M) .sort((a, b) => a.pickup_distance_m - b.pickup_distance_m); // Note: pickup_code is deliberately NOT selected here. The whole point of // the code is that the driver has to get it from the rider at the car. // // The rider's phone number isn't selected either. It used to be shipped to // the driver client and never rendered — personal data in transit for // nothing. Driver↔rider contact goes through the in-app chat and WebRTC // call, which is this app's equivalent of a masked number. const active = await sql` SELECT r.ride_id, r.status, r.service, r.payment_status, r.origin_address, r.destination_address, r.origin_latitude, r.origin_longitude, r.destination_latitude, r.destination_longitude, r.ride_time, r.fare_price, r.created_at, r.arrived_at, u.name AS rider_name, u.rating AS rider_rating FROM rides r LEFT JOIN users u ON u.id = r.user_id WHERE r.driver_id = ${driverId} AND r.status = ANY(${DRIVER_BUSY_ARRAY}::text[]) ORDER BY r.created_at DESC LIMIT 1 `; // driver_payout_cents is what the driver actually keeps; fare_price is // what the rider paid. Everything the driver sees is the payout — COALESCE // covers rides completed before the split existed. const recent = await sql` SELECT ride_id, fare_price, service, payment_status, completed_at, COALESCE(driver_payout_cents, fare_price) AS payout_cents, COALESCE(platform_fee_cents, 0) AS fee_cents FROM rides WHERE driver_id = ${driverId} AND status = 'completed' AND completed_at >= CURRENT_DATE ORDER BY completed_at DESC `; // The driver's running balance with the company, across all time rather // than just today — an unremitted commission doesn't stop mattering at // midnight. Two directions: cash commission they're holding for us, and // card payouts we still owe them. const [balance] = await sql<{ owes_company_cents: number; owed_to_driver_cents: number; }>` SELECT COALESCE(SUM(platform_fee_cents) FILTER (WHERE platform_fee_settled_at IS NULL), 0)::int AS owes_company_cents, COALESCE(SUM(driver_payout_cents) FILTER (WHERE driver_payout_settled_at IS NULL), 0)::int AS owed_to_driver_cents FROM rides WHERE driver_id = ${driverId} AND status = 'completed' AND payment_status IN ('paid','cash_collected') `; // A ride the driver finished recently and hasn't rated. Surfaced as a // prompt on the dashboard so the rating survives the driver immediately // accepting their next trip. const pendingRating = await sql` SELECT r.ride_id, u.name AS rider_name FROM rides r LEFT JOIN users u ON u.id = r.user_id WHERE r.driver_id = ${driverId} AND r.status = 'completed' AND r.completed_at > CURRENT_TIMESTAMP - INTERVAL '1 day' AND NOT EXISTS ( SELECT 1 FROM ride_ratings rr WHERE rr.ride_id = r.ride_id AND rr.rater_type = 'driver' ) ORDER BY r.completed_at DESC LIMIT 1 `; const settled = (r: RecentRow) => r.payment_status === "paid" || r.payment_status === "cash_collected"; const sumPayout = (rows: typeof recent) => rows.reduce((sum, r) => sum + Number(r.payout_cents), 0); const sumFares = (rows: typeof recent) => rows.reduce((sum, r) => sum + Number(r.fare_price), 0); // Earnings count settled money only, and count the driver's share of it. // A cash ride the driver marked "not collected" is still an unpaid trip // and used to land in this headline anyway, so the figure a driver saw and // the figure they'd be paid against disagreed from day one. const earnings = sumPayout(recent.filter(settled)); // The platform's cut of the same rides, so the number above is explainable // rather than mysteriously smaller than the fares they remember charging. const platformFees = recent .filter(settled) .reduce((sum, r) => sum + Number(r.fee_cents), 0); // Cash the driver has taken in hand today — the full fare, because that's // the physical money in their pocket, not their share of it. This is the // figure they'll be reconciled against, and the platform's cut of it is // owed back. const cashCollected = sumFares( recent.filter((r) => r.payment_status === "cash_collected"), ); // Fares that were never collected. Surfaced rather than hidden so an // unpaid trip is visible to the driver on the day it happened. const cashOwed = sumFares(recent.filter((r) => r.payment_status === "cash")); // A driver deciding whether to take a ride cares what they'll be paid, not // what the rider is charged. The split isn't stored until completion, so // it's computed here from the same helper that stamps it later — the two // can't disagree, and the driver is never shown a number they won't get. const withPayout = (row: T) => ({ ...row, payout_cents: splitFare(Number(row.fare_price)).driverPayoutCents, }); return Response.json({ data: { // The server's clock, so the client can draw a request countdown that // matches the TTL dispatch actually enforces. Without it a phone whose // clock is a few seconds out shows a timer that expires early or late. now: new Date().toISOString(), requests: requests.map(withPayout), active: active[0] ? withPayout(active[0] as unknown as ActiveRide) : null, recent, earnings, platform_fees: platformFees, cash_collected: cashCollected, cash_owed: cashOwed, owes_company: Number(balance?.owes_company_cents ?? 0), owed_to_driver: Number(balance?.owed_to_driver_cents ?? 0), pending_rating: (pendingRating[0] as unknown as PendingRatingRow | undefined) ?? null, }, }); } catch (error) { console.error("[DRIVER_RIDES]: ", error); return Response.json({ error: "Internal Server Error" }, { status: 500 }); } } type OpenRequestRow = { ride_id: number; origin_address: string; destination_address: string; origin_latitude: number; origin_longitude: number; destination_latitude: number; destination_longitude: number; ride_time: number; fare_price: number; service: string; created_at: string; rider_name: string | null; rider_rating: number | null; /** The id of this driver's live offer on the request, or null. */ my_offer_id: number | null; /** How many drivers are competing for it, this one included. */ offer_count: number; /** Metres from the driver's last position to the pickup. */ pickup_distance_m?: number; /** The driver's share of the fare, computed per request. */ payout_cents?: number; }; type ActiveRide = { /** The driver's share of the fare, computed per request. */ payout_cents?: number; ride_id: number; status: string; service: string; payment_status: string; origin_address: string; destination_address: string; origin_latitude: number; origin_longitude: number; destination_latitude: number; destination_longitude: number; ride_time: number; fare_price: number; created_at: string; arrived_at: string | null; rider_name: string | null; rider_rating: number | null; }; type RecentRow = { ride_id: number; fare_price: number; payout_cents: number; fee_cents: number; service: string; payment_status: string; completed_at: string; }; type PendingRatingRow = { ride_id: number; rider_name: string | null; };