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/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/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/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/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..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..5d1b23273 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. * @@ -670,8 +681,12 @@ 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 $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..9f817dfb7 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. */ @@ -629,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/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..7998d7586 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,19 +26,20 @@ 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 { - Driver::where('user_uuid', $userUuid)->delete(); + // 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): ?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/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..21bebc6f5 100644 --- a/server/src/Observers/UserObserver.php +++ b/server/src/Observers/UserObserver.php @@ -20,6 +20,7 @@ public function deleted(User $user) protected function deleteDrivers(string $userUuid): void { - Driver::where('user_uuid', $userUuid)->delete(); + // Delete each driver through the model so DriverObserver cleans up after it + Driver::where('user_uuid', $userUuid)->get()->each->delete(); } } diff --git a/server/src/Observers/VehicleObserver.php b/server/src/Observers/VehicleObserver.php index 761d345f9..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,9 +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 { - return Driver::where(['vehicle_uuid' => $vehicle->uuid])->delete(); + $unassigned = Driver::where(['vehicle_uuid' => $vehicle->uuid])->update(['vehicle_uuid' => null]); + if ($unassigned) { + Driver::invalidateApiCacheManually($vehicle->company_uuid); + } + + return $unassigned; } protected function invalidateLiveCache(): void 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); + } + } +} 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; 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/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/ObserverContractsTest.php b/server/tests/ObserverContractsTest.php index d4e4f98c5..015a004ac 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; @@ -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/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']); +}); 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 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/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'); +}); 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'], + ]); +}); 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']); 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], + ]); +}); 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); +});