// The ride state machine, shared by every endpoint that touches it. // // requested ──rider picks an offer──▶ accepted ──arrive──▶ arrived // │ │ │ pickup code // │ │ ▼ // │ │ en_route ──▶ completed // │ │ │ │ // │ └─────────────┴────────────┴──── cancel ──▶ cancelled // └── nobody offered in time ──▶ expired // // Dispatch is a broadcast, not a hand-off: a new request is put in front of // every eligible driver near the pickup at once, each of them can volunteer // for it (a row in ride_offers), and the rider chooses between whoever did. // So a ride has exactly one moment of assignment — the rider's pick — rather // than a driver claiming it and the rider being told after the fact. // // Keeping the status sets here (rather than inlining string arrays in each // route) is what stops a new state like 'arrived' from being handled in one // query and silently ignored in the next. import { query, sql, type SqlValue } from "@/lib/db"; import { REQUEST_TTL_SECONDS } from "@/constants/dispatch"; export const RIDE_STATUSES = [ "requested", "accepted", "arrived", "en_route", "completed", "cancelled", "expired", ] as const; export type RideStatus = (typeof RIDE_STATUSES)[number]; /** Ride is in flight for the rider: they should be on the tracking screen. */ export const ACTIVE_RIDE_STATUSES = [ "requested", "accepted", "arrived", "en_route", ] as const; /** * Driver is committed to a ride and must not be shown new requests. Offering * on a request costs a driver nothing and can be withdrawn, so it is the * assignment — not the offer — that takes them off the board. */ export const DRIVER_BUSY_STATUSES = [ "accepted", "arrived", "en_route", ] as const; /** Nothing more will happen to the ride. */ export const TERMINAL_RIDE_STATUSES = [ "completed", "cancelled", "expired", ] as const; /** Chat and calls are open between the two parties in these states. */ export const CONNECTED_RIDE_STATUSES = [ "accepted", "arrived", "en_route", ] as const; export const isTerminal = (status: string): boolean => (TERMINAL_RIDE_STATUSES as readonly string[]).includes(status); // Postgres array literals for the sets above. Passed as a bound parameter and // cast in the query — `status = ANY(${ACTIVE_STATUS_ARRAY}::text[])` — so a // status list is defined once here instead of being retyped inline in every // route, where adding a state means remembering every literal that needs it. const pgArray = (v: readonly string[]): string => `{${v.join(",")}}`; /** The rider may still call it off in these states — nobody is moving yet. */ export const RIDER_CANCELLABLE_STATUSES = [ "requested", "accepted", "arrived", ] as const; /** The assigned driver's cancellation window: from being picked to the pickup. */ export const DRIVER_CANCELLABLE_STATUSES = [ "accepted", "arrived", ] as const; export const ACTIVE_STATUS_ARRAY = pgArray(ACTIVE_RIDE_STATUSES); export const DRIVER_BUSY_ARRAY = pgArray(DRIVER_BUSY_STATUSES); export const CONNECTED_STATUS_ARRAY = pgArray(CONNECTED_RIDE_STATUSES); export const TERMINAL_STATUS_ARRAY = pgArray(TERMINAL_RIDE_STATUSES); export const RIDER_CANCELLABLE_ARRAY = pgArray(RIDER_CANCELLABLE_STATUSES); export const DRIVER_CANCELLABLE_ARRAY = pgArray(DRIVER_CANCELLABLE_STATUSES); // Cancellation reasons the clients may send. Free text is rejected: these // codes are what makes cancellations countable in the admin portal, and an // open text field would turn that into an unqueryable mess. export const CANCELLATION_REASONS = [ "changed_mind", "wait_too_long", "wrong_address", "driver_no_show", "rider_no_show", "unreachable", "vehicle_issue", "other", ] as const; export type CancellationReason = (typeof CANCELLATION_REASONS)[number]; export const isCancellationReason = (v: unknown): v is CancellationReason => typeof v === "string" && (CANCELLATION_REASONS as readonly string[]).includes(v); // 4-digit pickup code. Not a secret worth hardening — it only has to be hard // to guess on the first try in a parking lot, and the driver can only try it // against a ride already assigned to them. export const generatePickupCode = (): string => String(Math.floor(1000 + Math.random() * 9000)); /** * Give up on `requested` rides nobody was picked for within * REQUEST_TTL_SECONDS, and close whatever offers were sitting on them. * * Called from the lazy paths that stand in for a background worker — the * rider's status poll, the driver's dashboard poll — so there is no daemon to * keep alive. * * Unlike the old hand-off dispatch, a request with offers on it is expired * like any other. Offers are volunteers, not commitments: a rider who never * picked one has left three drivers holding a job that is never going to * start, and the honest end of that is to close it and free them. */ export const expireStaleRequests = async (rideId?: number): Promise => { const values: SqlValue[] = [REQUEST_TTL_SECONDS]; let scope = ""; if (rideId !== undefined) { values.push(rideId); scope = ` AND ride_id = $${values.length}`; } try { const rows = await query<{ ride_id: number }>( `UPDATE rides SET status = 'expired', cancelled_at = CURRENT_TIMESTAMP, cancelled_by = 'system', cancellation_reason = 'no_drivers_available' WHERE status = 'requested' AND created_at < CURRENT_TIMESTAMP - make_interval(secs => $1)${scope} RETURNING ride_id`, values, ); if (rows.length > 0) { // Same sweep, so a driver's "waiting for the rider" card can never // outlive the request it belongs to. // Passed as a Postgres array literal and cast in the query, the same way // the status sets above travel — SqlValue is deliberately scalar-only. await query( `UPDATE ride_offers SET status = 'expired', responded_at = CURRENT_TIMESTAMP WHERE status = 'offered' AND ride_id = ANY($1::int[])`, [`{${rows.map((r) => r.ride_id).join(",")}}`], ); } return rows.length; } catch (error) { console.error("[EXPIRE_STALE_REQUESTS]: ", error); return 0; } }; /** * Recompute a driver's headline rating from the ratings riders left them. * Denormalised onto drivers.rating because every driver card and every match * candidate reads it. Drivers with no ratings yet keep the 5.0 they onboard * with, so a new driver isn't shown as unrated-and-therefore-bad. */ export const refreshDriverRating = async (driverId: number): Promise => { try { await sql` UPDATE drivers d SET rating = COALESCE(agg.avg_rating, 5.0), rating_count = COALESCE(agg.n, 0) FROM ( SELECT ROUND(AVG(rr.rating)::numeric, 1) AS avg_rating, COUNT(*)::int AS n FROM ride_ratings rr JOIN rides r ON r.ride_id = rr.ride_id WHERE rr.rater_type = 'rider' AND r.driver_id = ${driverId} ) AS agg WHERE d.id = ${driverId} `; } catch (error) { console.error("[REFRESH_DRIVER_RATING]: ", error); } }; /** The mirror of the above: what drivers thought of a rider. */ export const refreshRiderRating = async (userId: string): Promise => { try { await sql` UPDATE users u SET rating = agg.avg_rating, rating_count = COALESCE(agg.n, 0) FROM ( SELECT ROUND(AVG(rr.rating)::numeric, 1) AS avg_rating, COUNT(*)::int AS n FROM ride_ratings rr JOIN rides r ON r.ride_id = rr.ride_id WHERE rr.rater_type = 'driver' AND r.user_id = ${userId} ) AS agg WHERE u.id = ${userId} `; } catch (error) { console.error("[REFRESH_RIDER_RATING]: ", error); } };