Skip to content

feat(dispatch): adhoc dispatch strategies, safe accept, ride flow and rider features - #424

Merged
roncodes merged 5 commits into
release/v0.7.0from
feat/passenger-rides
Nov 6, 2026
Merged

roncodes merged 5 commits into
release/v0.7.0from
feat/passenger-rides

Conversation

@roncodes

@roncodes roncodes commented Nov 6, 2026 •

Copy link
Copy Markdown
Member

Adhoc dispatch strategies, safe accept, a ride flow, and what a rider app needs around a ride. Built for Passenger, but the dispatch part applies to every adhoc order. Stacked on #420, so review that first.

Adhoc dispatch strategies (every adhoc order)

  • What a strategy is: an adhoc order is offered to drivers by an AdhocDispatchStrategy. Two are built in:
    • sequential (the default): one driver at a time, nearest first, for the offer timeout (default 20 s). When a driver declines or the time runs out, the next driver gets it. With nobody left, the order is canceled as "No driver found". Only the driver holding the offer may accept.
    • broadcast: every nearby driver at once, which is the old behaviour. The first to accept gets the order.
  • How an order picks one: its own adhoc_strategy, then its order config's adhoc_strategy, then the company's fleetops.adhoc_strategy option, then sequential.
  • The offer timeout comes from the order config's adhoc_offer_timeout, then the company's fleetops.adhoc_offer_timeout option. It's kept between 10 and 120 seconds.
  • Adding strategies: AdhocDispatch::register($name, $class).
  • Offers are recorded in both strategies (order_offers):
    • POST orders/{id}/decline works for any adhoc order. A broadcast decline stops the driver being offered that order again.
    • On accept, everyone else's offer is withdrawn.
    • Canceling the order withdraws its open offers.
  • What a driver's list shows: GET orders?nearby=driver_… (Navigator v2 and v3) only returns adhoc orders the driver can take:
    • an order they hold an open offer for;
    • an order whose strategy lists openly (broadcast) that they haven't declined.
    • Each order carries adhoc_offer: { status, expires_at } for that driver.
    • These lists aren't cached, because they change with every offer.
    • This keeps Navigator correct under the sequential default, with no app release. A strategy says whether it lists openly through listsOpenly().
  • Where the code went: HandleOrderDispatched and fleetops:dispatch-adhoc now hand adhoc orders to the order's strategy. The ping and nearby-driver code moved out of them, into the strategies and DriverMatching::nearby().

Settings and console

  • Strategy names: each strategy has a label() and description(). int/v1/fleet-ops/settings/adhoc-dispatch-strategies lists every registered strategy, including ones an extension adds.
  • Company defaults: int/v1/fleet-ops/settings/adhoc-dispatch-settings (GET/POST) reads and saves the ping radius, strategy and offer time in the company's Fleet-Ops options.
  • Console screens:
    • A new Settings → Dispatch page. It uses the routing settings permission, as Orchestrator does.
    • An "Adhoc dispatch" section in the order config editor. It can also be saved on core configs.
    • A strategy choice and a vehicle type on the order form, shown for adhoc orders.
    • These need feat(models): adhoc dispatch, fare and rating fields fleetops-data#90 released first, then @fleetbase/fleetops-data bumped here. Until then the new fields aren't saved.
  • Config clean-up: order configs drop an unknown strategy and keep the offer time between 10 and 120 seconds.
  • Offer closed event: order.offer_closed is broadcast on the driver's channel when their offer expires or is withdrawn, so their app drops it.

New columns and tables (one migration)

  • orders:
    • adhoc_strategy: overrides the strategy for one order.
    • required_vehicle_type: limits the order to drivers whose vehicle type matches.
    • Both are accepted on POST/PUT orders and returned in the order resource.
    • chat_channel_uuid, fare_finalized_at and paid_at (Order::markPaid(), called by Ledger).
  • driver_ratings: one rating per order. Drivers get rating_average and rating_count.
  • service_quotes: distance and time, the basis a quote was priced on.
  • order_configs:
    • adhoc_strategy, adhoc_offer_timeout.
    • customer_orders_adhoc: orders customers place are adhoc.
    • match_vehicle_type: customer orders need a fare, and only go to drivers of the fare's vehicle type.

Dispatch fixes (every adhoc order)

  • Atomic accept: accepting is one conditional update, so of two drivers only the first gets the order. The driver must work for the order's company and be online, a signed-in driver can only accept for themselves, and a canceled order can't be accepted.
  • Which drivers are offered an order:
    • only drivers of the order's company;
    • not drivers already on another job;
    • only drivers of the required_vehicle_type, when the order has one.
  • The re-offer window in fleetops:dispatch-adhoc works again. Both ends were taken from one shared Carbon instance, which moved them back 72 hours. It also no longer re-dispatches, which pinged every driver a second time.
  • Strict transitions are now enforced on the public update-activity route too.
  • Driver phone numbers: customers can no longer subscribe to a driver's socket channel, whose location events carry the driver's phone number.

Rides

  • fleetops:create-ride-config {company}:
    • Flow: dispatched → started (driver on the way) → arrived → in_progress → completed, or no_show.
    • Transitions are strict, and the config uses customer_orders_adhoc, match_vehicle_type and sequential.
    • Options set the emergency number and support contacts the rider app shows.
  • Fare previews with availability=1 add seats and pickup ETA per vehicle type.

Rider routes (Customer-Token)

  • Booking: on a config with match_vehicle_type, the order needs a fare and gets its required_vehicle_type from the fare. A rider with an unpaid card trip gets 402.
  • Chat: orders/{id}/messages (GET and POST) chats with the driver, always as the rider. It's a core chat channel that Navigator v3 shows as the order's customer thread.
  • Rating: orders/{id}/rating rates a finished ride once. The driver's average comes back with the ride's driver.
  • Sharing: orders/{id}/share returns a signed tracking-page link. The page redeems it at public/track/{number}/share for the rider's live view.
  • Settings: ride-settings returns the operator's emergency number and support contacts.
  • Account deletion: DELETE me deletes the account. It's refused during a ride.
  • Push: the rider's own devices get a push at each ride step, and when the car is within 400 m of the pickup.
  • Final fare: a completed ride is priced again from its car's positions and the trip time, through the booked rate. algo rates take per-km, per-minute and minimum fares. The fare is bought as the order's price, so Ledger bills the final amount.

Behaviour change

Adhoc orders now default to sequential. To keep pinging every nearby driver at once, set the company's fleetops.adhoc_strategy option or an order config's adhoc_strategy to broadcast.

Rider data is no longer in order meta

  • Payment: the rider route takes payment_method and keeps it on the payload it builds.
  • Final fare: it's the final quote with its distance and time, marked by fare_finalized_at. Order::fare() reads it.
  • Ratings: in driver_ratings.
  • Chat: orders.chat_channel_uuid points to the ride's channel.
  • Payment status: orders.paid_at.
  • Dispatch outcome: worked out from the offers (Order::dispatchOutcome()), not stored.
  • Seats and pickup ETA: top-level fields on the quote, sent only when availability is asked for.
  • Rider order resource: fare, rating, payment_method, paid_at and dispatch_outcome are top-level fields (RiderOrder).

Testing

  • Pest: new and updated tests. The full suite passes locally, except DriverControllerAuthFlowsTest, which also fails without these changes (the local vendored core-api has no AppReview).
  • Against a local Fleetbase, through the calls the apps make:
    • Sequential: nearest driver first, decline, timeout, no driver found, and a busy driver left out.
    • Broadcast: all three drivers offered, a decline recorded, the first accept wins and the others are withdrawn.
    • Default: an order with no strategy set goes one at a time, by vehicle type.
    • Validation: an unknown strategy is refused (422).
  • Driver lists: with three drivers, each only saw orders they could take, under both strategies.
  • Rider features: the M5/M6 script passes 39/39 on the new fields.
  • Walked in the Passenger web app, from booking to rating.
  • Console: a unit test for the Dispatch settings page. The console wasn't run locally, so the new screens haven't been seen in a browser.
  • Not run:
    • Navigator's own screens.
    • Push on a device.
    • A card payment.

- Accepting an adhoc order is one conditional update, so of two drivers only the first gets it. The driver must work for the order's company and be online, a signed-in driver can only accept for themselves, and a canceled order can't be accepted.
- Drivers pinged or offered an order are of the order's company, not on another job, and of the vehicle class booked (`meta.vehicle_class`).
- `fleetops:dispatch-adhoc` re-pings again: both ends of its window were moved back 72 hours by one shared Carbon. It no longer re-dispatches, which pinged every driver twice.
- An order config with `meta.offers.enabled` offers an order to one driver at a time, nearest first, for `meta.offers.timeout` seconds. Drivers decline with `POST orders/{id}/decline`. With nobody left, the order is canceled as "No driver found".
- `fleetops:create-ride-config` adds a strict ride flow: dispatched, started (driver on the way), arrived, in_progress, completed, or no_show.
- Strict transitions are enforced on the public update-activity route too.
- Fare previews with `availability=1` add each class's seats and how soon its nearest free car could arrive.
- Customers can no longer subscribe to a driver's socket channel, whose location events carry the driver's phone number.
…count deletion

- Rides: a rider's order on a config with timed offers is adhoc, needs a fare, and takes its vehicle class from the fare. Server-set meta keys can't come from the rider.
- Chat: `customers/orders/{id}/messages` reads and sends messages with the ride's driver, as the rider. The channel is a core chat channel that Navigator shows as the order's customer thread.
- Ratings: `customers/orders/{id}/rating` rates the driver of a finished ride once. The driver's average is kept on the driver and returned with the ride's driver.
- Sharing: `customers/orders/{id}/share` returns a signed tracking link. The tracking page redeems it (`public/track/{number}/share`) for the rider's live view, without a code.
- Push: riders get a push to their own devices at each step of a ride and when the car is about to arrive. No Storefront or company notification setting is needed.
- Final fare: a completed ride is priced again from its car's positions and the trip time, through the rate the rider booked. The breakdown is kept on the order and bought as the order's price.
- A rider can't book while a card trip is unpaid (`meta.paid_at`, set by Ledger).
- `customers/ride-settings` returns the operator's emergency number and support contacts, set with `fleetops:create-ride-config`.
- `DELETE customers/me` deletes the rider's account, unless they have a ride under way.
Base automatically changed from feat/passenger-rider-access to release/v0.7.0 November 6, 2026 02:41
…ers by default

Timed offers were switched on by an order config meta key and only covered rider orders. Offering is now a dispatch strategy for every adhoc order.

- `AdhocDispatchStrategy` contract with `broadcast` (every nearby driver at once) and `sequential` (one at a time, nearest first, with a timeout and "No driver found"). `AdhocDispatch::register()` adds more.
- Sequential is the default. An order uses its own `adhoc_strategy`, else its config's, else the company's `fleetops.adhoc_strategy` option.
- Both strategies record offers, so decline works for any adhoc order (a broadcast decline stops that driver being offered it again), and on accept everyone else's offer is withdrawn.
- `HandleOrderDispatched` and `fleetops:dispatch-adhoc` hand adhoc orders to the strategy. Nearby drivers come from `DriverMatching::nearby()`.
- Settings are columns, not meta. Orders get `adhoc_strategy` and `required_vehicle_type`, accepted on the order API and returned in its resource. Order configs get `adhoc_strategy`, `adhoc_offer_timeout`, `customer_orders_adhoc` and `match_vehicle_type`.
- The rider route makes orders adhoc and sets their vehicle type from those config columns, instead of from the offers switch.
@roncodes roncodes changed the title feat(rides): safe dispatch, timed offers, ride flow and rider features for Passenger feat(dispatch): adhoc dispatch strategies, safe accept, ride flow and rider features Nov 6, 2026
@roncodes roncodes mentioned this pull request Nov 6, 2026
…an take

Navigator lists offers with `GET orders?nearby=driver_…&adhoc=1&unassigned=1`. With one-at-a-time offers as the default, every nearby driver saw every adhoc order and all but one got a 409 on accept.

- For a driver asking with `nearby=driver_…`, an unassigned adhoc order shows only if they hold an open offer for it, or if its strategy lists orders openly and they haven't declined it. The strategy is worked out in SQL (the order's, then its config's, then the company default). Other orders are untouched.
- Each order carries `adhoc_offer: { status, expires_at }` for that driver, for a countdown.
- These lists skip the response cache, because they change with every offer.
- Strategies declare `listsOpenly()`: broadcast does, sequential doesn't.
…ts and rider data out of meta

Strategies and settings:
- Strategies have a `label()` and `description()`. `settings/adhoc-dispatch-strategies` lists them.
- `settings/adhoc-dispatch-settings` reads and saves the company defaults: ping radius, strategy and offer time.
- Order configs drop an unknown strategy and keep the offer time between 10 and 120 seconds.
- `order.offer_closed` tells a driver's app when their offer expired or was withdrawn.

Console:
- A Settings → Dispatch page.
- An "Adhoc dispatch" section in the order config editor.
- A strategy and vehicle type on the order form, for adhoc orders.

Rider data moves out of order meta, and #424's migrations become one:
- The payment method is kept on the payload.
- The final fare is the final quote, which records its distance and time, marked by `fare_finalized_at` and read with `Order::fare()`.
- Ratings go in `driver_ratings`, with each driver's average and count on `drivers`.
- The chat channel is `orders.chat_channel_uuid`.
- Payment is `orders.paid_at`, set with `Order::markPaid()`.
- The dispatch outcome is worked out from the offers.
- Fare previews return seats and pickup ETA as quote fields.
- Riders get these as top-level fields (`RiderOrder`).
@roncodes
roncodes merged commit 97dadd7 into release/v0.7.0 Nov 6, 2026
4 of 6 checks passed
@roncodes
roncodes deleted the feat/passenger-rides branch November 6, 2026 14:23
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant