import { requireAuth } from "@/lib/jwt"; import { sql } from "@/lib/db"; import { broadcastRequest } from "@/lib/dispatch"; import { requireDriverProfile } from "@/lib/driver"; import { isCancellationReason, DRIVER_CANCELLABLE_ARRAY, RIDER_CANCELLABLE_ARRAY, } from "@/lib/ride-lifecycle"; import { REQUEST_TTL_SECONDS } from "@/constants/dispatch"; import { COMMISSION_RATE } from "@/lib/pricing"; // GET — single ride by id, the rider's status-poll endpoint. // // While the ride is still open this also returns the drivers who have offered // on it, which is what the rider chooses from. The poll re-drives the // broadcast too (a no-op once announced), so a request whose announcement lost // its race with the push service still reaches drivers on the next tick — // there is no background worker to do it. export async function GET(request: Request, { id }: { id: string }) { const auth = requireAuth(request); if ("error" in auth) return auth.error; const rideId = Number(id); if (!Number.isInteger(rideId)) { return Response.json({ error: "Invalid ride id." }, { status: 400 }); } try { const ride = await sql` SELECT status FROM rides WHERE ride_id = ${rideId} AND user_id = ${auth.userId} `; if (!ride[0]) { return Response.json({ error: "Ride not found." }, { status: 404 }); } // Lazy dispatch: announce the request if that hasn't happened yet, and // give up on it if it has run past its window. Awaited, because the row // this request is about to read is the one the sweep may rewrite — a // rider whose request just expired should be told, not shown a list of // drivers they can no longer pick. if (ride[0].status === "requested") { await broadcastRequest(rideId); } const rows = 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.payment_status, r.status, r.service, r.created_at, r.accepted_at, r.arrived_at, r.started_at, r.completed_at, r.cancelled_at, r.cancelled_by, r.cancellation_reason, r.cash_collected_at, -- The rider's copy of the pickup code. Only ever sent to the ride's -- own rider (this route is rider-scoped), and only while it still -- matters: once the trip has started the code is spent. CASE WHEN r.status IN ('accepted', 'arrived') THEN r.pickup_code END AS pickup_code, -- Has this rider already rated the ride? Drives the rating card. (SELECT rr.rating FROM ride_ratings rr WHERE rr.ride_id = r.ride_id AND rr.rater_type = 'rider') AS my_rating, json_build_object( 'id', d.id, 'first_name', d.first_name, 'last_name', d.last_name, 'car_seats', d.car_seats, 'profile_image_url', d.profile_image_url, 'car_image_url', d.car_image_url, 'rating', d.rating, 'rating_count', d.rating_count, 'service', d.service, 'car_model', d.car_model, 'latitude', d.latitude, 'longitude', d.longitude ) AS driver FROM rides r LEFT JOIN drivers d ON d.id = r.driver_id WHERE r.ride_id = ${rideId} `; // The drivers who have volunteered, newest first. Only while the request // is open: once it is assigned, the losing offers are nobody's business // and the winning one is just "your driver". Coordinates are deliberately // not included — a rider comparing offers needs how far away each driver // is, not where they are, and only the chosen driver's position is theirs // to watch. const offers = rows[0]?.status === "requested" ? await sql` SELECT ro.id AS offer_id, ro.offered_at, ro.pickup_distance_m, d.id AS driver_id, d.first_name, d.last_name, d.profile_image_url, d.car_image_url, d.car_model, d.car_seats, d.rating, d.rating_count, d.service FROM ride_offers ro JOIN drivers d ON d.id = ro.driver_id WHERE ro.ride_id = ${rideId} AND ro.status = 'offered' ORDER BY ro.pickup_distance_m NULLS LAST, ro.offered_at ` : []; return Response.json({ data: { ...rows[0], offers, // The server's clock and the request window, so the "still looking" // countdown the rider watches is the one the server actually enforces // rather than whatever their phone thinks the time is. now: new Date().toISOString(), request_ttl_seconds: REQUEST_TTL_SECONDS, }, }); } catch (error) { console.error("[GET_RIDE]: ", error); return Response.json({ error: "Internal Server Error" }, { status: 500 }); } } // PATCH — ride lifecycle transitions. // Rider: { status: 'cancelled', reason? } — before the trip starts, on // their own ride. // Driver: { status: 'arrived' } accepted -> arrived // { status: 'en_route', pickup_code } arrived -> en_route // { status: 'completed', cash_collected? } en_route -> completed // { status: 'cancelled', reason? } before the trip starts // Every transition is a single guarded UPDATE: the prior state is part of the // WHERE clause, so a double-tap or a stale client can't skip a step or // resurrect a finished ride, and two racing writers can't both win. export async function PATCH(request: Request, { id }: { id: string }) { const rideId = Number(id); if (!Number.isInteger(rideId)) { return Response.json({ error: "Invalid ride id." }, { status: 400 }); } let body: { status?: string; reason?: string; pickup_code?: string; cash_collected?: boolean; }; try { body = await request.json(); } catch { return Response.json({ error: "Invalid JSON body." }, { status: 400 }); } const next = body.status; // A reason is optional, but if one is sent it has to be a known code — the // admin portal counts these, and free text would make them uncountable. const reason = body.reason; if (reason !== undefined && !isCancellationReason(reason)) { return Response.json( { error: "Unknown cancellation reason." }, { status: 400 }, ); } try { // Cancel — either the rider (any time before the trip starts) or the // assigned driver (same window). Rider path is tried first: a user who is // also a driver should cancel their own ride as a rider, not be misrouted // to the driver branch. if (next === "cancelled") { const auth = requireAuth(request); if ("error" in auth) return auth.error; const riderCancel = await sql<{ status: string }>` UPDATE rides SET status = 'cancelled', cancelled_at = CURRENT_TIMESTAMP, cancelled_by = 'rider', cancellation_reason = ${reason ?? null} WHERE ride_id = ${rideId} AND user_id = ${auth.userId} AND status = ANY(${RIDER_CANCELLABLE_ARRAY}::text[]) RETURNING status `; if (riderCancel[0]) { // Free the driver's offer so dispatch doesn't keep a phantom offer in // flight for a ride that no longer exists. await sql` UPDATE ride_offers SET status = 'cancelled', responded_at = CURRENT_TIMESTAMP WHERE ride_id = ${rideId} AND status = 'offered' `; return Response.json({ data: { status: riderCancel[0].status } }); } const driver = await requireDriverProfile(request); if (!("error" in driver)) { const driverCancel = await sql<{ status: string }>` UPDATE rides SET status = 'cancelled', cancelled_at = CURRENT_TIMESTAMP, cancelled_by = 'driver', cancellation_reason = ${reason ?? null} WHERE ride_id = ${rideId} AND driver_id = ${driver.driverId} AND status = ANY(${DRIVER_CANCELLABLE_ARRAY}::text[]) RETURNING status `; if (driverCancel[0]) { return Response.json({ data: { status: driverCancel[0].status } }); } } return Response.json( { error: "Ride cannot be cancelled." }, { status: 409 }, ); } // Driver transitions — must be the driver assigned to the ride. if (next === "arrived" || next === "en_route" || next === "completed") { const result = await requireDriverProfile(request); if ("error" in result) return result.error; const { driverId } = result; // Driver is at the pickup point. Purely informational for the rider, // but it's the signal that turns "on the way" into "your car is here". if (next === "arrived") { const rows = await sql<{ status: string }>` UPDATE rides SET status = 'arrived', arrived_at = CURRENT_TIMESTAMP WHERE ride_id = ${rideId} AND driver_id = ${driverId} AND status = 'accepted' RETURNING status `; if (!rows[0]) { return Response.json( { error: "Ride cannot transition to that state." }, { status: 409 }, ); } return Response.json({ data: { status: rows[0].status } }); } // Start the trip. The pickup code is the handshake that proves the // person in the car is the rider who ordered it — checked inside the // UPDATE so a wrong code can't start the trip even under a race. if (next === "en_route") { const code = String(body.pickup_code ?? "").trim(); if (!code) { return Response.json( { error: "Pickup code required.", code: "PICKUP_CODE_REQUIRED" }, { status: 400 }, ); } const rows = await sql<{ status: string }>` UPDATE rides SET status = 'en_route', started_at = CURRENT_TIMESTAMP WHERE ride_id = ${rideId} AND driver_id = ${driverId} AND status IN ('accepted', 'arrived') AND pickup_code = ${code} RETURNING status `; if (!rows[0]) { // Distinguish "wrong code" from "wrong state" — the driver needs to // know whether to re-ask the rider or reload the screen. const current = await sql<{ status: string; pickup_code: string | null; }>` SELECT status, pickup_code FROM rides WHERE ride_id = ${rideId} AND driver_id = ${driverId} `; if ( current[0] && ["accepted", "arrived"].includes(current[0].status) && current[0].pickup_code !== code ) { return Response.json( { error: "That code doesn't match.", code: "PICKUP_CODE_INVALID", }, { status: 403 }, ); } return Response.json( { error: "Ride cannot transition to that state." }, { status: 409 }, ); } return Response.json({ data: { status: rows[0].status } }); } // Complete. For a cash ride the driver also confirms they collected the // fare, which is what moves the money from "owed" to "settled" — a cash // ride left at payment_status='cash' is an unreconciled debt, and the // admin portal reports on exactly that gap. const settleCash = body.cash_collected === true; // Stamp the fare split at completion. Computed from the row's own // fare_price inside the UPDATE so it can't disagree with what was // charged, and recorded with the rate used so a later rate change never // rewrites what this driver was owed today. // // The ::numeric casts are load-bearing. Parameters are sent untyped, so // Postgres infers each one from context — and next to an integer column // it infers `fare_price * $n` as integer multiplication, then refuses to // parse "0.2" as an integer. Every completion failed on that, which is // what left drivers unable to end a trip at all. const rows = await sql<{ status: string; payment_status: string }>` UPDATE rides SET status = 'completed', completed_at = CURRENT_TIMESTAMP, commission_rate = ${COMMISSION_RATE}::numeric, platform_fee_cents = ROUND(fare_price * ${COMMISSION_RATE}::numeric), driver_payout_cents = fare_price - ROUND(fare_price * ${COMMISSION_RATE}::numeric), payment_status = CASE WHEN payment_status = 'cash' AND ${settleCash}::boolean THEN 'cash_collected' ELSE payment_status END, cash_collected_at = CASE WHEN payment_status = 'cash' AND ${settleCash}::boolean THEN CURRENT_TIMESTAMP ELSE cash_collected_at END, -- Whoever physically holds their own share is settled immediately; -- only the other side is left owed. A card ride means the company -- has its fee and owes the driver; a collected cash fare means the -- driver has their payout and owes the company. See -- lib/settlement.ts, which is where this rule is defined. platform_fee_settled_at = CASE WHEN payment_status = 'paid' THEN CURRENT_TIMESTAMP ELSE platform_fee_settled_at END, driver_payout_settled_at = CASE WHEN payment_status = 'cash' AND ${settleCash}::boolean THEN CURRENT_TIMESTAMP ELSE driver_payout_settled_at END WHERE ride_id = ${rideId} AND driver_id = ${driverId} AND status = 'en_route' RETURNING status, payment_status `; if (!rows[0]) { return Response.json( { error: "Ride cannot transition to that state." }, { status: 409 }, ); } return Response.json({ data: { status: rows[0].status, payment_status: rows[0].payment_status, }, }); } return Response.json( { error: "Unknown status transition." }, { status: 400 }, ); } catch (error) { console.error("[PATCH_RIDE]: ", error); return Response.json({ error: "Internal Server Error" }, { status: 500 }); } }