From 9792af6e2f2eb364bec15c7b0fef63e5b18c4861 Mon Sep 17 00:00:00 2001 From: "Ronald A. Richardson" Date: Mon, 12 Oct 2026 23:17:12 +0800 Subject: [PATCH 01/11] feat(api-cache): declare the tables cached lists embed Cached API lists hold every relation their query eager loads, and the whereHas filters that decide membership, but their keys were only versioned by the model's own table. Declare `$apiCacheDependencies` on the cached models (Order, Driver, Vehicle, Device, DeviceEvent, Vendor, ServiceArea) so a write to an embedded table invalidates the list. The property is read by fleetbase/core-api#301; until that is released it is unused. Waypoint, FleetDriver, FleetVehicle and AssetConnection had no cache trait, so their writes bumped nothing. They now use HasApiModelCache, which only registers the invalidation hooks: they have no HasApiModelBehavior, so none of them gains a cached list. --- server/src/Models/AssetConnection.php | 3 +++ server/src/Models/Device.php | 12 ++++++++++ server/src/Models/DeviceEvent.php | 7 ++++++ server/src/Models/Driver.php | 11 +++++++++ server/src/Models/FleetDriver.php | 3 +++ server/src/Models/FleetVehicle.php | 3 +++ server/src/Models/Order.php | 32 +++++++++++++++++++++++++++ server/src/Models/ServiceArea.php | 7 ++++++ server/src/Models/Vehicle.php | 13 +++++++++++ server/src/Models/Vendor.php | 7 ++++++ server/src/Models/Waypoint.php | 3 +++ 11 files changed, 101 insertions(+) diff --git a/server/src/Models/AssetConnection.php b/server/src/Models/AssetConnection.php index 204e53216..08c01dd6e 100644 --- a/server/src/Models/AssetConnection.php +++ b/server/src/Models/AssetConnection.php @@ -5,6 +5,7 @@ use Fleetbase\Casts\Json; use Fleetbase\Casts\PolymorphicType; use Fleetbase\Models\Model; +use Fleetbase\Traits\HasApiModelCache; use Fleetbase\Traits\HasPublicId; use Fleetbase\Traits\HasUuid; use Illuminate\Database\Eloquent\Relations\BelongsTo; @@ -14,6 +15,8 @@ class AssetConnection extends Model { use HasUuid; + // Writes bump the asset_connections API cache version, which cached vehicle lists filter on. + use HasApiModelCache; use HasPublicId; use SoftDeletes; diff --git a/server/src/Models/Device.php b/server/src/Models/Device.php index 06feb64f0..118650efc 100644 --- a/server/src/Models/Device.php +++ b/server/src/Models/Device.php @@ -56,6 +56,18 @@ class Device extends Model */ protected $table = 'devices'; + /** + * Relation paths and tables the cached device lists embed (fleetbase/core-api#301). + * + * Lists load telematic, warranty and attachable and count sensors. + * attachable is a morphTo to a vehicle or trailer, so its tables are named. + * photo (public list) is left out: files is written by every upload, and a + * new photo is set by writing the device's own photo_uuid. + * + * @var string[] + */ + protected $apiCacheDependencies = ['telematic', 'warranty', 'sensors', 'vehicles', 'assets']; + /** * The type of public Id to generate. * diff --git a/server/src/Models/DeviceEvent.php b/server/src/Models/DeviceEvent.php index 6c5b2a5b9..5b5d4a16f 100644 --- a/server/src/Models/DeviceEvent.php +++ b/server/src/Models/DeviceEvent.php @@ -46,6 +46,13 @@ class DeviceEvent extends Model */ protected $table = 'device_events'; + /** + * Relation paths the cached device event lists embed (fleetbase/core-api#301). + * + * @var string[] + */ + protected $apiCacheDependencies = ['device', 'device.telematic']; + /** * The type of public Id to generate. * diff --git a/server/src/Models/Driver.php b/server/src/Models/Driver.php index c796257fa..63164b526 100644 --- a/server/src/Models/Driver.php +++ b/server/src/Models/Driver.php @@ -171,6 +171,17 @@ class Driver extends Model */ protected $with = []; + /** + * Relation paths the cached driver lists depend on (fleetbase/core-api#301). + * + * Lists eager load nothing, but which drivers they hold depends on the + * user (existence, name, email and phone search), the vehicle (vehicle + * search) and the fleets (fleet filter, through fleet_drivers). + * + * @var string[] + */ + protected $apiCacheDependencies = ['user', 'vehicle', 'fleets']; + /** * Dynamic attributes that are appended to object. * diff --git a/server/src/Models/FleetDriver.php b/server/src/Models/FleetDriver.php index e1e5d9fcc..3b61ffaff 100644 --- a/server/src/Models/FleetDriver.php +++ b/server/src/Models/FleetDriver.php @@ -3,12 +3,15 @@ namespace Fleetbase\FleetOps\Models; use Fleetbase\Models\Model; +use Fleetbase\Traits\HasApiModelCache; use Fleetbase\Traits\HasUuid; use Illuminate\Database\Eloquent\Relations\BelongsTo; class FleetDriver extends Model { use HasUuid; + // Writes bump the fleet_drivers API cache version, which cached driver lists filter on. + use HasApiModelCache; /** * The database table used by the model. diff --git a/server/src/Models/FleetVehicle.php b/server/src/Models/FleetVehicle.php index 74dd1ec75..037452ca5 100644 --- a/server/src/Models/FleetVehicle.php +++ b/server/src/Models/FleetVehicle.php @@ -3,12 +3,15 @@ namespace Fleetbase\FleetOps\Models; use Fleetbase\Models\Model; +use Fleetbase\Traits\HasApiModelCache; use Fleetbase\Traits\HasUuid; use Illuminate\Database\Eloquent\Relations\BelongsTo; class FleetVehicle extends Model { use HasUuid; + // Writes bump the fleet_vehicles API cache version, which cached vehicle lists filter on. + use HasApiModelCache; /** * The database table used by the model. diff --git a/server/src/Models/Order.php b/server/src/Models/Order.php index 378d29795..8f404281b 100644 --- a/server/src/Models/Order.php +++ b/server/src/Models/Order.php @@ -196,6 +196,38 @@ class Order extends Model */ protected $with = []; + /** + * Relation paths and tables the cached order lists embed, so a write to any + * of them invalidates those lists (fleetbase/core-api#301). + * + * They mirror OrderFilter::queryForInternal()'s eager loads and whereHas + * membership, the payload relation's own `with`, Payload's `$with` and the + * public query's loads. customer and facilitator are morphTo relations, so + * their tables (contacts, vendors, integrated_vendors) are named instead; + * a vendor loads its place, which payload.pickup already covers. files + * (customer photo, vendor logo) is left out: every upload writes it, and a + * new photo is set by writing the contact's or vendor's own row. + * + * @var string[] + */ + protected $apiCacheDependencies = [ + 'payload', + 'payload.pickup', + 'payload.dropoff', + 'payload.return', + 'payload.entities', + 'payload.waypoints', + 'payload.firstWaypointMarker.place', + 'payload.lastWaypointMarker.place', + 'trackingNumber', + 'trackingStatuses', + 'driverAssigned', + 'vehicleAssigned', + 'contacts', + 'vendors', + 'integrated_vendors', + ]; + /** * The attributes that should be cast to native types. * diff --git a/server/src/Models/ServiceArea.php b/server/src/Models/ServiceArea.php index d910afb2c..6f3d9738d 100644 --- a/server/src/Models/ServiceArea.php +++ b/server/src/Models/ServiceArea.php @@ -91,6 +91,13 @@ class ServiceArea extends Model */ protected $with = ['zones']; + /** + * Relation paths the cached service area lists embed through `$with` (fleetbase/core-api#301). + * + * @var string[] + */ + protected $apiCacheDependencies = ['zones']; + /** * Dynamic attributes that are appended to object. * diff --git a/server/src/Models/Vehicle.php b/server/src/Models/Vehicle.php index 250b440d9..72813a3db 100644 --- a/server/src/Models/Vehicle.php +++ b/server/src/Models/Vehicle.php @@ -81,6 +81,19 @@ class Vehicle extends Model */ protected $with = []; + /** + * Relation paths the cached vehicle lists embed or filter on (fleetbase/core-api#301). + * + * The public list loads vendor (with its place), category, warranty and + * driver; driver, devices, current trailers (through asset_connections) and + * fleets (through fleet_vehicles) decide which vehicles the filters return. + * photo is left out: files is written by every upload, and a new photo is + * set by writing the vehicle's own photo_uuid. + * + * @var string[] + */ + protected $apiCacheDependencies = ['vendor', 'vendor.place', 'category', 'warranty', 'driver', 'devices', 'currentTrailers', 'fleets']; + /** * Get the activity log options for the model. */ diff --git a/server/src/Models/Vendor.php b/server/src/Models/Vendor.php index 079367acb..f76217355 100644 --- a/server/src/Models/Vendor.php +++ b/server/src/Models/Vendor.php @@ -117,6 +117,13 @@ class Vendor extends Model */ protected $with = ['place']; + /** + * Relation paths the cached vendor lists embed through `$with` (fleetbase/core-api#301). + * + * @var string[] + */ + protected $apiCacheDependencies = ['place']; + /** * Get the activity log options for the model. */ diff --git a/server/src/Models/Waypoint.php b/server/src/Models/Waypoint.php index bfb895df3..1d74a449b 100644 --- a/server/src/Models/Waypoint.php +++ b/server/src/Models/Waypoint.php @@ -8,6 +8,7 @@ use Fleetbase\FleetOps\Traits\HasTrackingNumber; use Fleetbase\FleetOps\Traits\PayloadAccessors; use Fleetbase\Models\Model; +use Fleetbase\Traits\HasApiModelCache; use Fleetbase\Traits\HasPublicId; use Fleetbase\Traits\HasUuid; use Fleetbase\Traits\TracksApiCredential; @@ -20,6 +21,8 @@ class Waypoint extends Model { use HasUuid; + // Writes bump the waypoints API cache version, which cached order lists embed. + use HasApiModelCache; use HasPublicId; use TracksApiCredential; use HasTrackingNumber; From 8ba73cf2c4b91368138446baedf9263fac24b585 Mon Sep 17 00:00:00 2001 From: "Ronald A. Richardson" Date: Mon, 12 Oct 2026 23:17:30 +0800 Subject: [PATCH 02/11] fix(api-cache): invalidate order lists after quiet order writes Order writes that skip model events left the cached order lists, and the order's own cached lookups, on the old version: dispatching, attaching a route, purchase rate or transaction, setting distance and time, scheduling, bulk driver assignment and unassignment, unassigning a deleted driver's or a vehicle's orders, saving recipient instructions and recording operational alerts. Each now invalidates the orders cache for the order's company after the write. The writes themselves stay quiet. --- server/src/Console/Commands/ProcessOperationalAlerts.php | 1 + .../src/Http/Controllers/Internal/v1/DriverController.php | 1 + .../src/Http/Controllers/Internal/v1/OrderController.php | 2 ++ .../src/Http/Controllers/Internal/v1/VehicleController.php | 1 + server/src/Http/Controllers/Public/TrackingController.php | 1 + server/src/Models/Order.php | 6 ++++++ server/src/Observers/DriverObserver.php | 7 ++++++- 7 files changed, 18 insertions(+), 1 deletion(-) diff --git a/server/src/Console/Commands/ProcessOperationalAlerts.php b/server/src/Console/Commands/ProcessOperationalAlerts.php index c9ae5eab7..8f1110fcf 100644 --- a/server/src/Console/Commands/ProcessOperationalAlerts.php +++ b/server/src/Console/Commands/ProcessOperationalAlerts.php @@ -189,6 +189,7 @@ protected function notifyOnce(Order $order, string $key, string $notificationCla 'context' => $context, ]); $order->saveQuietly(); + $order->invalidateApiCache(); } return true; diff --git a/server/src/Http/Controllers/Internal/v1/DriverController.php b/server/src/Http/Controllers/Internal/v1/DriverController.php index 87d591cee..aee020d4b 100644 --- a/server/src/Http/Controllers/Internal/v1/DriverController.php +++ b/server/src/Http/Controllers/Internal/v1/DriverController.php @@ -595,6 +595,7 @@ protected function clearDriverAssignmentsForOrders($orders): void 'driver_assigned_uuid' => null, 'updated_at' => now(), ]); + Order::invalidateApiCacheManually(session('company')); } protected function freshOrders($orders, array $with) diff --git a/server/src/Http/Controllers/Internal/v1/OrderController.php b/server/src/Http/Controllers/Internal/v1/OrderController.php index 1375bc8d3..169c815cf 100644 --- a/server/src/Http/Controllers/Internal/v1/OrderController.php +++ b/server/src/Http/Controllers/Internal/v1/OrderController.php @@ -776,6 +776,7 @@ protected function assignDriverToOrders($orderUuids, Driver $driver): void 'driver_assigned_uuid' => $driver->uuid, 'updated_at' => now(), ]); + Order::invalidateApiCacheManually(session('company')); } protected function dispatchBulkAssignedDriverNotification(array $orderUuids, Driver $driver): void @@ -2005,6 +2006,7 @@ public function scheduleOrder(Request $request) } $order->saveQuietly(); + $order->invalidateApiCache(); return response()->json([ 'status' => 'OK', diff --git a/server/src/Http/Controllers/Internal/v1/VehicleController.php b/server/src/Http/Controllers/Internal/v1/VehicleController.php index b8f113d93..25712b6cb 100644 --- a/server/src/Http/Controllers/Internal/v1/VehicleController.php +++ b/server/src/Http/Controllers/Internal/v1/VehicleController.php @@ -186,6 +186,7 @@ public function unassignOrders(Request $request, string $id): JsonResponse 'updated_at' => now(), ]); }); + Order::invalidateApiCacheManually(session('company')); return response()->json([ 'status' => 'ok', diff --git a/server/src/Http/Controllers/Public/TrackingController.php b/server/src/Http/Controllers/Public/TrackingController.php index eaba4c722..758c3436f 100644 --- a/server/src/Http/Controllers/Public/TrackingController.php +++ b/server/src/Http/Controllers/Public/TrackingController.php @@ -340,6 +340,7 @@ protected function saveInstructions(Order $order, TrackingScope $scope, string $ $instructions[sha1($scope->key())] = $text; $order->setMeta('recipient_instructions', $instructions); $order->saveQuietly(); + $order->invalidateApiCache(); } protected function dataUrl(string $raw): string diff --git a/server/src/Models/Order.php b/server/src/Models/Order.php index 8f404281b..f2e306ae5 100644 --- a/server/src/Models/Order.php +++ b/server/src/Models/Order.php @@ -1091,6 +1091,7 @@ public function setRoute(Route|array|null $attributes = []) $route->save(); $this->updateQuietly(['route_uuid' => $route->uuid]); + $this->invalidateApiCache(); return $this; } @@ -1226,6 +1227,7 @@ public function attachPurchaseRate(PurchaseRate $purchaseRate): bool ]); if ($attached) { + $this->invalidateApiCache(); $this->setRelation('purchaseRate', $purchaseRate); if ($purchaseRate->transaction_uuid) { @@ -1288,6 +1290,7 @@ public function createOrderTransactionWithoutServiceQuote(): ?Transaction ]); $this->updateQuietly(['transaction_uuid' => $transaction->uuid]); + $this->invalidateApiCache(); } catch (\Throwable $e) { } @@ -1327,6 +1330,7 @@ public function dispatch(bool $save = true): self if ($save) { $this->saveQuietly(); $this->flushAttributesCache(); + $this->invalidateApiCache(); } dispatch(fn () => event(new OrderDispatched($this)))->afterCommit(); @@ -1814,6 +1818,7 @@ public function setPreliminaryDistanceAndTime() $matrix = Utils::getPreliminaryDistanceMatrix($origin, $destination); $this->updateQuietly(['distance' => $matrix->distance, 'time' => $matrix->time]); + $this->invalidateApiCache(); return $this; } @@ -1851,6 +1856,7 @@ public function setDistanceAndTime(array $options = []): self 'distance' => $matrix->distance, 'time' => $matrix->time, ]); + $this->invalidateApiCache(); } return $this; diff --git a/server/src/Observers/DriverObserver.php b/server/src/Observers/DriverObserver.php index ba806dc83..6d124eb50 100644 --- a/server/src/Observers/DriverObserver.php +++ b/server/src/Observers/DriverObserver.php @@ -79,7 +79,12 @@ protected function invalidateLiveCache(): void protected function unassignOrders(Driver $driver): int { - return Order::where(['driver_assigned_uuid' => $driver->uuid])->update(['driver_assigned_uuid' => null]); + $unassigned = Order::where(['driver_assigned_uuid' => $driver->uuid])->update(['driver_assigned_uuid' => null]); + if ($unassigned > 0) { + Order::invalidateApiCacheManually($driver->company_uuid); + } + + return $unassigned; } protected function findDriverUser(Driver $driver): ?User From bfd5b87ecf2dbcace12756cac6c222412af1dd09 Mon Sep 17 00:00:00 2001 From: "Ronald A. Richardson" Date: Mon, 12 Oct 2026 23:17:42 +0800 Subject: [PATCH 03/11] fix(api-cache): invalidate embedded tables after writes that skip events Raw inserts, query builder updates and deletes, and quiet saves bump no cache version, so lists embedding those tables kept serving stale rows. Invalidate the written table after each of them: - waypoints: raw insert, payload waypoint removal, orchestrator resequence - payloads: current waypoint and pickup/dropoff/return writes, service stop tracking numbers - tracking numbers and statuses: raw inserts, status pointer updates, and the owner's status and tracking number columns - entities and places: raw inserts and payload reassignment - drivers: vehicle unassignment and deletes cascaded from vehicles and users - fleet memberships, trailer detach, contact and issue quiet saves, customer reassignment to a vendor - order delete and restore cascades (payloads, entities, waypoints, tracking numbers and statuses) - telematics: a sweep that changes the connection status; the sync meta it rewrites every minute does not invalidate The writes themselves stay quiet. --- .../Controllers/Api/v1/FleetController.php | 14 ++++++++++---- .../Internal/v1/ContactController.php | 3 +++ .../Internal/v1/FleetController.php | 18 ++++++++++++++++-- .../Internal/v1/IssueController.php | 2 ++ .../Internal/v1/OrchestrationController.php | 14 +++++++++++--- .../Internal/v1/TrailerController.php | 1 + server/src/Models/Contact.php | 1 + server/src/Models/Driver.php | 1 + server/src/Models/Entity.php | 7 +++++++ server/src/Models/Payload.php | 6 ++++++ server/src/Models/Place.php | 5 +++++ server/src/Models/TrackingNumber.php | 5 +++++ server/src/Models/TrackingStatus.php | 6 ++++++ server/src/Models/Vehicle.php | 1 + server/src/Models/Waypoint.php | 5 +++++ server/src/Observers/CompanyUserObserver.php | 5 ++++- server/src/Observers/OrderObserver.php | 16 ++++++++++++++++ server/src/Observers/UserObserver.php | 5 ++++- server/src/Observers/VehicleObserver.php | 7 ++++++- .../src/Support/ResolvesOrderServiceStops.php | 5 +++++ .../src/Support/Telematics/Telemetry/Inbox.php | 9 ++++++++- server/src/Traits/HasTrackingNumber.php | 2 ++ 22 files changed, 125 insertions(+), 13 deletions(-) diff --git a/server/src/Http/Controllers/Api/v1/FleetController.php b/server/src/Http/Controllers/Api/v1/FleetController.php index 16e7a0594..42b8d3b71 100644 --- a/server/src/Http/Controllers/Api/v1/FleetController.php +++ b/server/src/Http/Controllers/Api/v1/FleetController.php @@ -434,10 +434,13 @@ protected function assignMembership(string $pivotClass, array $attributes): void protected function removeVehicleFromFleet(Fleet $fleet, Vehicle $vehicle): void { - FleetVehicle::where([ + // Deleted without model events; fleet_vehicles rows carry no company + if (FleetVehicle::where([ 'fleet_uuid' => $fleet->uuid, 'vehicle_uuid' => $vehicle->uuid, - ])->delete(); + ])->delete()) { + FleetVehicle::invalidateApiCacheManually(); + } } protected function assignDriverToFleet(Fleet $fleet, Driver $driver): void @@ -450,10 +453,13 @@ protected function assignDriverToFleet(Fleet $fleet, Driver $driver): void protected function removeDriverFromFleet(Fleet $fleet, Driver $driver): void { - FleetDriver::where([ + // Deleted without model events; fleet_drivers rows carry no company + if (FleetDriver::where([ 'fleet_uuid' => $fleet->uuid, 'driver_uuid' => $driver->uuid, - ])->delete(); + ])->delete()) { + FleetDriver::invalidateApiCacheManually(); + } } protected function vehicleMembershipPayload(Fleet $fleet, Vehicle $vehicle, bool $assigned): array diff --git a/server/src/Http/Controllers/Internal/v1/ContactController.php b/server/src/Http/Controllers/Internal/v1/ContactController.php index f4b3346f2..ecfdf21fd 100644 --- a/server/src/Http/Controllers/Internal/v1/ContactController.php +++ b/server/src/Http/Controllers/Internal/v1/ContactController.php @@ -19,6 +19,7 @@ use Fleetbase\Http\Requests\ExportRequest; use Fleetbase\Http\Requests\ImportRequest; use Fleetbase\Models\User; +use Fleetbase\Support\ApiModelCache; use Illuminate\Http\Request; use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\Mail; @@ -266,6 +267,7 @@ protected function migrateContactCustomerContextToVendor(Contact $contact, Vendo protected function bulkUpdateCustomerContext(string $modelClass, array $filter, array $replacement): void { $modelClass::where($filter)->update($replacement); + ApiModelCache::invalidateModelCache(new $modelClass(), session('company')); } protected function customerPortalIssuesForContact(Contact $contact) @@ -350,6 +352,7 @@ protected function sendCustomerCredentialsMail(User $user, string $password, Con protected function saveContactMetaQuietly(Contact $contact, array $meta): void { $contact->forceFill(['meta' => $meta])->saveQuietly(); + $contact->invalidateApiCache(); } protected function isCustomerPortalInstalled(): bool diff --git a/server/src/Http/Controllers/Internal/v1/FleetController.php b/server/src/Http/Controllers/Internal/v1/FleetController.php index ba5962321..e66c1e1fe 100644 --- a/server/src/Http/Controllers/Internal/v1/FleetController.php +++ b/server/src/Http/Controllers/Internal/v1/FleetController.php @@ -250,10 +250,17 @@ protected static function createFleetDriverAssignment(string $fleetUuid, string protected static function deleteFleetDriverAssignment(string $fleetUuid, string $driverUuid): mixed { - return FleetDriver::where([ + $deleted = FleetDriver::where([ 'fleet_uuid' => $fleetUuid, 'driver_uuid' => $driverUuid, ])->delete(); + + // Deleted without model events; fleet_drivers rows carry no company + if ($deleted) { + FleetDriver::invalidateApiCacheManually(); + } + + return $deleted; } protected static function fleetVehicleAssignmentExists(string $fleetUuid, string $vehicleUuid): bool @@ -274,10 +281,17 @@ protected static function createFleetVehicleAssignment(string $fleetUuid, string protected static function deleteFleetVehicleAssignment(string $fleetUuid, string $vehicleUuid): mixed { - return FleetVehicle::where([ + $deleted = FleetVehicle::where([ 'fleet_uuid' => $fleetUuid, 'vehicle_uuid' => $vehicleUuid, ])->delete(); + + // Deleted without model events; fleet_vehicles rows carry no company + if ($deleted) { + FleetVehicle::invalidateApiCacheManually(); + } + + return $deleted; } protected static function invalidateOperationsMonitor(): void diff --git a/server/src/Http/Controllers/Internal/v1/IssueController.php b/server/src/Http/Controllers/Internal/v1/IssueController.php index 3195f4ed3..bdd9fb98c 100644 --- a/server/src/Http/Controllers/Internal/v1/IssueController.php +++ b/server/src/Http/Controllers/Internal/v1/IssueController.php @@ -38,6 +38,7 @@ public function afterSave(Request $request, Issue $issue) if ($assignee && $assignee->type === 'customer') { $issue->assigned_to_uuid = null; $issue->saveQuietly(); + $issue->invalidateApiCache(); } } @@ -58,6 +59,7 @@ public function afterSave(Request $request, Issue $issue) if ($order) { $issue->order_uuid = $order->uuid; $issue->saveQuietly(); + $issue->invalidateApiCache(); } } } diff --git a/server/src/Http/Controllers/Internal/v1/OrchestrationController.php b/server/src/Http/Controllers/Internal/v1/OrchestrationController.php index cfccd48b5..6db6bdab8 100644 --- a/server/src/Http/Controllers/Internal/v1/OrchestrationController.php +++ b/server/src/Http/Controllers/Internal/v1/OrchestrationController.php @@ -13,6 +13,7 @@ use Fleetbase\FleetOps\Models\Place; use Fleetbase\FleetOps\Models\Vehicle; use Fleetbase\FleetOps\Models\Vendor; +use Fleetbase\FleetOps\Models\Waypoint; use Fleetbase\FleetOps\Orchestration\Contracts\OrchestrationEngineInterface; use Fleetbase\FleetOps\Orchestration\Engines\DriverAssignmentEngine; use Fleetbase\FleetOps\Orchestration\Engines\RouteSequencingEngine; @@ -489,9 +490,10 @@ public function commit(Request $request): JsonResponse return response()->json(['error' => 'No assignments provided.'], 422); } - $committed = []; - $failed = []; - $manifests = []; + $committed = []; + $failed = []; + $manifests = []; + $resequenced = false; $this->beginOrchestrationTransaction(); try { @@ -576,6 +578,7 @@ public function commit(Request $request): JsonResponse if (!empty($assignment['waypoint_sequence']) && $order->payload) { foreach ($assignment['waypoint_sequence'] as $seq => $waypointId) { $this->updateWaypointSequence($order->payload_uuid, $waypointId, $seq); + $resequenced = true; } } @@ -597,6 +600,11 @@ public function commit(Request $request): JsonResponse return response()->json(['error' => 'Commit failed: ' . $e->getMessage()], 500); } + // Waypoint sequences are written without model events + if ($resequenced) { + Waypoint::invalidateApiCacheManually($companyUuid); + } + return response()->json([ 'committed' => $committed, 'failed' => $failed, diff --git a/server/src/Http/Controllers/Internal/v1/TrailerController.php b/server/src/Http/Controllers/Internal/v1/TrailerController.php index 841ab10e8..41420ded5 100644 --- a/server/src/Http/Controllers/Internal/v1/TrailerController.php +++ b/server/src/Http/Controllers/Internal/v1/TrailerController.php @@ -166,6 +166,7 @@ public function detach(string $id) 'updated_by_uuid' => session('user'), 'updated_at' => now(), ])); + AssetConnection::invalidateApiCacheManually(session('company')); return response()->json([ 'status' => 'ok', diff --git a/server/src/Models/Contact.php b/server/src/Models/Contact.php index 28decd7e6..9f0af9374 100644 --- a/server/src/Models/Contact.php +++ b/server/src/Models/Contact.php @@ -417,6 +417,7 @@ public function repairCustomerTypeInvariant(bool $quiet = false): ?User ]); $user->forceFill(['type' => 'customer'])->saveQuietly(); $this->forceFill(['user_uuid' => $user->uuid])->saveQuietly(); + $this->invalidateApiCache(); } else { $user = $this->createUser(); } diff --git a/server/src/Models/Driver.php b/server/src/Models/Driver.php index 63164b526..f445a0006 100644 --- a/server/src/Models/Driver.php +++ b/server/src/Models/Driver.php @@ -683,6 +683,7 @@ public function assignVehicle(Vehicle $vehicle): self { // Unassign vehicle from other drivers static::where('vehicle_uuid', $vehicle->uuid)->update(['vehicle_uuid' => null]); + static::invalidateApiCacheManually($vehicle->company_uuid ?? $this->company_uuid); // Set this vehicle to the driver instance $this->setVehicle($vehicle); diff --git a/server/src/Models/Entity.php b/server/src/Models/Entity.php index 224d15ec0..b45b8d9a1 100644 --- a/server/src/Models/Entity.php +++ b/server/src/Models/Entity.php @@ -9,6 +9,7 @@ use Fleetbase\FleetOps\Traits\HasTrackingNumber; use Fleetbase\FleetOps\Traits\PayloadAccessors; use Fleetbase\Models\Model; +use Fleetbase\Support\ApiModelCache; use Fleetbase\Traits\HasApiModelBehavior; use Fleetbase\Traits\HasCustomFields; use Fleetbase\Traits\HasInternalId; @@ -437,6 +438,7 @@ public static function insertGetUuid($values = [], ?Payload $payload = null) Entity::where('uuid', $values['uuid'])->update([ 'payload_uuid' => $payload->uuid, ]); + ApiModelCache::invalidateModelCache(new static(), $payload->company_uuid ?? session('company')); return $values['uuid']; } @@ -482,6 +484,11 @@ public static function insertGetUuid($values = [], ?Payload $payload = null) static::where('uuid', $uuid)->update(['tracking_number_uuid' => $trackingNumberId]); } + // Inserted and updated without model events + if ($result) { + ApiModelCache::invalidateModelCache($instance, $values['company_uuid']); + } + return $result ? $uuid : false; } diff --git a/server/src/Models/Payload.php b/server/src/Models/Payload.php index de158cc0f..cb690f625 100644 --- a/server/src/Models/Payload.php +++ b/server/src/Models/Payload.php @@ -12,6 +12,7 @@ use Fleetbase\FleetOps\Support\Utils; use Fleetbase\LaravelMysqlSpatial\Types\Point; use Fleetbase\Models\Model; +use Fleetbase\Support\ApiModelCache; use Fleetbase\Traits\HasApiModelBehavior; use Fleetbase\Traits\HasMetaAttributes; use Fleetbase\Traits\HasPublicId; @@ -365,6 +366,7 @@ public function setCurrentWaypoint(Place|Waypoint $destination, bool $save = tru $this->current_waypoint_uuid = $destination->uuid; if ($save) { DB::table($this->getTable())->where('uuid', $this->uuid)->update(['current_waypoint_uuid' => $destination->uuid]); + ApiModelCache::invalidateModelCache($this, $this->company_uuid); } return $this; @@ -868,6 +870,7 @@ public function getOrder() public function removeWaypoints() { Waypoint::where('payload_uuid', $this->uuid)->delete(); + Waypoint::invalidateApiCacheManually($this->company_uuid); $this->setRelation('waypoints', collect()); return $this; @@ -895,6 +898,7 @@ public function removePlace($property, array $options = []) if ($save) { $this->updateQuietly([$attr => null]); + ApiModelCache::invalidateModelCache($this, $this->company_uuid); } if (is_callable($callback)) { @@ -928,6 +932,7 @@ public function setPlace($property, Place $place, array $options = []) // If optioned to save if ($save) { $this->updateQuietly([$attr => $id]); + ApiModelCache::invalidateModelCache($this, $this->company_uuid); } if (is_callable($callback)) { @@ -1000,6 +1005,7 @@ public function setFirstWaypoint(?Activity $activity = null, $location = null) $this->current_waypoint_uuid = $destination->uuid; $this->saveQuietly(); + ApiModelCache::invalidateModelCache($this, $this->company_uuid); $this->updateWaypointActivity($activity, $location); return $this->load('currentWaypoint'); diff --git a/server/src/Models/Place.php b/server/src/Models/Place.php index 898259e5f..1c3e8ea27 100644 --- a/server/src/Models/Place.php +++ b/server/src/Models/Place.php @@ -873,6 +873,11 @@ public static function insertGetUuid($values = []) $result = static::insert($values); + // Inserted without model events + if ($result) { + static::invalidateApiCacheManually($values['company_uuid']); + } + return $result ? $uuid : false; } diff --git a/server/src/Models/TrackingNumber.php b/server/src/Models/TrackingNumber.php index 68ae9bd15..a5c9f0c77 100644 --- a/server/src/Models/TrackingNumber.php +++ b/server/src/Models/TrackingNumber.php @@ -8,6 +8,7 @@ use Fleetbase\FleetOps\Support\Utils; use Fleetbase\LaravelMysqlSpatial\Eloquent\SpatialTrait; use Fleetbase\Models\Model; +use Fleetbase\Support\ApiModelCache; use Fleetbase\Traits\HasApiModelBehavior; use Fleetbase\Traits\HasPublicId; use Fleetbase\Traits\HasUuid; @@ -387,8 +388,12 @@ public static function insertGetUuid($values = [], ?Model $owner = null) // silent update static::updateOwnerStatusColumn($owner, $initialCode ?? 'created'); + ApiModelCache::invalidateModelCache($owner, data_get($owner, 'company_uuid', $values['company_uuid'])); } + // Inserted and updated without model events + ApiModelCache::invalidateModelCache($instance, $values['company_uuid']); + return $uuid; } diff --git a/server/src/Models/TrackingStatus.php b/server/src/Models/TrackingStatus.php index b866a52b3..4166c62ae 100644 --- a/server/src/Models/TrackingStatus.php +++ b/server/src/Models/TrackingStatus.php @@ -7,6 +7,7 @@ use Fleetbase\FleetOps\Support\Utils; use Fleetbase\LaravelMysqlSpatial\Eloquent\SpatialTrait; use Fleetbase\Models\Model; +use Fleetbase\Support\ApiModelCache; use Fleetbase\Traits\HasApiModelBehavior; use Fleetbase\Traits\HasMetaAttributes; use Fleetbase\Traits\HasPublicId; @@ -177,6 +178,11 @@ public static function insertGetUuid($values = [], ?TrackingNumber $trackingNumb $result = static::insertTrackingStatus($values); + // Inserted without model events + if ($result) { + ApiModelCache::invalidateModelCache($instance, $values['company_uuid']); + } + return $result ? $uuid : false; } diff --git a/server/src/Models/Vehicle.php b/server/src/Models/Vehicle.php index 72813a3db..9f817dfb7 100644 --- a/server/src/Models/Vehicle.php +++ b/server/src/Models/Vehicle.php @@ -642,6 +642,7 @@ public function assignDriver(Driver $driver) public function unassignDriver(): self { Driver::where('vehicle_uuid', $this->uuid)->update(['vehicle_uuid' => null]); + Driver::invalidateApiCacheManually($this->company_uuid); $this->unsetRelation('driver'); return $this; diff --git a/server/src/Models/Waypoint.php b/server/src/Models/Waypoint.php index 1d74a449b..227c87156 100644 --- a/server/src/Models/Waypoint.php +++ b/server/src/Models/Waypoint.php @@ -235,6 +235,11 @@ public static function insertGetUuid($values = [], ?Payload $payload = null) static::updateTrackingNumberUuid($uuid, $trackingNumberId); } + // Inserted and updated without model events + if ($result) { + static::invalidateApiCacheManually($values['company_uuid']); + } + return $result ? $uuid : false; } diff --git a/server/src/Observers/CompanyUserObserver.php b/server/src/Observers/CompanyUserObserver.php index 016830b0f..378c0fe37 100644 --- a/server/src/Observers/CompanyUserObserver.php +++ b/server/src/Observers/CompanyUserObserver.php @@ -34,7 +34,10 @@ public function updated(CompanyUser $companyUser) protected function deleteDrivers(string $userUuid): void { - Driver::where('user_uuid', $userUuid)->delete(); + // A user's drivers can belong to several companies, so this invalidates every company's driver lists + if (Driver::where('user_uuid', $userUuid)->delete()) { + Driver::invalidateApiCacheManually(); + } } protected function findDriver(string $userUuid): ?Driver diff --git a/server/src/Observers/OrderObserver.php b/server/src/Observers/OrderObserver.php index a5b8a8496..796c908c7 100644 --- a/server/src/Observers/OrderObserver.php +++ b/server/src/Observers/OrderObserver.php @@ -10,6 +10,7 @@ use Fleetbase\FleetOps\Models\Waypoint; use Fleetbase\FleetOps\Support\LiveCacheService; use Fleetbase\FleetOps\Support\TrackingPublisher; +use Fleetbase\Support\ApiModelCache; use Illuminate\Support\Facades\Cache; class OrderObserver @@ -126,6 +127,8 @@ protected function cascadeDelete(Order $order): void $trackingNumbers = TrackingNumber::where(fn ($query) => $query->whereIn('uuid', $tree['tracking_numbers'])->orWhereIn('owner_uuid', $tree['owners']))->pluck('uuid')->all(); TrackingStatus::whereIn('tracking_number_uuid', $trackingNumbers)->update(['deleted_at' => $deletedAt]); TrackingNumber::whereIn('uuid', $trackingNumbers)->update(['deleted_at' => $deletedAt]); + + $this->invalidateChildApiCaches($order); } /** @@ -147,6 +150,19 @@ protected function cascadeRestore(Order $order, string $deletedAt): void $trashed(Entity::class)->whereIn('uuid', $tree['entities'])->update(['deleted_at' => null]); $trashed(Waypoint::class)->whereIn('uuid', $tree['waypoints'])->update(['deleted_at' => null]); } + + $this->invalidateChildApiCaches($order); + } + + /** + * The cascades write their tables without model events, so invalidate the API + * caches of every table they touch. + */ + protected function invalidateChildApiCaches(Order $order): void + { + foreach ([Payload::class, Entity::class, Waypoint::class, TrackingNumber::class, TrackingStatus::class] as $modelClass) { + ApiModelCache::invalidateModelCache(new $modelClass(), $order->company_uuid); + } } /** diff --git a/server/src/Observers/UserObserver.php b/server/src/Observers/UserObserver.php index 71f8e7b4c..7c7a3d8b9 100644 --- a/server/src/Observers/UserObserver.php +++ b/server/src/Observers/UserObserver.php @@ -20,6 +20,9 @@ public function deleted(User $user) protected function deleteDrivers(string $userUuid): void { - Driver::where('user_uuid', $userUuid)->delete(); + // A user's drivers can belong to several companies, so this invalidates every company's driver lists + if (Driver::where('user_uuid', $userUuid)->delete()) { + Driver::invalidateApiCacheManually(); + } } } diff --git a/server/src/Observers/VehicleObserver.php b/server/src/Observers/VehicleObserver.php index 761d345f9..1af205e18 100644 --- a/server/src/Observers/VehicleObserver.php +++ b/server/src/Observers/VehicleObserver.php @@ -83,7 +83,12 @@ protected function findDriver(string $identifier): ?Driver protected function deleteDriversAssignedTo(Vehicle $vehicle): mixed { - return Driver::where(['vehicle_uuid' => $vehicle->uuid])->delete(); + $deleted = Driver::where(['vehicle_uuid' => $vehicle->uuid])->delete(); + if ($deleted) { + Driver::invalidateApiCacheManually($vehicle->company_uuid); + } + + return $deleted; } protected function invalidateLiveCache(): void diff --git a/server/src/Support/ResolvesOrderServiceStops.php b/server/src/Support/ResolvesOrderServiceStops.php index cf3aa83a1..17c2d3b12 100644 --- a/server/src/Support/ResolvesOrderServiceStops.php +++ b/server/src/Support/ResolvesOrderServiceStops.php @@ -15,6 +15,7 @@ use Fleetbase\FleetOps\Models\TrackingStatus; use Fleetbase\FleetOps\Models\Waypoint; use Fleetbase\LaravelMysqlSpatial\Types\Point; +use Fleetbase\Support\ApiModelCache; use Fleetbase\Support\TemplateString; use Illuminate\Support\Collection; use Illuminate\Support\Facades\DB; @@ -185,6 +186,7 @@ protected function setPayloadCurrentServiceStop(Payload $payload, array $stop): $payload->current_waypoint_uuid = $place->uuid; $payload->saveQuietly(); + ApiModelCache::invalidateModelCache($payload, $payload->company_uuid); $payload->setRelation('currentWaypoint', $place); if (($stop['waypoint'] ?? null) instanceof Waypoint) { @@ -254,6 +256,7 @@ protected function updateCurrentServiceStopActivity(Order $order, Activity $acti $activityId = $waypoint->insertActivity($activity, $location, $proof); if ($activityId && $waypoint->tracking_number_uuid) { DB::table((new TrackingNumber())->getTable())->where('uuid', $waypoint->tracking_number_uuid)->update(['status_uuid' => $activityId]); + ApiModelCache::invalidateModelCache(new TrackingNumber(), $payload->company_uuid ?? $order->company_uuid); if ($waypoint->relationLoaded('trackingNumber')) { $waypoint->trackingNumber->status_uuid = $activityId; $waypoint->trackingNumber->unsetRelation('status'); @@ -391,6 +394,7 @@ protected function endpointServiceStopTrackingNumber(Order $order, Payload $payl // @codeCoverageIgnoreEnd DB::table($payload->getTable())->where('uuid', $payload->uuid)->update([$column => $trackingNumberUuid]); + ApiModelCache::invalidateModelCache($payload, $payload->company_uuid ?? $order->company_uuid); $payload->{$column} = $trackingNumberUuid; return TrackingNumber::where('uuid', $trackingNumberUuid)->first(); @@ -417,6 +421,7 @@ protected function insertEndpointServiceStopActivity(Order $order, Payload $payl if ($activityId) { DB::table($trackingNumber->getTable())->where('uuid', $trackingNumber->uuid)->update(['status_uuid' => $activityId]); + ApiModelCache::invalidateModelCache($trackingNumber, $payload->company_uuid ?? $order->company_uuid); $trackingNumber->status_uuid = $activityId; } diff --git a/server/src/Support/Telematics/Telemetry/Inbox.php b/server/src/Support/Telematics/Telemetry/Inbox.php index b1e998409..34b43b3e8 100644 --- a/server/src/Support/Telematics/Telemetry/Inbox.php +++ b/server/src/Support/Telematics/Telemetry/Inbox.php @@ -4,6 +4,7 @@ use Fleetbase\FleetOps\Jobs\ProcessTelematicDelivery; use Fleetbase\FleetOps\Models\Telematic; +use Fleetbase\Support\ApiModelCache; use Illuminate\Support\Facades\Crypt; use Illuminate\Support\Facades\DB; use Illuminate\Support\Str; @@ -98,10 +99,16 @@ public static function finishRun(?string $id): void return; } // Written without model events: sweeps run every minute and the model logs every save. + $connectionStatus = in_array($connection->status, ['error', 'synchronizing'], true) ? 'active' : $connection->status; DB::table('telematics')->where('uuid', $connection->uuid)->update([ - 'status' => in_array($connection->status, ['error', 'synchronizing'], true) ? 'active' : $connection->status, + 'status' => $connectionStatus, 'meta' => json_encode(array_merge($meta, $result), JSON_THROW_ON_ERROR), ]); + // Cached device lists embed the telematic. Only a status change invalidates + // them: the sync meta changes on every sweep and may lag by the list TTL. + if ($connectionStatus !== $connection->status) { + ApiModelCache::invalidateModelCache($connection, $connection->company_uuid); + } }); } } diff --git a/server/src/Traits/HasTrackingNumber.php b/server/src/Traits/HasTrackingNumber.php index 44945b649..ab931862c 100644 --- a/server/src/Traits/HasTrackingNumber.php +++ b/server/src/Traits/HasTrackingNumber.php @@ -9,6 +9,7 @@ use Fleetbase\FleetOps\Models\TrackingStatus; use Fleetbase\FleetOps\Support\Utils; use Fleetbase\LaravelMysqlSpatial\Types\Point; +use Fleetbase\Support\ApiModelCache; use Fleetbase\Support\TemplateString; use Illuminate\Database\Eloquent\Model; use Illuminate\Support\Facades\DB; @@ -28,6 +29,7 @@ public static function bootHasTrackingNumber(): void ], $model); DB::table($model->getTable())->where('uuid', $model->uuid)->update(['tracking_number_uuid' => $trackingNumberId]); + ApiModelCache::invalidateModelCache($model, data_get($model, 'company_uuid', session('company'))); if ($model->isFillable('tracking_number_uuid')) { $model->tracking_number_uuid = $trackingNumberId; From 62c7091a2a271904d81ef354e29e50009d8f6c33 Mon Sep 17 00:00:00 2001 From: "Ronald A. Richardson" Date: Mon, 12 Oct 2026 23:17:52 +0800 Subject: [PATCH 04/11] fix(api-cache): invalidate driver and vehicle lists after location and online writes The driver and vehicle track endpoints write location quietly on every ping, so cached driver, vehicle and order lists (and storefront lists that embed vehicles) showed a location up to the list TTL old. Invalidating on every ping would keep those lists from ever being served from cache, so ApiCacheThrottle invalidates a table at most once a minute per company: a ping costs one Cache::add, and a cached location is at most a minute old. The online toggle is infrequent and invalidates on every call. --- .../Controllers/Api/v1/DriverController.php | 9 +++++ .../Controllers/Api/v1/VehicleController.php | 3 ++ server/src/Support/ApiCacheThrottle.php | 39 +++++++++++++++++++ 3 files changed, 51 insertions(+) create mode 100644 server/src/Support/ApiCacheThrottle.php diff --git a/server/src/Http/Controllers/Api/v1/DriverController.php b/server/src/Http/Controllers/Api/v1/DriverController.php index 82d0d6b16..124373721 100644 --- a/server/src/Http/Controllers/Api/v1/DriverController.php +++ b/server/src/Http/Controllers/Api/v1/DriverController.php @@ -21,6 +21,7 @@ use Fleetbase\FleetOps\Models\Order; use Fleetbase\FleetOps\Models\Vehicle; use Fleetbase\FleetOps\Models\Vendor; +use Fleetbase\FleetOps\Support\ApiCacheThrottle; use Fleetbase\FleetOps\Support\GeofenceIntersectionService; use Fleetbase\FleetOps\Support\OSRM; use Fleetbase\FleetOps\Support\ProfileAccountManager; @@ -374,6 +375,12 @@ public function track(string $id, Request $request) } } + // Location writes are quiet and frequent: invalidate cached lists at most once a window + ApiCacheThrottle::invalidate($driver, $driver->company_uuid); + if ($vehicle) { + ApiCacheThrottle::invalidate($vehicle, $vehicle->company_uuid); + } + broadcast(new DriverLocationChanged($driver)); app(TrackingPublisher::class)->driverMoved($driver); @@ -433,11 +440,13 @@ public function toggleOnline(string $id, Request $request) // Perform a single update call $driver->updateQuietly(['online' => $onlineValue]); + $driver->invalidateApiCache(); // Update vehicle online too $driver->loadMissing('vehicle'); if ($vehicle = $driver->vehicle) { $vehicle->updateQuietly(['online' => $onlineValue]); + $vehicle->invalidateApiCache(); } // Return the updated resource diff --git a/server/src/Http/Controllers/Api/v1/VehicleController.php b/server/src/Http/Controllers/Api/v1/VehicleController.php index 099d1b9ab..387d8a2d2 100644 --- a/server/src/Http/Controllers/Api/v1/VehicleController.php +++ b/server/src/Http/Controllers/Api/v1/VehicleController.php @@ -17,6 +17,7 @@ use Fleetbase\FleetOps\Models\Vehicle; use Fleetbase\FleetOps\Models\Vendor; use Fleetbase\FleetOps\Models\Warranty; +use Fleetbase\FleetOps\Support\ApiCacheThrottle; use Fleetbase\FleetOps\Support\GeofenceIntersectionService; use Fleetbase\FleetOps\Support\TrackingPublisher; use Fleetbase\FleetOps\Support\Utils; @@ -297,6 +298,8 @@ public function track(string $id, Request $request) $vehicle->updateQuietly($positionData); $vehicle->createPosition($positionData); + // Location writes are quiet and frequent: invalidate cached lists at most once a window + ApiCacheThrottle::invalidate($vehicle, $vehicle->company_uuid); broadcast(new VehicleLocationChanged($vehicle)); app(TrackingPublisher::class)->vehicleMoved($vehicle); diff --git a/server/src/Support/ApiCacheThrottle.php b/server/src/Support/ApiCacheThrottle.php new file mode 100644 index 000000000..97fd4080a --- /dev/null +++ b/server/src/Support/ApiCacheThrottle.php @@ -0,0 +1,39 @@ +getTable() . ':' . ($companyUuid ?? 'all'); + if (Cache::add($key, true, $seconds)) { + ApiModelCache::invalidateModelCache($model, $companyUuid); + } + } +} From a2562a24d90c42ac8cf820b31d361e848fc1312e Mon Sep 17 00:00:00 2001 From: "Ronald A. Richardson" Date: Mon, 12 Oct 2026 23:17:52 +0800 Subject: [PATCH 05/11] test(api-cache): cover dependency declarations and quiet write invalidation Walk every declared relation path through the real models and check that named tables are exactly the morphTo targets' tables, so a renamed relation fails here rather than in production (fleetbase/core-api#301 throws on unresolvable entries only in debug). Check that the newly hooked models bump their table on writes, that Order::dispatch(), raw waypoint and tracking status inserts and waypoint removal invalidate, and that throttled invalidation runs once per window. The tests read the property directly and use only released core-api APIs, since CI installs the released package. --- .../Unit/Support/ApiCacheDependenciesTest.php | 234 ++++++++++++++++++ 1 file changed, 234 insertions(+) create mode 100644 server/tests/Unit/Support/ApiCacheDependenciesTest.php diff --git a/server/tests/Unit/Support/ApiCacheDependenciesTest.php b/server/tests/Unit/Support/ApiCacheDependenciesTest.php new file mode 100644 index 000000000..eeed07026 --- /dev/null +++ b/server/tests/Unit/Support/ApiCacheDependenciesTest.php @@ -0,0 +1,234 @@ + 'co-1']); +}); + +afterEach(function () { + Carbon::setTestNow(); + EloquentModel::unsetConnectionResolver(); + EloquentModel::unsetEventDispatcher(); +}); + +/** + * The model's declared dependencies, read from the property because + * getApiCacheDependencies() only exists from fleetbase/core-api#301 on. + */ +function apiCacheDeclaredDependencies(EloquentModel $model): array +{ + $property = new ReflectionProperty($model, 'apiCacheDependencies'); + $property->setAccessible(true); + + return $property->getValue($model); +} + +/** + * The relation method a dependency segment names, matched the way core does (as written, camel or snake case). + */ +function apiCacheRelationMethod(EloquentModel $model, string $segment): ?string +{ + foreach (array_unique([$segment, Str::camel($segment), Str::snake($segment)]) as $method) { + if (!method_exists($model, $method)) { + continue; + } + + $reflection = new ReflectionMethod($model, $method); + if ($reflection->isPublic() && !$reflection->isStatic() && $reflection->getNumberOfRequiredParameters() === 0) { + return $method; + } + } + + return null; +} + +function apiCacheQueryVersion(string $table, ?string $companyUuid): int +{ + return (int) Cache::get("api_query_version:{$table}:{$companyUuid}", 0); +} + +function apiCacheDatabase(): SQLiteConnection +{ + $db = TrackingPageDatabase::boot(); + $schema = $db->getSchemaBuilder(); + + // Model hooks bind to the dispatcher present when a class boots + EloquentModel::setEventDispatcher(new Dispatcher()); + EloquentModel::clearBootedModels(); + // Core models clear the HTTP response cache on writes + app()->instance('responsecache', new class { + public function __call($method, $arguments) + { + return null; + } + }); + + foreach (['fleet_drivers' => 'driver_uuid', 'fleet_vehicles' => 'vehicle_uuid'] as $table => $member) { + $schema->create($table, function ($table) use ($member) { + $table->increments('id'); + $table->string('uuid')->nullable(); + $table->string('fleet_uuid')->nullable(); + $table->string($member)->nullable(); + $table->timestamps(); + $table->softDeletes(); + }); + } + + $schema->create('asset_connections', function ($table) { + $table->increments('id'); + $table->string('uuid')->nullable(); + $table->string('public_id')->nullable(); + $table->string('company_uuid')->nullable(); + foreach (['connector_type', 'connector_uuid', 'connected_type', 'connected_uuid', 'relationship_type'] as $column) { + $table->string($column)->nullable(); + } + $table->timestamps(); + $table->softDeletes(); + }); + + $schema->table('orders', function ($table) { + $table->timestamp('dispatched_at')->nullable(); + }); + + return $db; +} + +test('declared api cache dependencies resolve to relations or to the tables of morphTo targets', function (string $modelClass, array $morphTargets) { + TrackingPageDatabase::boot(); + + $model = new $modelClass(); + $declared = apiCacheDeclaredDependencies($model); + $named = []; + + expect($declared)->not->toBeEmpty(); + + foreach ($declared as $path) { + $current = $model; + $segments = explode('.', $path); + + foreach ($segments as $segment) { + $method = apiCacheRelationMethod($current, $segment); + + // core treats a single segment naming no method as a table name + if ($method === null && count($segments) === 1) { + $named[] = $path; + continue 2; + } + + expect($method)->not->toBeNull("{$modelClass} dependency {$path} has no relation {$segment}"); + + $relation = Relation::noConstraints(fn () => $current->newInstance()->{$method}()); + + expect($relation)->toBeInstanceOf(Relation::class) + ->and($relation)->not->toBeInstanceOf(MorphTo::class); + + $current = $relation->getRelated(); + } + } + + expect($named)->toEqualCanonicalizing(array_map(fn (string $target) => (new $target())->getTable(), $morphTargets)); +})->with([ + 'order' => [Order::class, [Contact::class, Vendor::class, IntegratedVendor::class]], + 'driver' => [Driver::class, []], + 'vehicle' => [Vehicle::class, []], + 'device' => [Device::class, [Vehicle::class, Trailer::class]], + 'device event' => [DeviceEvent::class, []], + 'vendor' => [Vendor::class, []], + 'service area' => [ServiceArea::class, []], +]); + +test('models only embedded by cached lists invalidate their table on writes', function () { + $db = apiCacheDatabase(); + + $fleetDriver = FleetDriver::create(['fleet_uuid' => 'fleet-1', 'driver_uuid' => 'driver-1']); + expect(apiCacheQueryVersion('fleet_drivers', null))->toBe(1); + + $fleetDriver->delete(); + expect(apiCacheQueryVersion('fleet_drivers', null))->toBe(2); + + FleetVehicle::create(['fleet_uuid' => 'fleet-1', 'vehicle_uuid' => 'vehicle-1']); + expect(apiCacheQueryVersion('fleet_vehicles', null))->toBe(1); + + AssetConnection::create(['company_uuid' => 'co-1', 'connector_type' => Vehicle::class, 'connector_uuid' => 'vehicle-1', 'connected_type' => Trailer::class, 'connected_uuid' => 'trailer-1']); + expect(apiCacheQueryVersion('asset_connections', 'co-1'))->toBe(1); + + TrackingPageDatabase::insert($db, 'waypoints', ['uuid' => 'wp-1', 'company_uuid' => 'co-1', 'payload_uuid' => 'payload-1', 'order' => 0]); + Waypoint::where('uuid', 'wp-1')->first()->update(['order' => 1]); + expect(apiCacheQueryVersion('waypoints', 'co-1'))->toBe(1); +}); + +test('writes that skip model events invalidate the tables they write', function () { + $db = apiCacheDatabase(); + + // Order::dispatch() saves quietly + TrackingPageDatabase::insert($db, 'orders', ['uuid' => 'order-1', 'company_uuid' => 'co-1', 'dispatched' => false]); + Order::where('uuid', 'order-1')->first()->dispatch(); + expect(apiCacheQueryVersion('orders', 'co-1'))->toBe(1) + ->and($db->table('orders')->where('uuid', 'order-1')->value('dispatched'))->toBe(1); + + // Waypoint::insertGetUuid() inserts with the query builder + $waypointUuid = Waypoint::insertGetUuid(['place_uuid' => 'place-1', 'order' => 0]); + expect($waypointUuid)->toBeString() + ->and(apiCacheQueryVersion('waypoints', 'co-1'))->toBe(1); + + // Payload::removeWaypoints() deletes with the query builder + TrackingPageDatabase::insert($db, 'payloads', ['uuid' => 'payload-1', 'company_uuid' => 'co-1']); + $payload = new Payload(); + $payload->setRawAttributes(['uuid' => 'payload-1', 'company_uuid' => 'co-1'], true); + $payload->removeWaypoints(); + expect(apiCacheQueryVersion('waypoints', 'co-1'))->toBe(2); + + // TrackingStatus::insertGetUuid() inserts with the query builder + expect(TrackingStatus::insertGetUuid(['tracking_number_uuid' => 'tn-1', 'status' => 'Created']))->toBeString() + ->and(apiCacheQueryVersion('tracking_statuses', 'co-1'))->toBe(1); +}); + +test('throttled invalidation runs at most once per window and table', function () { + Carbon::setTestNow('2026-10-12 12:00:00'); + + ApiCacheThrottle::invalidate(new Vehicle(), 'co-1'); + ApiCacheThrottle::invalidate(new Vehicle(), 'co-1'); + ApiCacheThrottle::invalidate(new Driver(), 'co-1'); + + expect(apiCacheQueryVersion('vehicles', 'co-1'))->toBe(1) + ->and(apiCacheQueryVersion('drivers', 'co-1'))->toBe(1); + + Carbon::setTestNow(Carbon::now()->addSeconds(ApiCacheThrottle::LOCATION_WINDOW + 1)); + ApiCacheThrottle::invalidate(new Vehicle(), 'co-1'); + + expect(apiCacheQueryVersion('vehicles', 'co-1'))->toBe(2); +}); From 6d457f04369e62e962fcba967384e3e9cdce1b36 Mon Sep 17 00:00:00 2001 From: "Ronald A. Richardson" Date: Tue, 13 Oct 2026 06:23:02 +0800 Subject: [PATCH 06/11] fix(vehicles): deleting a vehicle unassigns its drivers instead of deleting them VehicleObserver::deleted soft-deleted every driver holding the vehicle, although its comment says it unassigns them. Clear their vehicle_uuid instead. --- server/src/Observers/VehicleObserver.php | 10 +-- server/tests/ObserverContractsTest.php | 2 +- .../Observers/ObserverListenerSeamsTest.php | 6 +- .../Observers/VehicleObserverDeleteTest.php | 67 +++++++++++++++++++ 4 files changed, 76 insertions(+), 9 deletions(-) create mode 100644 server/tests/Unit/Observers/VehicleObserverDeleteTest.php diff --git a/server/src/Observers/VehicleObserver.php b/server/src/Observers/VehicleObserver.php index 1af205e18..1c9f504e8 100644 --- a/server/src/Observers/VehicleObserver.php +++ b/server/src/Observers/VehicleObserver.php @@ -66,7 +66,7 @@ public function updating(Vehicle $vehicle) public function deleted(Vehicle $vehicle) { // Unassign the deleted vehicle from matching driver/(s) - $this->deleteDriversAssignedTo($vehicle); + $this->unassignDriversFrom($vehicle); $this->invalidateLiveCache(); } @@ -81,14 +81,14 @@ protected function findDriver(string $identifier): ?Driver return Driver::where('uuid', $identifier)->whereNull('deleted_at')->withoutGlobalScopes()->first(); } - protected function deleteDriversAssignedTo(Vehicle $vehicle): mixed + protected function unassignDriversFrom(Vehicle $vehicle): int { - $deleted = Driver::where(['vehicle_uuid' => $vehicle->uuid])->delete(); - if ($deleted) { + $unassigned = Driver::where(['vehicle_uuid' => $vehicle->uuid])->update(['vehicle_uuid' => null]); + if ($unassigned) { Driver::invalidateApiCacheManually($vehicle->company_uuid); } - return $deleted; + return $unassigned; } protected function invalidateLiveCache(): void diff --git a/server/tests/ObserverContractsTest.php b/server/tests/ObserverContractsTest.php index d4e4f98c5..3a048ea3e 100644 --- a/server/tests/ObserverContractsTest.php +++ b/server/tests/ObserverContractsTest.php @@ -286,7 +286,7 @@ protected function findDriver(string $identifier): ?Driver return $this->driver && $identifier === $this->identifier ? $this->driver : null; } - protected function deleteDriversAssignedTo(Vehicle $vehicle): mixed + protected function unassignDriversFrom(Vehicle $vehicle): int { $this->deleted[] = $vehicle->uuid; diff --git a/server/tests/Unit/Observers/ObserverListenerSeamsTest.php b/server/tests/Unit/Observers/ObserverListenerSeamsTest.php index 03223f571..48e7277ea 100644 --- a/server/tests/Unit/Observers/ObserverListenerSeamsTest.php +++ b/server/tests/Unit/Observers/ObserverListenerSeamsTest.php @@ -142,12 +142,12 @@ function fleetopsObserverSeamInvoke(string $class, string $method, array $argume expect(fleetopsObserverSeamInvoke(Fleetbase\FleetOps\Observers\VehicleObserver::class, 'findDriver', ['driver-veh-1'])?->uuid)->toBe('driver-veh-1') ->and(fleetopsObserverSeamInvoke(Fleetbase\FleetOps\Observers\VehicleObserver::class, 'findDriver', ['driver-missing']))->toBeNull(); - // Deleting a vehicle releases only the drivers assigned to it + // Deleting a vehicle unassigns only the drivers assigned to it, and keeps them $vehicle = new Vehicle(); $vehicle->setRawAttributes(['uuid' => 'vehicle-seam-1'], true); - fleetopsObserverSeamInvoke(Fleetbase\FleetOps\Observers\VehicleObserver::class, 'deleteDriversAssignedTo', [$vehicle]); + fleetopsObserverSeamInvoke(Fleetbase\FleetOps\Observers\VehicleObserver::class, 'unassignDriversFrom', [$vehicle]); - expect($connection->table('drivers')->whereNull('deleted_at')->pluck('uuid')->all())->toBe(['driver-veh-2']); + expect($connection->table('drivers')->whereNull('deleted_at')->pluck('vehicle_uuid', 'uuid')->all())->toBe(['driver-veh-1' => null, 'driver-veh-2' => 'vehicle-other']); }); test('fleet observer clears the parent reference from child fleets', function () { diff --git a/server/tests/Unit/Observers/VehicleObserverDeleteTest.php b/server/tests/Unit/Observers/VehicleObserverDeleteTest.php new file mode 100644 index 000000000..1fbf7a3fa --- /dev/null +++ b/server/tests/Unit/Observers/VehicleObserverDeleteTest.php @@ -0,0 +1,67 @@ + 'co-1']); +}); + +afterEach(function () { + EloquentModel::unsetConnectionResolver(); +}); + +function vehicleObserverDeleteDatabase(): SQLiteConnection +{ + $db = TrackingPageDatabase::boot(); + + $db->getSchemaBuilder()->create('users', function ($table) { + $table->increments('id'); + $table->string('uuid')->nullable(); + $table->string('company_uuid')->nullable(); + $table->timestamps(); + $table->softDeletes(); + }); + $db->getSchemaBuilder()->create('drivers', function ($table) { + $table->increments('id'); + $table->string('uuid')->nullable(); + $table->string('company_uuid')->nullable(); + $table->string('user_uuid')->nullable(); + $table->string('vehicle_uuid')->nullable(); + $table->timestamps(); + $table->softDeletes(); + }); + + foreach (['user-1', 'user-2', 'user-3'] as $user) { + TrackingPageDatabase::insert($db, 'users', ['uuid' => $user, 'company_uuid' => 'co-1']); + } + TrackingPageDatabase::insert($db, 'drivers', ['uuid' => 'driver-1', 'company_uuid' => 'co-1', 'user_uuid' => 'user-1', 'vehicle_uuid' => 'vehicle-1']); + TrackingPageDatabase::insert($db, 'drivers', ['uuid' => 'driver-2', 'company_uuid' => 'co-1', 'user_uuid' => 'user-2', 'vehicle_uuid' => 'vehicle-1']); + TrackingPageDatabase::insert($db, 'drivers', ['uuid' => 'driver-3', 'company_uuid' => 'co-1', 'user_uuid' => 'user-3', 'vehicle_uuid' => 'vehicle-2']); + + return $db; +} + +test('deleting a vehicle unassigns its drivers without deleting them', function () { + $db = vehicleObserverDeleteDatabase(); + + $vehicle = new Vehicle(); + $vehicle->setRawAttributes(['uuid' => 'vehicle-1', 'company_uuid' => 'co-1'], true); + + (new VehicleObserver())->deleted($vehicle); + + $drivers = $db->table('drivers')->orderBy('uuid')->get(['uuid', 'vehicle_uuid', 'deleted_at'])->map(fn ($row) => (array) $row)->all(); + + expect($drivers)->toBe([ + ['uuid' => 'driver-1', 'vehicle_uuid' => null, 'deleted_at' => null], + ['uuid' => 'driver-2', 'vehicle_uuid' => null, 'deleted_at' => null], + ['uuid' => 'driver-3', 'vehicle_uuid' => 'vehicle-2', 'deleted_at' => null], + ]); +}); From 934104fd13dba45470e35c0435a8cf98f5847414 Mon Sep 17 00:00:00 2001 From: "Ronald A. Richardson" Date: Tue, 13 Oct 2026 06:23:27 +0800 Subject: [PATCH 07/11] fix(drivers): assigning a driver the vehicle it already holds keeps it assignVehicle cleared vehicle_uuid on every driver holding the vehicle, this driver included, then saved a model that was not dirty, so the cleared value stayed. Skip this driver when unassigning the others. --- server/src/Models/Driver.php | 7 +- .../Unit/Models/DriverAssignVehicleTest.php | 81 +++++++++++++++++++ 2 files changed, 86 insertions(+), 2 deletions(-) create mode 100644 server/tests/Unit/Models/DriverAssignVehicleTest.php diff --git a/server/src/Models/Driver.php b/server/src/Models/Driver.php index f445a0006..5d1b23273 100644 --- a/server/src/Models/Driver.php +++ b/server/src/Models/Driver.php @@ -681,8 +681,11 @@ public function unassignCurrentJob(): bool */ public function assignVehicle(Vehicle $vehicle): self { - // Unassign vehicle from other drivers - static::where('vehicle_uuid', $vehicle->uuid)->update(['vehicle_uuid' => null]); + // Unassign vehicle from other drivers. This driver is skipped: when it already holds the + // vehicle the save below isn't dirty, so it would not write the vehicle back + static::where('vehicle_uuid', $vehicle->uuid) + ->when($this->uuid, fn ($query) => $query->where('uuid', '!=', $this->uuid)) + ->update(['vehicle_uuid' => null]); static::invalidateApiCacheManually($vehicle->company_uuid ?? $this->company_uuid); // Set this vehicle to the driver instance diff --git a/server/tests/Unit/Models/DriverAssignVehicleTest.php b/server/tests/Unit/Models/DriverAssignVehicleTest.php new file mode 100644 index 000000000..640878f9d --- /dev/null +++ b/server/tests/Unit/Models/DriverAssignVehicleTest.php @@ -0,0 +1,81 @@ + 'co-1']); +}); + +afterEach(function () { + EloquentModel::unsetConnectionResolver(); +}); + +function driverAssignVehicleDatabase(): SQLiteConnection +{ + $db = TrackingPageDatabase::boot(); + + $db->getSchemaBuilder()->create('users', function ($table) { + $table->increments('id'); + $table->string('uuid')->nullable(); + $table->string('company_uuid')->nullable(); + $table->timestamps(); + $table->softDeletes(); + }); + $db->getSchemaBuilder()->create('drivers', function ($table) { + $table->increments('id'); + $table->string('uuid')->nullable(); + $table->string('public_id')->nullable(); + $table->string('company_uuid')->nullable(); + $table->string('user_uuid')->nullable(); + $table->string('vehicle_uuid')->nullable(); + $table->timestamps(); + $table->softDeletes(); + }); + + foreach (['user-1', 'user-2'] as $user) { + TrackingPageDatabase::insert($db, 'users', ['uuid' => $user, 'company_uuid' => 'co-1']); + } + TrackingPageDatabase::insert($db, 'drivers', ['uuid' => 'driver-1', 'company_uuid' => 'co-1', 'user_uuid' => 'user-1', 'vehicle_uuid' => 'vehicle-1']); + TrackingPageDatabase::insert($db, 'drivers', ['uuid' => 'driver-2', 'company_uuid' => 'co-1', 'user_uuid' => 'user-2']); + + return $db; +} + +function driverAssignVehicleVehicle(): Vehicle +{ + $vehicle = new Vehicle(); + $vehicle->setRawAttributes(['uuid' => 'vehicle-1', 'company_uuid' => 'co-1'], true); + $vehicle->exists = true; + + return $vehicle; +} + +function driverAssignVehicleAssignments(SQLiteConnection $db): array +{ + return $db->table('drivers')->orderBy('uuid')->pluck('vehicle_uuid', 'uuid')->all(); +} + +test('assigning a driver the vehicle it already has keeps the assignment', function () { + $db = driverAssignVehicleDatabase(); + + Driver::where('uuid', 'driver-1')->first()->assignVehicle(driverAssignVehicleVehicle()); + + expect(driverAssignVehicleAssignments($db))->toBe(['driver-1' => 'vehicle-1', 'driver-2' => null]); +}); + +test('assigning a vehicle to another driver moves it from its current driver', function () { + $db = driverAssignVehicleDatabase(); + + $driver = Driver::where('uuid', 'driver-2')->first()->assignVehicle(driverAssignVehicleVehicle()); + + expect(driverAssignVehicleAssignments($db))->toBe(['driver-1' => null, 'driver-2' => 'vehicle-1']) + ->and($driver->vehicle->uuid)->toBe('vehicle-1'); +}); From aee826574c4e811ff548a3a830c7465bb9172b07 Mon Sep 17 00:00:00 2001 From: "Ronald A. Richardson" Date: Tue, 13 Oct 2026 06:24:37 +0800 Subject: [PATCH 08/11] fix(drivers): a company membership change only touches that company's driver CompanyUserObserver matched drivers by user alone, so removing a user from one organization deleted their driver profiles in every organization, and a status change could land on another organization's driver. Match on the membership's company too, as HandleUserRemovedFromCompany already does. --- server/src/Observers/CompanyUserObserver.php | 17 ++++++++-------- server/tests/ObserverContractsTest.php | 4 ++-- ...CompanyUserAndServiceAreaObserversTest.php | 20 +++++++++++++++++++ 3 files changed, 30 insertions(+), 11 deletions(-) diff --git a/server/src/Observers/CompanyUserObserver.php b/server/src/Observers/CompanyUserObserver.php index 378c0fe37..1b5832485 100644 --- a/server/src/Observers/CompanyUserObserver.php +++ b/server/src/Observers/CompanyUserObserver.php @@ -14,8 +14,8 @@ class CompanyUserObserver */ public function deleted(CompanyUser $companyUser) { - // if the company user deleted is a driver, delete their driver record to - $this->deleteDrivers($companyUser->user_uuid); + // if the company user deleted is a driver, delete their driver record in that company + $this->deleteDrivers($companyUser->user_uuid, $companyUser->company_uuid); } /** @@ -26,22 +26,21 @@ public function deleted(CompanyUser $companyUser) public function updated(CompanyUser $companyUser) { // If the company user has any driver assosciated update status to same as company_users - $driver = $this->findDriver($companyUser->user_uuid); + $driver = $this->findDriver($companyUser->user_uuid, $companyUser->company_uuid); if ($driver && $companyUser->wasChanged('status')) { $driver->update(['status' => $companyUser->status]); } } - protected function deleteDrivers(string $userUuid): void + protected function deleteDrivers(string $userUuid, ?string $companyUuid): void { - // A user's drivers can belong to several companies, so this invalidates every company's driver lists - if (Driver::where('user_uuid', $userUuid)->delete()) { - Driver::invalidateApiCacheManually(); + if (Driver::where(['user_uuid' => $userUuid, 'company_uuid' => $companyUuid])->delete()) { + Driver::invalidateApiCacheManually($companyUuid); } } - protected function findDriver(string $userUuid): ?Driver + protected function findDriver(string $userUuid, ?string $companyUuid): ?Driver { - return Driver::where('user_uuid', $userUuid)->first(); + return Driver::where(['user_uuid' => $userUuid, 'company_uuid' => $companyUuid])->first(); } } diff --git a/server/tests/ObserverContractsTest.php b/server/tests/ObserverContractsTest.php index 3a048ea3e..015a004ac 100644 --- a/server/tests/ObserverContractsTest.php +++ b/server/tests/ObserverContractsTest.php @@ -536,12 +536,12 @@ class FleetOpsCompanyUserObserverProbe extends CompanyUserObserver public array $deleted = []; public ?Driver $driver = null; - protected function deleteDrivers(string $userUuid): void + protected function deleteDrivers(string $userUuid, ?string $companyUuid): void { $this->deleted[] = $userUuid; } - protected function findDriver(string $userUuid): ?Driver + protected function findDriver(string $userUuid, ?string $companyUuid): ?Driver { return $this->driver; } diff --git a/server/tests/Unit/Observers/FleetOpsCompanyUserAndServiceAreaObserversTest.php b/server/tests/Unit/Observers/FleetOpsCompanyUserAndServiceAreaObserversTest.php index 30c7f5aab..b993795c2 100644 --- a/server/tests/Unit/Observers/FleetOpsCompanyUserAndServiceAreaObserversTest.php +++ b/server/tests/Unit/Observers/FleetOpsCompanyUserAndServiceAreaObserversTest.php @@ -104,6 +104,26 @@ function fleetopsObserversCompanyUser(array $attributes = []): CompanyUser expect($connection->table('drivers')->value('status'))->toBe('suspended'); }); +test('company user changes only touch the driver profile in that company', function () { + $connection = fleetopsObserversBoot(); + $connection->table('users')->insert(['uuid' => 'user-1', 'company_uuid' => 'company-1']); + $connection->table('drivers')->insert([ + ['uuid' => 'driver-other', 'company_uuid' => 'company-2', 'user_uuid' => 'user-1', 'status' => 'active'], + ['uuid' => 'driver-1', 'company_uuid' => 'company-1', 'user_uuid' => 'user-1', 'status' => 'active'], + ]); + + $companyUser = fleetopsObserversCompanyUser(); + $companyUser->status = 'suspended'; + $companyUser->syncChanges(); + (new CompanyUserObserver())->updated($companyUser); + + expect($connection->table('drivers')->pluck('status', 'uuid')->all())->toBe(['driver-other' => 'active', 'driver-1' => 'suspended']); + + (new CompanyUserObserver())->deleted($companyUser); + + expect($connection->table('drivers')->whereNull('deleted_at')->pluck('uuid')->all())->toBe(['driver-other']); +}); + test('service area deletion cascades to zones and border derives from country', function () { $connection = fleetopsObserversBoot(); $connection->table('zones')->insert(['uuid' => 'zone-1', 'company_uuid' => 'company-1', 'service_area_uuid' => 'sa-1', 'name' => 'Zone 1']); From 866d96459fb0a429a8d6646d9128cae2a7db7be1 Mon Sep 17 00:00:00 2001 From: "Ronald A. Richardson" Date: Tue, 13 Oct 2026 06:27:15 +0800 Subject: [PATCH 09/11] fix(drivers): delete drivers through the model when their user or membership goes UserObserver, CompanyUserObserver and HandleUserRemovedFromCompany deleted drivers with a query builder delete, so DriverObserver never ran: orders stayed assigned to the deleted driver and a managed login account was never released. Delete each driver through the model instead; its deleted event also invalidates the API cache, so the manual invalidation goes. --- .../HandleUserRemovedFromCompany.php | 3 +- server/src/Observers/CompanyUserObserver.php | 5 +- server/src/Observers/UserObserver.php | 6 +- server/tests/EventContractsTest.php | 5 + .../HandleUserRemovedFromCompanyTest.php | 5 + .../Observers/DriverDeletionCleanupTest.php | 158 ++++++++++++++++++ 6 files changed, 174 insertions(+), 8 deletions(-) create mode 100644 server/tests/Unit/Observers/DriverDeletionCleanupTest.php diff --git a/server/src/Listeners/HandleUserRemovedFromCompany.php b/server/src/Listeners/HandleUserRemovedFromCompany.php index 8fbaacb47..1be031760 100644 --- a/server/src/Listeners/HandleUserRemovedFromCompany.php +++ b/server/src/Listeners/HandleUserRemovedFromCompany.php @@ -26,7 +26,8 @@ public function handle(UserRemovedFromCompany $event) protected function deleteDriversForCompanyUser(string $companyUuid, string $userUuid): void { - $this->driverQueryForCompanyUser($companyUuid, $userUuid)->delete(); + // Delete each driver through the model so DriverObserver cleans up after it + $this->driverQueryForCompanyUser($companyUuid, $userUuid)->get()->each->delete(); } protected function driverQueryForCompanyUser(string $companyUuid, string $userUuid): mixed diff --git a/server/src/Observers/CompanyUserObserver.php b/server/src/Observers/CompanyUserObserver.php index 1b5832485..7998d7586 100644 --- a/server/src/Observers/CompanyUserObserver.php +++ b/server/src/Observers/CompanyUserObserver.php @@ -34,9 +34,8 @@ public function updated(CompanyUser $companyUser) protected function deleteDrivers(string $userUuid, ?string $companyUuid): void { - if (Driver::where(['user_uuid' => $userUuid, 'company_uuid' => $companyUuid])->delete()) { - Driver::invalidateApiCacheManually($companyUuid); - } + // Delete each driver through the model so DriverObserver cleans up after it + Driver::where(['user_uuid' => $userUuid, 'company_uuid' => $companyUuid])->get()->each->delete(); } protected function findDriver(string $userUuid, ?string $companyUuid): ?Driver diff --git a/server/src/Observers/UserObserver.php b/server/src/Observers/UserObserver.php index 7c7a3d8b9..21bebc6f5 100644 --- a/server/src/Observers/UserObserver.php +++ b/server/src/Observers/UserObserver.php @@ -20,9 +20,7 @@ public function deleted(User $user) protected function deleteDrivers(string $userUuid): void { - // A user's drivers can belong to several companies, so this invalidates every company's driver lists - if (Driver::where('user_uuid', $userUuid)->delete()) { - Driver::invalidateApiCacheManually(); - } + // Delete each driver through the model so DriverObserver cleans up after it + Driver::where('user_uuid', $userUuid)->get()->each->delete(); } } diff --git a/server/tests/EventContractsTest.php b/server/tests/EventContractsTest.php index 044b758de..78c4ccf61 100644 --- a/server/tests/EventContractsTest.php +++ b/server/tests/EventContractsTest.php @@ -233,6 +233,11 @@ class FleetOpsUserRemovedDriverDeleteQueryFake { public bool $deleted = false; + public function get(): Illuminate\Support\Collection + { + return collect([$this]); + } + public function delete(): void { $this->deleted = true; diff --git a/server/tests/Unit/Listeners/HandleUserRemovedFromCompanyTest.php b/server/tests/Unit/Listeners/HandleUserRemovedFromCompanyTest.php index 8d9c2591a..b730384eb 100644 --- a/server/tests/Unit/Listeners/HandleUserRemovedFromCompanyTest.php +++ b/server/tests/Unit/Listeners/HandleUserRemovedFromCompanyTest.php @@ -21,6 +21,11 @@ class FleetOpsUserRemovedCompanyDriverQueryFake { public bool $deleted = false; + public function get(): Illuminate\Support\Collection + { + return collect([$this]); + } + public function delete(): void { $this->deleted = true; diff --git a/server/tests/Unit/Observers/DriverDeletionCleanupTest.php b/server/tests/Unit/Observers/DriverDeletionCleanupTest.php new file mode 100644 index 000000000..7c632d474 --- /dev/null +++ b/server/tests/Unit/Observers/DriverDeletionCleanupTest.php @@ -0,0 +1,158 @@ + str_replace('_', ' ', Illuminate\Support\Str::snake((string) $value))); +} + +use Fleetbase\Events\UserRemovedFromCompany; +use Fleetbase\FleetOps\Listeners\HandleUserRemovedFromCompany; +use Fleetbase\FleetOps\Models\Driver; +use Fleetbase\FleetOps\Observers\CompanyUserObserver; +use Fleetbase\FleetOps\Observers\DriverObserver; +use Fleetbase\FleetOps\Observers\UserObserver; +use Fleetbase\Models\Company; +use Fleetbase\Models\CompanyUser; +use Fleetbase\Models\User; +use Fleetbase\Tests\Support\TrackingPageDatabase; +use Illuminate\Cache\ArrayStore; +use Illuminate\Cache\Repository; +use Illuminate\Database\Eloquent\Model as EloquentModel; +use Illuminate\Database\SQLiteConnection; +use Illuminate\Events\Dispatcher; +use Illuminate\Support\Facades\Cache; + +/** + * Drivers removed along with their user or company membership go through + * Driver::delete(), so DriverObserver unassigns their orders and releases + * their managed login account. + */ +beforeEach(function () { + Cache::swap(new Repository(new ArrayStore())); + session(['company' => 'co-1']); +}); + +afterEach(function () { + EloquentModel::unsetConnectionResolver(); + EloquentModel::unsetEventDispatcher(); +}); + +function driverDeletionCleanupDatabase(): SQLiteConnection +{ + $db = TrackingPageDatabase::boot(); + $schema = $db->getSchemaBuilder(); + + $schema->create('users', function ($table) { + $table->increments('id'); + $table->string('uuid')->nullable(); + $table->string('public_id')->nullable(); + $table->string('company_uuid')->nullable(); + $table->string('type')->nullable(); + $table->timestamps(); + $table->softDeletes(); + }); + $schema->create('company_users', function ($table) { + $table->increments('id'); + $table->string('uuid')->nullable(); + $table->string('company_uuid')->nullable(); + $table->string('user_uuid')->nullable(); + $table->string('status')->nullable(); + $table->timestamps(); + $table->softDeletes(); + }); + $schema->create('drivers', function ($table) { + $table->increments('id'); + $table->string('uuid')->nullable(); + $table->string('public_id')->nullable(); + $table->string('company_uuid')->nullable(); + $table->string('user_uuid')->nullable(); + $table->string('vehicle_uuid')->nullable(); + $table->timestamps(); + $table->softDeletes(); + }); + + // Model hooks bind to the dispatcher present when a class boots + EloquentModel::setEventDispatcher(new Dispatcher()); + EloquentModel::clearBootedModels(); + Driver::observe(DriverObserver::class); + // Core models clear the HTTP response cache on writes, and log activity + app()->instance('responsecache', new class { + public function __call($method, $arguments) + { + return null; + } + }); + config()->set('activitylog.enabled', false); + app()->bind(Illuminate\Contracts\Config\Repository::class, fn () => config()); + + TrackingPageDatabase::insert($db, 'users', ['uuid' => 'user-1', 'type' => 'driver']); + TrackingPageDatabase::insert($db, 'users', ['uuid' => 'user-2', 'type' => 'driver']); + TrackingPageDatabase::insert($db, 'company_users', ['uuid' => 'cu-1', 'company_uuid' => 'co-1', 'user_uuid' => 'user-1', 'status' => 'active']); + TrackingPageDatabase::insert($db, 'drivers', ['uuid' => 'driver-1', 'company_uuid' => 'co-1', 'user_uuid' => 'user-1']); + TrackingPageDatabase::insert($db, 'drivers', ['uuid' => 'driver-2', 'company_uuid' => 'co-1', 'user_uuid' => 'user-2']); + TrackingPageDatabase::insert($db, 'orders', ['uuid' => 'order-1', 'company_uuid' => 'co-1', 'driver_assigned_uuid' => 'driver-1']); + TrackingPageDatabase::insert($db, 'orders', ['uuid' => 'order-2', 'company_uuid' => 'co-1', 'driver_assigned_uuid' => 'driver-2']); + + return $db; +} + +function driverDeletionCleanupState(SQLiteConnection $db): array +{ + return [ + 'drivers' => $db->table('drivers')->whereNull('deleted_at')->orderBy('uuid')->pluck('uuid')->all(), + 'orders' => $db->table('orders')->orderBy('uuid')->pluck('driver_assigned_uuid', 'uuid')->all(), + 'users' => $db->table('users')->whereNull('deleted_at')->orderBy('uuid')->pluck('uuid')->all(), + ]; +} + +test('deleting a user deletes their drivers through the model so their orders are unassigned', function () { + $db = driverDeletionCleanupDatabase(); + TrackingPageDatabase::insert($db, 'drivers', ['uuid' => 'driver-1-other', 'company_uuid' => 'co-2', 'user_uuid' => 'user-1']); + + // The user is already soft-deleted when its deleted event fires + $db->table('users')->where('uuid', 'user-1')->update(['deleted_at' => now()]); + $user = User::withTrashed()->where('uuid', 'user-1')->first(); + + (new UserObserver())->deleted($user); + + expect(driverDeletionCleanupState($db))->toBe([ + 'drivers' => ['driver-2'], + 'orders' => ['order-1' => null, 'order-2' => 'driver-2'], + 'users' => ['user-2'], + ]); +}); + +test('deleting a company user deletes its driver through the model and releases the managed account', function () { + $db = driverDeletionCleanupDatabase(); + + $companyUser = CompanyUser::where('uuid', 'cu-1')->first(); + $companyUser->delete(); + + (new CompanyUserObserver())->deleted($companyUser); + + expect(driverDeletionCleanupState($db))->toBe([ + 'drivers' => ['driver-2'], + 'orders' => ['order-1' => null, 'order-2' => 'driver-2'], + 'users' => ['user-2'], + ]); +}); + +test('removing a user from a company deletes its driver through the model', function () { + $db = driverDeletionCleanupDatabase(); + + $company = new Company(); + $company->setRawAttributes(['uuid' => 'co-1'], true); + + (new HandleUserRemovedFromCompany())->handle(new UserRemovedFromCompany(User::where('uuid', 'user-1')->first(), $company)); + + expect(driverDeletionCleanupState($db))->toBe([ + 'drivers' => ['driver-2'], + 'orders' => ['order-1' => null, 'order-2' => 'driver-2'], + 'users' => ['user-2'], + ]); +}); From ab5ee3a716e0d8bdab971b2a63ade891b8aafee0 Mon Sep 17 00:00:00 2001 From: "Ronald A. Richardson" Date: Tue, 13 Oct 2026 06:27:58 +0800 Subject: [PATCH 10/11] fix(orders): index latest_status is the newest tracking status The index resource took the first loaded tracking status as the latest, but the relation has no order, so lists usually reported the oldest one (Created). Pick the newest by created_at, then id. --- server/src/Http/Resources/v1/Index/Order.php | 13 ++++++-- .../Http/Resources/IndexOrderResourceTest.php | 30 +++++++++++++++++++ 2 files changed, 41 insertions(+), 2 deletions(-) diff --git a/server/src/Http/Resources/v1/Index/Order.php b/server/src/Http/Resources/v1/Index/Order.php index 9c8ed0b8a..1b413f22d 100644 --- a/server/src/Http/Resources/v1/Index/Order.php +++ b/server/src/Http/Resources/v1/Index/Order.php @@ -94,12 +94,12 @@ public function toArray($request): array // Latest status only, not full array 'latest_status' => $this->whenLoaded('trackingStatuses', function () { - $latest = $this->trackingStatuses->first(); + $latest = $this->latestTrackingStatus(); return $latest ? $latest->status : 'created'; }), 'latest_status_code' => $this->whenLoaded('trackingStatuses', function () { - $latest = $this->trackingStatuses->first(); + $latest = $this->latestTrackingStatus(); return $latest ? $latest->code : null; }), @@ -127,4 +127,13 @@ public function toArray($request): array ], ]; } + + /** + * The newest loaded tracking status. The relation has no order, so the + * statuses are sorted here, newest insert first within the same second. + */ + protected function latestTrackingStatus(): mixed + { + return $this->trackingStatuses->sortBy([['created_at', 'desc'], ['id', 'desc']])->first(); + } } diff --git a/server/tests/Unit/Http/Resources/IndexOrderResourceTest.php b/server/tests/Unit/Http/Resources/IndexOrderResourceTest.php index b3a1ba0d4..bf002f442 100644 --- a/server/tests/Unit/Http/Resources/IndexOrderResourceTest.php +++ b/server/tests/Unit/Http/Resources/IndexOrderResourceTest.php @@ -6,6 +6,7 @@ use Fleetbase\FleetOps\Models\Order; use Fleetbase\FleetOps\Models\Payload; use Fleetbase\FleetOps\Models\TrackingNumber; +use Fleetbase\FleetOps\Models\TrackingStatus; use Fleetbase\FleetOps\Models\Vehicle; use Fleetbase\FleetOps\Models\Vendor; use Illuminate\Http\Request; @@ -83,6 +84,35 @@ function fleetopsIndexOrderResourceOrder(): Order ->and($resolved['meta'])->toBe(['_index_resource' => true]); }); +test('index order resource reports the newest tracking status whatever order they load in', function () { + $connection = new Illuminate\Database\SQLiteConnection(new PDO('sqlite::memory:')); + $resolver = new Illuminate\Database\ConnectionResolver(['default' => $connection, 'mysql' => $connection]); + $resolver->setDefaultConnection('mysql'); + Illuminate\Database\Eloquent\Model::setConnectionResolver($resolver); + app()->instance('request', Request::create('/v1/orders', 'GET')); + session(['company' => 'company-1']); + + $status = function (int $id, string $code, string $createdAt) { + $status = new TrackingStatus(); + $status->setRawAttributes(['id' => $id, 'uuid' => "ts-{$code}", 'status' => ucfirst($code), 'code' => $code, 'created_at' => $createdAt], true); + + return $status; + }; + + // The relation has no order, so statuses arrive oldest first; two share a second + $order = fleetopsIndexOrderResourceOrder(); + $order->setRelation('trackingStatuses', collect([ + $status(1, 'created', '2026-10-13 09:00:00'), + $status(2, 'dispatched', '2026-10-13 09:05:00'), + $status(3, 'started', '2026-10-13 09:05:00'), + ])); + + $resolved = (new IndexOrderResource($order))->resolve(Request::create('/v1/orders', 'GET')); + + expect($resolved['latest_status'])->toBe('Started') + ->and($resolved['latest_status_code'])->toBe('started'); +}); + /** * The index Payload resource only emits pickup/dropoff when the payload can * actually produce a place. Both accessors short-circuit on a directly related From aa1e7b30670091a92a9567edb759d64ce0713071 Mon Sep 17 00:00:00 2001 From: "Ronald A. Richardson" Date: Tue, 13 Oct 2026 06:28:36 +0800 Subject: [PATCH 11/11] fix(contacts): the address filter searches the contact's place ContactFilter::address queried an addresses relation that Contact does not have, so filtering contacts by address threw. Search the contact's place, the address the contacts list shows. --- server/src/Http/Filter/ContactFilter.php | 2 +- .../tests/ControllerFilterContractsTest.php | 2 +- .../Http/Filter/ContactFilterAddressTest.php | 61 +++++++++++++++++++ 3 files changed, 63 insertions(+), 2 deletions(-) create mode 100644 server/tests/Unit/Http/Filter/ContactFilterAddressTest.php diff --git a/server/src/Http/Filter/ContactFilter.php b/server/src/Http/Filter/ContactFilter.php index f609ba6ab..ca8cdbeea 100644 --- a/server/src/Http/Filter/ContactFilter.php +++ b/server/src/Http/Filter/ContactFilter.php @@ -64,7 +64,7 @@ public function phone(?string $phone) public function address(?string $address) { $this->builder->whereHas( - 'addresses', + 'place', function ($query) use ($address) { $query->search($address); } diff --git a/server/tests/ControllerFilterContractsTest.php b/server/tests/ControllerFilterContractsTest.php index de97fb955..ceefacb47 100644 --- a/server/tests/ControllerFilterContractsTest.php +++ b/server/tests/ControllerFilterContractsTest.php @@ -1152,7 +1152,7 @@ public function get(string $key): ?string ->and(collect($query->calls)->where(0, 'whereBetween')->values())->toHaveCount(1) ->and(collect($query->calls)->where(0, 'whereDate')->values())->toHaveCount(1); - $address = collect($query->calls)->first(fn ($call) => $call[0] === 'whereHas' && $call[1] === 'addresses'); + $address = collect($query->calls)->first(fn ($call) => $call[0] === 'whereHas' && $call[1] === 'place'); expect($address[2])->toBe([ ['search', '1 Test Street'], diff --git a/server/tests/Unit/Http/Filter/ContactFilterAddressTest.php b/server/tests/Unit/Http/Filter/ContactFilterAddressTest.php new file mode 100644 index 000000000..deb89a149 --- /dev/null +++ b/server/tests/Unit/Http/Filter/ContactFilterAddressTest.php @@ -0,0 +1,61 @@ + $connection, 'mysql' => $connection]); + $resolver->setDefaultConnection('mysql'); + EloquentModel::setConnectionResolver($resolver); + + $tables = [ + 'contacts' => ['uuid', 'public_id', 'company_uuid', 'place_uuid', 'name'], + 'places' => ['uuid', 'public_id', 'company_uuid', 'owner_uuid', 'name', 'street1', 'street2', 'country', 'province', 'district', 'city', 'postal_code', 'phone'], + ]; + foreach ($tables as $table => $columns) { + $connection->getSchemaBuilder()->create($table, function ($blueprint) use ($columns) { + $blueprint->increments('id'); + foreach ($columns as $column) { + $blueprint->string($column)->nullable(); + } + $blueprint->timestamps(); + $blueprint->timestamp('deleted_at')->nullable(); + }); + } + + $connection->table('places')->insert([ + ['uuid' => 'place-1', 'company_uuid' => 'co-1', 'street1' => '1 Harbour Road', 'city' => 'Singapore'], + ['uuid' => 'place-2', 'company_uuid' => 'co-1', 'street1' => '9 Orchard Lane', 'city' => 'Singapore'], + ]); + $connection->table('contacts')->insert([ + ['uuid' => 'contact-1', 'company_uuid' => 'co-1', 'place_uuid' => 'place-1', 'name' => 'Harbour Contact'], + ['uuid' => 'contact-2', 'company_uuid' => 'co-1', 'place_uuid' => 'place-2', 'name' => 'Orchard Contact'], + ['uuid' => 'contact-3', 'company_uuid' => 'co-1', 'place_uuid' => null, 'name' => 'Placeless Contact'], + ]); + + return $connection; +} + +test('the contact address filter matches the address the contact list shows', function () { + contactFilterAddressBoot(); + + $builder = Contact::query(); + $filter = (new ReflectionClass(ContactFilter::class))->newInstanceWithoutConstructor(); + $property = new ReflectionProperty(Filter::class, 'builder'); + $property->setAccessible(true); + $property->setValue($filter, $builder); + + $filter->address('harbour'); + + expect($builder->pluck('uuid')->all())->toBe(['contact-1']); +});