import { requireApprovedDriver } from "@/lib/driver"; import { sql, transaction } from "@/lib/db"; import { sendPushToUser } from "@/lib/push"; import { DRIVER_BUSY_ARRAY } from "@/lib/ride-lifecycle"; import { haversine } from "@/lib/utils"; // POST — a driver's answer to a broadcast request. // // { action: 'offer' } — volunteer for it. The rider sees this driver // appear in their list of offers and may pick them. // { action: 'withdraw' } — take the offer back, before the rider picks. // // Offering is not an assignment: several drivers can be offered on the same // request at once and none of them is committed until the rider chooses. That // is why offering doesn't take a driver off the board, and why withdrawing is // free — the cost of a driver changing their mind lands here rather than on a // rider whose ride was already promised away. export async function POST(req: Request, { id }: { id: string }) { const rideId = Number(id); if (!Number.isInteger(rideId)) { return Response.json({ error: "Invalid ride id." }, { status: 400 }); } // Approval is re-checked here, not just at broadcast time: a driver // suspended between seeing a request and tapping Offer must not be able to // put themselves in front of a rider. (Rides already under way stay under // requireDriverProfile — a suspension must never strand a rider who is // sitting in the car.) const result = await requireApprovedDriver(req); if ("error" in result) return result.error; const { driverId } = result; let body: { action?: string }; try { body = await req.json(); } catch { return Response.json({ error: "Invalid JSON body." }, { status: 400 }); } const action = body.action; if (action !== "offer" && action !== "withdraw") { return Response.json( { error: "action must be 'offer' or 'withdraw'." }, { status: 400 }, ); } try { if (action === "withdraw") { const withdrawn = await sql<{ id: number }>` UPDATE ride_offers SET status = 'withdrawn', responded_at = CURRENT_TIMESTAMP WHERE ride_id = ${rideId} AND driver_id = ${driverId} AND status = 'offered' RETURNING id `; if (!withdrawn[0]) { return Response.json( { error: "There is no live offer to withdraw." }, { status: 409 }, ); } return Response.json({ data: { status: "withdrawn" } }); } const offered = await transaction<{ userId: string; alreadyOffered: boolean; } | null>(async (tx) => { // Lock the request so a rider picking someone else at this exact moment // and this driver offering can't both believe they won. const rides = await tx<{ status: string; user_id: string; service: string; lat: number; lng: number; }>` SELECT status, user_id, service, origin_latitude AS lat, origin_longitude AS lng FROM rides WHERE ride_id = ${rideId} FOR UPDATE `; const ride = rides[0]; if (!ride || ride.status !== "requested") return null; // The driver's own state has to be re-read here rather than trusted from // the dashboard that drew the button: service, liveness and — above all // — whether they picked up another ride in the meantime. const drivers = await tx<{ service: string; online: boolean; latitude: number | null; longitude: number | null; }>` SELECT service, online, latitude, longitude FROM drivers WHERE id = ${driverId} `; const driver = drivers[0]; if (!driver || !driver.online || driver.service !== ride.service) { return null; } const busy = await tx<{ n: number }>` SELECT COUNT(*)::int AS n FROM rides WHERE driver_id = ${driverId} AND status = ANY(${DRIVER_BUSY_ARRAY}::text[]) `; if ((busy[0]?.n ?? 0) > 0) return null; const distance = driver.latitude === null || driver.longitude === null ? null : Math.round( haversine(ride.lat, ride.lng, driver.latitude, driver.longitude), ); // ON CONFLICT rather than an existence check: the unique index is the // real guard, and a driver who taps Offer twice (or re-offers after // withdrawing) should end up with one live offer either way. const rows = await tx<{ inserted: boolean }>` INSERT INTO ride_offers (ride_id, driver_id, status, pickup_distance_m) VALUES (${rideId}, ${driverId}, 'offered', ${distance}) ON CONFLICT (ride_id, driver_id) DO UPDATE SET status = 'offered', offered_at = CURRENT_TIMESTAMP, responded_at = NULL, pickup_distance_m = EXCLUDED.pickup_distance_m WHERE ride_offers.status IN ('withdrawn', 'offered') RETURNING (xmax = 0) AS inserted `; // No row means the conflict target existed in a state we refuse to // revive — the rider already picked someone, or this offer was closed // with the request. if (!rows[0]) return null; return { userId: ride.user_id, alreadyOffered: !rows[0].inserted }; }); if (!offered) { return Response.json( { error: "This request is no longer open." }, { status: 409 }, ); } // Nudge the rider — they are sitting on a screen watching for exactly // this. Only for the first offer on the request: the rest arrive on the // list they are already looking at, and a buzz per driver would turn a // busy street into a nuisance. if (!offered.alreadyOffered) { const [count] = await sql<{ n: number }>` SELECT COUNT(*)::int AS n FROM ride_offers WHERE ride_id = ${rideId} AND status = 'offered' `; if ((count?.n ?? 0) === 1) { void sendPushToUser(offered.userId, { title: "A driver is available", body: "Open your ride to see who can pick you up.", data: { type: "ride_offer_received", rideId }, }); } } return Response.json({ data: { status: "offered" } }); } catch (error) { console.error("[RIDE_OFFER]: ", error); return Response.json({ error: "Internal Server Error" }, { status: 500 }); } }