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/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/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/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/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/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/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/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/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/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/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/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/Device.php b/server/src/Models/Device.php index 06feb64f0..d38ecf3c4 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. * @@ -453,6 +465,10 @@ public function attachTo(Model $attachable): bool }); if ($updated) { + // Cached vehicle and trailer lists filter on attached devices without depending on + // the device table, which telemetry writes on every ping + static::invalidateAttachableApiCache($attachable, $this->company_uuid); + activity('device_attached') ->performedOn($this) ->withProperties([ @@ -466,6 +482,14 @@ public function attachTo(Model $attachable): bool return $updated; } + /** + * Invalidate the cached lists of the attached model's table (vehicles or trailers). + */ + protected static function invalidateAttachableApiCache(\Illuminate\Database\Eloquent\Model $attachable, ?string $companyUuid): void + { + \Fleetbase\Support\ApiModelCache::invalidateModelCache($attachable, $companyUuid); + } + /** * Detach the device from its current attachment. */ @@ -483,6 +507,10 @@ public function detach(): bool return $this->update(['attachable_type' => null, 'attachable_uuid' => null]); }); + if ($updated && $oldAttachableType && is_a($oldAttachableType, \Illuminate\Database\Eloquent\Model::class, true)) { + static::invalidateAttachableApiCache(new $oldAttachableType(), $this->company_uuid); + } + if ($updated) { activity('device_detached') ->performedOn($this) 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..f445a0006 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. * @@ -672,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/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..f2e306ae5 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. * @@ -1059,6 +1091,7 @@ public function setRoute(Route|array|null $attributes = []) $route->save(); $this->updateQuietly(['route_uuid' => $route->uuid]); + $this->invalidateApiCache(); return $this; } @@ -1194,6 +1227,7 @@ public function attachPurchaseRate(PurchaseRate $purchaseRate): bool ]); if ($attached) { + $this->invalidateApiCache(); $this->setRelation('purchaseRate', $purchaseRate); if ($purchaseRate->transaction_uuid) { @@ -1256,6 +1290,7 @@ public function createOrderTransactionWithoutServiceQuote(): ?Transaction ]); $this->updateQuietly(['transaction_uuid' => $transaction->uuid]); + $this->invalidateApiCache(); } catch (\Throwable $e) { } @@ -1295,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(); @@ -1782,6 +1818,7 @@ public function setPreliminaryDistanceAndTime() $matrix = Utils::getPreliminaryDistanceMatrix($origin, $destination); $this->updateQuietly(['distance' => $matrix->distance, 'time' => $matrix->time]); + $this->invalidateApiCache(); return $this; } @@ -1819,6 +1856,7 @@ public function setDistanceAndTime(array $options = []): self 'distance' => $matrix->distance, 'time' => $matrix->time, ]); + $this->invalidateApiCache(); } return $this; 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/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/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 250b440d9..5819d2891 100644 --- a/server/src/Models/Vehicle.php +++ b/server/src/Models/Vehicle.php @@ -81,6 +81,23 @@ 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, 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. devices is left out too: the + * device filters only read which devices are attached, but telemetry saves + * the device row on every ping, which would keep vehicle lists from ever + * being served from cache. Device::attachTo() and detach() invalidate the + * vehicle lists instead. + * + * @var string[] + */ + protected $apiCacheDependencies = ['vendor', 'vendor.place', 'category', 'warranty', 'driver', 'currentTrailers', 'fleets']; + /** * Get the activity log options for the model. */ @@ -629,6 +646,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/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..227c87156 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; @@ -232,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/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 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/ApiCacheThrottle.php b/server/src/Support/ApiCacheThrottle.php new file mode 100644 index 000000000..65b571bf9 --- /dev/null +++ b/server/src/Support/ApiCacheThrottle.php @@ -0,0 +1,72 @@ +company_uuid, $seconds); + + return $result; + } + + /** + * Invalidate the model's table for the company unless that was done within the window. + */ + public static function invalidate(Model $model, ?string $companyUuid, int $seconds = self::LOCATION_WINDOW): void + { + if (!ApiModelCache::isCachingEnabled()) { + return; + } + + $key = 'fleetops:api_cache_throttle:' . $model->getTable() . ':' . ($companyUuid ?? 'all'); + + try { + if (Cache::add($key, true, $seconds)) { + ApiModelCache::invalidateModelCache($model, $companyUuid); + } + } catch (\Throwable $e) { + // A location ping must never fail on cache bookkeeping; cached lists then + // show the location until their TTL. Core versions before #301 can throw here. + } + } +} 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/TelematicService.php b/server/src/Support/Telematics/TelematicService.php index 2f7f10ce1..250ea5374 100644 --- a/server/src/Support/Telematics/TelematicService.php +++ b/server/src/Support/Telematics/TelematicService.php @@ -13,6 +13,7 @@ use Fleetbase\FleetOps\Models\Telematic; use Fleetbase\FleetOps\Models\Trailer; use Fleetbase\FleetOps\Models\Vehicle; +use Fleetbase\FleetOps\Support\ApiCacheThrottle; use Fleetbase\FleetOps\Support\TrackingPublisher; use Fleetbase\LaravelMysqlSpatial\Types\Point as SpatialPoint; use Illuminate\Support\Carbon; @@ -760,7 +761,8 @@ protected function updateTrailerTelemetry(Trailer $trailer, array $location, arr 'last_provider' => $event->provider, 'last_telemetry_data' => array_filter($eventData, fn ($value) => $value !== null), ]); - $trailer->save(); + // Every telemetry event saves the trailer: model events fire, cache invalidation is coalesced + ApiCacheThrottle::telemetryWrite($trailer, fn () => $trailer->save()); $broadcast = new TrailerLocationChanged($trailer, ['source' => 'telematics', 'device_event_uuid' => $event->uuid, 'provider' => $event->provider, 'position_at' => $event->occurred_at?->toISOString()]); $this->broadcastTelemetry($broadcast, $eventData['_ordered_telemetry'] ?? false); @@ -803,7 +805,8 @@ protected function updateVehicleTelemetry(Vehicle $vehicle, array $location, arr 'fuel_level' => $eventData['fuel_level'] ?? null, ], fn ($value) => $value !== null), ]); - $vehicle->save(); + // Every telemetry event saves the vehicle: model events fire, cache invalidation is coalesced + ApiCacheThrottle::telemetryWrite($vehicle, fn () => $vehicle->save()); $broadcast = new VehicleLocationChanged($vehicle, [ 'source' => 'telematics', 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; diff --git a/server/tests/Unit/Support/ApiCacheDependenciesTest.php b/server/tests/Unit/Support/ApiCacheDependenciesTest.php new file mode 100644 index 000000000..d721343e0 --- /dev/null +++ b/server/tests/Unit/Support/ApiCacheDependenciesTest.php @@ -0,0 +1,337 @@ + '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); +}); + +test('attaching or detaching a device invalidates the attached model lists, and device pings do not', function () { + $db = apiCacheDatabase(); + $db->getSchemaBuilder()->create('devices', function ($table) { + $table->increments('id'); + foreach (['uuid', 'public_id', 'company_uuid', 'attachable_type', 'attachable_uuid', 'name', 'status'] as $column) { + $table->string($column)->nullable(); + } + $table->text('meta')->nullable(); + $table->timestamps(); + $table->softDeletes(); + }); + TrackingPageDatabase::insert($db, 'devices', ['uuid' => 'device-1', 'company_uuid' => 'co-1']); + // attachTo() and detach() log activity; only the cache versions matter here + app()->instance(Spatie\Activitylog\ActivityLogger::class, new class extends Spatie\Activitylog\ActivityLogger { + public function __construct() + { + } + + public function useLog(?string $logName): static + { + return $this; + } + + public function setLogStatus(Spatie\Activitylog\ActivityLogStatus $logStatus): static + { + return $this; + } + + public function performedOn(EloquentModel $model): static + { + return $this; + } + + public function withProperties(mixed $properties): static + { + return $this; + } + + public function log(string $description): ?Spatie\Activitylog\Contracts\Activity + { + return null; + } + }); + app()->instance(Spatie\Activitylog\ActivityLogStatus::class, new Spatie\Activitylog\ActivityLogStatus(new Illuminate\Config\Repository(['activitylog' => ['enabled' => false]]))); + + $vehicle = new Vehicle(); + $vehicle->setRawAttributes(['uuid' => 'vehicle-1', 'company_uuid' => 'co-1'], true); + $device = Device::where('uuid', 'device-1')->first(); + + expect($device->attachTo($vehicle))->toBeTrue() + ->and(apiCacheQueryVersion('vehicles', 'co-1'))->toBe(1); + + // Telemetry saves the device row on every ping; vehicle lists no longer depend on it + $device->update(['status' => 'online']); + expect(apiCacheQueryVersion('vehicles', 'co-1'))->toBe(1); + + expect($device->detach())->toBeTrue() + ->and(apiCacheQueryVersion('vehicles', 'co-1'))->toBe(2); + + app()->forgetInstance(Spatie\Activitylog\ActivityLogger::class); + app()->forgetInstance(Spatie\Activitylog\ActivityLogStatus::class); +}); + +test('a cache outage never fails a throttled location invalidation', function () { + Cache::swap(new Repository(new class extends ArrayStore { + public function add($key, $value, $seconds) + { + throw new RuntimeException('cache unavailable'); + } + })); + + ApiCacheThrottle::invalidate(new Vehicle(), 'co-1'); + + expect(true)->toBeTrue(); +}); + +test('telemetry writes fire their model events but invalidate at most once per window', function () { + Carbon::setTestNow('2026-10-12 12:00:00'); + $vehicle = new Vehicle(); + $vehicle->setRawAttributes(['uuid' => 'vehicle-1', 'company_uuid' => 'co-1'], true); + $writes = 0; + // Stands in for an evented save, whose model hooks invalidate the table + $save = function () use ($vehicle, &$writes) { + $writes++; + Fleetbase\Support\ApiModelCache::invalidateModelCache($vehicle, 'co-1'); + + return true; + }; + + foreach (range(1, 3) as $ping) { + expect(ApiCacheThrottle::telemetryWrite($vehicle, $save))->toBeTrue(); + } + + expect($writes)->toBe(3); + if (method_exists(Fleetbase\Support\ApiModelCache::class, 'withoutInvalidating')) { + // fleetbase/core-api#301: the saves' own invalidations are coalesced into one per window + expect(apiCacheQueryVersion('vehicles', 'co-1'))->toBe(1); + } else { + // An older core keeps invalidating on every save, as before, with no extra throttled bump + expect(apiCacheQueryVersion('vehicles', 'co-1'))->toBe(3); + } +});