@fleetbase/fleetops-engine 0.6.61 → 0.6.62

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (64) hide show
  1. package/RELEASE.md +23 -8
  2. package/addon/services/map-settings.js +4 -1
  3. package/composer.json +1 -1
  4. package/extension.json +1 -1
  5. package/package.json +1 -1
  6. package/server/migrations/2026_09_05_000001_add_unique_indexes_to_fleet_membership_pivots.php +129 -0
  7. package/server/src/Exceptions/PublicRelationNotFoundException.php +47 -0
  8. package/server/src/Http/Controllers/Api/v1/Concerns/ResolvesFleetOpsApiResources.php +42 -7
  9. package/server/src/Http/Controllers/Api/v1/Concerns/ResolvesPublicExpansions.php +187 -0
  10. package/server/src/Http/Controllers/Api/v1/DriverController.php +93 -57
  11. package/server/src/Http/Controllers/Api/v1/EntityController.php +1 -1
  12. package/server/src/Http/Controllers/Api/v1/FleetController.php +370 -33
  13. package/server/src/Http/Controllers/Api/v1/FuelReportController.php +1 -1
  14. package/server/src/Http/Controllers/Api/v1/IssueController.php +1 -1
  15. package/server/src/Http/Controllers/Api/v1/OrderController.php +1 -1
  16. package/server/src/Http/Controllers/Api/v1/ServiceAreaController.php +1 -1
  17. package/server/src/Http/Controllers/Api/v1/TrackingStatusController.php +1 -1
  18. package/server/src/Http/Controllers/Api/v1/VehicleController.php +134 -35
  19. package/server/src/Http/Controllers/Api/v1/ZoneController.php +1 -1
  20. package/server/src/Http/Filter/Concerns/ResolvesPublicRelationUuids.php +54 -0
  21. package/server/src/Http/Filter/DriverFilter.php +71 -15
  22. package/server/src/Http/Filter/FleetFilter.php +32 -33
  23. package/server/src/Http/Filter/VehicleFilter.php +57 -19
  24. package/server/src/Http/Requests/Concerns/ScopesPublicRelationRules.php +48 -0
  25. package/server/src/Http/Requests/CreateDriverRequest.php +100 -9
  26. package/server/src/Http/Requests/CreateFleetRequest.php +30 -2
  27. package/server/src/Http/Requests/CreateServiceRateRequest.php +7 -2
  28. package/server/src/Http/Requests/CreateVehicleRequest.php +150 -31
  29. package/server/src/Http/Resources/v1/Concerns/ResolvesPublicRelationFields.php +110 -0
  30. package/server/src/Http/Resources/v1/Driver.php +22 -2
  31. package/server/src/Http/Resources/v1/Fleet.php +38 -25
  32. package/server/src/Http/Resources/v1/Vehicle.php +46 -4
  33. package/server/src/Models/Driver.php +2 -2
  34. package/server/src/routes.php +8 -0
  35. package/server/tests/ApiDriverControllerContractsTest.php +284 -13
  36. package/server/tests/ApiEntityControllerContractsTest.php +1 -1
  37. package/server/tests/ApiEquipmentControllerContractsTest.php +2 -2
  38. package/server/tests/ApiFleetControllerContractsTest.php +354 -21
  39. package/server/tests/ApiFuelReportControllerContractsTest.php +1 -1
  40. package/server/tests/ApiIssueControllerContractsTest.php +1 -1
  41. package/server/tests/ApiOrderControllerContractsTest.php +1 -1
  42. package/server/tests/ApiPartControllerContractsTest.php +2 -2
  43. package/server/tests/ApiServiceAreaZoneControllerContractsTest.php +2 -2
  44. package/server/tests/ApiTrackingStatusControllerContractsTest.php +1 -1
  45. package/server/tests/ApiVehicleControllerContractsTest.php +175 -8
  46. package/server/tests/ControllerFilterContractsTest.php +132 -13
  47. package/server/tests/ControllerHelperContractsTest.php +6 -3
  48. package/server/tests/DriverFilterExecutionTest.php +130 -6
  49. package/server/tests/ExceptionContractsTest.php +15 -0
  50. package/server/tests/Feature/Http/Api/DeviceControllerContractsTest.php +1 -1
  51. package/server/tests/Feature/Http/Api/FleetMembershipMigrationTest.php +140 -0
  52. package/server/tests/Feature/Http/Api/FleetMembershipTest.php +333 -0
  53. package/server/tests/Feature/Http/Api/FleetPublicContractTest.php +439 -0
  54. package/server/tests/Feature/Http/Api/FuelTransactionControllerContractsTest.php +2 -2
  55. package/server/tests/Feature/Http/Api/NullRelationshipInputTest.php +191 -0
  56. package/server/tests/Feature/Http/Api/PublicIdentifierFilterTest.php +293 -0
  57. package/server/tests/Feature/Http/Api/SmallApiControllerHelpersTest.php +7 -1
  58. package/server/tests/Feature/Http/Api/VehicleControllerTrackingTest.php +3 -1
  59. package/server/tests/Feature/Http/Api/VehiclePublicContractTest.php +454 -0
  60. package/server/tests/Feature/Http/Api/WorkOrderControllerContractsTest.php +1 -1
  61. package/server/tests/FleetHierarchyResourceTest.php +56 -8
  62. package/server/tests/RequestContractsTest.php +100 -7
  63. package/server/tests/Unit/Http/Resources/FleetResourceTest.php +50 -6
  64. package/server/tests/VehicleControllerHelperContractsTest.php +1 -1
@@ -4,7 +4,9 @@ use Fleetbase\FleetOps\Http\Controllers\Api\v1\DriverController;
4
4
  use Fleetbase\FleetOps\Http\Requests\CreateDriverRequest;
5
5
  use Fleetbase\FleetOps\Http\Requests\UpdateDriverRequest;
6
6
  use Fleetbase\FleetOps\Models\Driver;
7
+ use Fleetbase\FleetOps\Models\Order;
7
8
  use Fleetbase\FleetOps\Models\Vehicle;
9
+ use Fleetbase\FleetOps\Models\Vendor;
8
10
  use Fleetbase\LaravelMysqlSpatial\Types\Point;
9
11
  use Fleetbase\Models\Company;
10
12
  use Fleetbase\Models\User;
@@ -29,6 +31,9 @@ class FleetOpsApiDriverControllerProbe extends DriverController
29
31
  public array $companyCalls = [];
30
32
  public array $createdUsers = [];
31
33
  public array $uuidLookups = [];
34
+ public array $relationLookups = [];
35
+ public array $relationCompanyScopes = [];
36
+ public array $unresolvable = [];
32
37
  public array $pointInputs = [];
33
38
  public array $createdDrivers = [];
34
39
  public array $resolvedFiles = [];
@@ -76,6 +81,33 @@ class FleetOpsApiDriverControllerProbe extends DriverController
76
81
  return $table . '-uuid';
77
82
  }
78
83
 
84
+ /**
85
+ * Stand in for the company-scoped public-id lookup.
86
+ *
87
+ * Anything listed in `$unresolvable` behaves as a cross-company or missing
88
+ * identifier does in production.
89
+ */
90
+ protected function resolveUuid(string $modelClass, ?string $id, ?string $companyUuid = null): ?string
91
+ {
92
+ if (empty($id)) {
93
+ return null;
94
+ }
95
+
96
+ $this->relationLookups[] = [$modelClass, $id];
97
+ $this->relationCompanyScopes[] = $companyUuid;
98
+
99
+ if (in_array($id, $this->unresolvable, true)) {
100
+ throw (new ModelNotFoundException())->setModel($modelClass, $id);
101
+ }
102
+
103
+ return strtolower(class_basename($modelClass)) . '-uuid';
104
+ }
105
+
106
+ public function inputForTest(Request $request): array
107
+ {
108
+ return $this->driverInputFromRequest($request);
109
+ }
110
+
79
111
  protected function pointFromCoordinates(array $coordinates): Point
80
112
  {
81
113
  $this->pointInputs[] = $coordinates;
@@ -343,21 +375,23 @@ test('api driver controller creates drivers with user company assignment and rel
343
375
  ->and($controller->user->assignedRoles)->toBe(['Driver'])
344
376
  ->and($controller->createdDrivers[0])->toMatchArray([
345
377
  'status' => 'available',
346
- 'vehicle_uuid' => 'vehicles-uuid',
347
- 'vendor_uuid' => 'vendors-uuid',
348
- 'current_job_uuid' => 'orders-uuid',
378
+ 'vehicle_uuid' => 'vehicle-uuid',
379
+ 'vendor_uuid' => 'vendor-uuid',
380
+ 'current_job_uuid' => 'order-uuid',
349
381
  'online' => 0,
350
382
  'user_uuid' => 'user-uuid',
351
383
  'company_uuid' => 'company-uuid',
352
384
  ])
353
385
  ->and($controller->createdDrivers[0]['location'])->toBeInstanceOf(Point::class)
354
- ->and($controller->uuidLookups)->toContain(
355
- ['vehicles', ['public_id' => 'vehicle_public', 'company_uuid' => 'company-uuid'], []],
356
- ['vendors', ['public_id' => 'vendor_public', 'company_uuid' => 'company-uuid'], []],
357
- ['orders', ['public_id' => 'order_public', 'company_uuid' => 'company-uuid'], []]
358
- )
386
+ ->and($controller->relationLookups)->toBe([
387
+ [Vehicle::class, 'vehicle_public'],
388
+ [Vendor::class, 'vendor_public'],
389
+ [Order::class, 'order_public'],
390
+ ])
359
391
  ->and($controller->resolvedFiles)->toBe([['file_public', 'uploads/company-uuid/drivers']])
360
- ->and($controller->user->updates)->toContain(['photo_uuid' => 'photo-file-uuid'])
392
+ // `photo_uuid` is not a column on users; the avatar lives in `avatar_uuid`
393
+ // and the old key was silently dropped by mass assignment.
394
+ ->and($controller->user->updates)->toContain(['avatar_uuid' => 'photo-file-uuid'])
361
395
  ->and($controller->driver->loaded)->toContain(['user', 'vehicle', 'vendor', 'currentJob']);
362
396
  });
363
397
 
@@ -411,14 +445,14 @@ test('api driver controller updates drivers user details assignments location an
411
445
  ])
412
446
  ->and($driver->updates[0])->toMatchArray([
413
447
  'status' => 'busy',
414
- 'vehicle_uuid' => 'vehicles-uuid',
415
- 'vendor_uuid' => 'vendors-uuid',
416
- 'current_job_uuid' => 'orders-uuid',
448
+ 'vehicle_uuid' => 'vehicle-uuid',
449
+ 'vendor_uuid' => 'vendor-uuid',
450
+ 'current_job_uuid' => 'order-uuid',
417
451
  ])
418
452
  ->and($driver->updates[0]['location'])->toBeInstanceOf(Point::class)
419
453
  ->and($driver->flushedForTest)->toBeTrue()
420
454
  ->and($controller->resolvedFiles)->toBe([['photo-input', 'uploads/session-company-uuid/drivers']])
421
- ->and($user->updates)->toContain(['photo_uuid' => 'photo-file-uuid'])
455
+ ->and($user->updates)->toContain(['avatar_uuid' => 'photo-file-uuid'])
422
456
  ->and($driver->loaded)->toContain(['user', 'vehicle', 'vendor', 'currentJob']);
423
457
  });
424
458
 
@@ -634,3 +668,240 @@ test('api driver controller refuses to set a password through a general update',
634
668
  ->and($user->updates[0])->not->toHaveKey('password')
635
669
  ->and($user->updates[0])->toHaveKey('name');
636
670
  });
671
+
672
+ test('api driver controller accepts every safe driver field the model exposes', function () {
673
+ $payload = [
674
+ // Identity
675
+ 'internal_id' => 'DRV-9001', 'drivers_license_number' => 'S1234567A',
676
+ 'license_expiry' => '2030-06-30',
677
+ // Operational
678
+ 'country' => 'SG', 'currency' => 'SGD', 'city' => 'Singapore', 'online' => true,
679
+ 'current_status' => 'on_break', 'status' => 'available',
680
+ 'heading' => 180, 'bearing' => 90, 'altitude' => 15, 'speed' => 42,
681
+ // Structured / orchestrator
682
+ 'meta' => ['depot' => 'north'], 'skills' => ['hazmat'],
683
+ 'max_travel_time' => 28800, 'max_distance' => 250000,
684
+ 'time_window_start' => '08:00', 'time_window_end' => '18:00',
685
+ ];
686
+
687
+ $controller = new FleetOpsApiDriverControllerProbe();
688
+ $input = $controller->inputForTest(new Request($payload));
689
+
690
+ $missing = array_values(array_diff(array_keys($payload), array_keys($input)));
691
+
692
+ expect($missing)->toBe([])
693
+ ->and($input)->toMatchArray($payload);
694
+ });
695
+
696
+ test('api driver controller input excludes authentication tenancy and generated columns', function () {
697
+ // The projection used to be an `except()` blocklist, so anything nobody had
698
+ // thought to name reached Driver::create() intact — including the auth token.
699
+ $controller = new FleetOpsApiDriverControllerProbe();
700
+
701
+ $input = $controller->inputForTest(new Request([
702
+ 'internal_id' => 'DRV-1',
703
+ 'auth_token' => 'forged',
704
+ 'signup_token_used' => true,
705
+ 'user_uuid' => 'forged',
706
+ 'company_uuid' => 'someone-elses-company',
707
+ 'vehicle_uuid' => 'forged',
708
+ 'vendor_uuid' => 'forged',
709
+ 'current_job_uuid' => 'forged',
710
+ '_key' => 'forged',
711
+ 'uuid' => 'forged',
712
+ 'public_id' => 'driver_forged',
713
+ 'slug' => 'forged',
714
+ 'avatar_url' => 'forged',
715
+ ]));
716
+
717
+ expect(array_keys($input))->toBe(['internal_id']);
718
+ });
719
+
720
+ test('api driver controller persists meta location and telemetry that the blocklist used to drop', function () {
721
+ // `location`, `heading`, `altitude`, `speed` and `meta` were all on the old
722
+ // `except()` list, so every write silently discarded them — and `meta` could
723
+ // not be mass assigned anyway because the model spelled it `meta,`.
724
+ $controller = new FleetOpsApiDriverControllerProbe();
725
+
726
+ $input = $controller->inputForTest(new Request([
727
+ 'meta' => ['badge' => 'A12'],
728
+ 'heading' => 270,
729
+ 'altitude' => 8,
730
+ 'speed' => 55,
731
+ ]));
732
+
733
+ expect($input)->toMatchArray([
734
+ 'meta' => ['badge' => 'A12'],
735
+ 'heading' => 270,
736
+ 'altitude' => 8,
737
+ 'speed' => 55,
738
+ ])->and(in_array('meta', (new Driver())->getFillable(), true))->toBeTrue();
739
+ });
740
+
741
+ test('api driver controller creates an operational driver with no email or phone', function () {
742
+ $controller = new FleetOpsApiDriverControllerProbe();
743
+
744
+ $response = $controller->create(new CreateDriverRequest([
745
+ 'name' => 'Yard Driver',
746
+ 'internal_id' => 'DRV-7788',
747
+ ]));
748
+
749
+ // No invented address, no invented number: the user record simply carries
750
+ // neither, which the users table permits. The driver cannot sign in to
751
+ // Navigator until credentials are supplied.
752
+ expect($response)->toBe(['resource' => 'driver', 'driver' => $controller->driver])
753
+ ->and($controller->createdUsers[0])->toMatchArray(['name' => 'Yard Driver'])
754
+ ->and($controller->createdUsers[0]['email'] ?? null)->toBeNull()
755
+ ->and($controller->createdUsers[0]['phone'] ?? null)->toBeNull()
756
+ ->and($controller->createdDrivers[0])->toMatchArray([
757
+ 'internal_id' => 'DRV-7788',
758
+ 'user_uuid' => 'user-uuid',
759
+ 'company_uuid' => 'company-uuid',
760
+ 'status' => 'available',
761
+ ])
762
+ // The Driver-to-User relationship, organization membership, user type
763
+ // and role are all preserved for a credential-less driver.
764
+ ->and($controller->user->assignedCompanies)->toBe(['company-uuid'])
765
+ ->and($controller->user->assignedTypes)->toBe(['driver'])
766
+ ->and($controller->user->assignedRoles)->toBe(['Driver']);
767
+ });
768
+
769
+ test('api driver controller creates a driver with only one contact method', function () {
770
+ $emailOnly = new FleetOpsApiDriverControllerProbe();
771
+ $emailOnly->create(new CreateDriverRequest([
772
+ 'name' => 'Email Only',
773
+ 'email' => 'email.only@example.test',
774
+ ]));
775
+
776
+ $phoneOnly = new FleetOpsApiDriverControllerProbe();
777
+ $phoneOnly->create(new CreateDriverRequest([
778
+ 'name' => 'Phone Only',
779
+ 'phone' => '+15550001111',
780
+ ]));
781
+
782
+ expect($emailOnly->createdUsers[0])->toMatchArray(['email' => 'email.only@example.test'])
783
+ ->and($emailOnly->createdUsers[0]['phone'] ?? null)->toBeNull()
784
+ ->and($phoneOnly->createdUsers[0])->toMatchArray(['phone' => '+15550001111'])
785
+ ->and($phoneOnly->createdUsers[0]['email'] ?? null)->toBeNull();
786
+ });
787
+
788
+ test('api driver controller rejects a relationship that belongs to another company', function () {
789
+ $controller = new FleetOpsApiDriverControllerProbe();
790
+ $controller->unresolvable = ['vehicle_other_company'];
791
+
792
+ $created = $controller->create(new CreateDriverRequest([
793
+ 'name' => 'Driver One',
794
+ 'vehicle' => 'vehicle_other_company',
795
+ ]));
796
+
797
+ $controller = new FleetOpsApiDriverControllerProbe();
798
+ $controller->unresolvable = ['vendor_other_company'];
799
+
800
+ $updated = $controller->update('driver_public', new UpdateDriverRequest([
801
+ 'vendor' => 'vendor_other_company',
802
+ ]));
803
+
804
+ expect($created)->toBe([
805
+ 'json' => ['error' => 'No vehicle resource found for the identifier provided.'],
806
+ 'status' => 404,
807
+ ])->and($updated)->toBe([
808
+ 'json' => ['error' => 'No vendor resource found for the identifier provided.'],
809
+ 'status' => 404,
810
+ ]);
811
+ });
812
+
813
+ test('api driver controller clears a relationship when the input is sent empty', function () {
814
+ $controller = new FleetOpsApiDriverControllerProbe();
815
+
816
+ $input = $controller->inputForTest(new Request(['vehicle' => null, 'vendor' => '', 'job' => null]));
817
+
818
+ expect($input)->toMatchArray([
819
+ 'vehicle_uuid' => null,
820
+ 'vendor_uuid' => null,
821
+ 'current_job_uuid' => null,
822
+ ])->and($controller->relationLookups)->toBe([]);
823
+ });
824
+
825
+ test('api driver controller resolves driver relationships inside the company the driver is created in', function () {
826
+ // A create request may name a company explicitly, and the relationships must
827
+ // be looked up in that company rather than in whatever the session holds.
828
+ $controller = new FleetOpsApiDriverControllerProbe();
829
+
830
+ $controller->create(new CreateDriverRequest([
831
+ 'company' => 'company_public',
832
+ 'name' => 'Driver One',
833
+ 'vehicle' => 'vehicle_public',
834
+ 'vendor' => 'vendor_public',
835
+ ]));
836
+
837
+ expect($controller->relationCompanyScopes)->toBe(['company-uuid', 'company-uuid']);
838
+ });
839
+
840
+ test('api driver controller keeps every relationship the base contract returned', function () {
841
+ // The SDK stores what the API returns verbatim, and Navigator interpolates
842
+ // `driver.user` straight into a socket channel name — an object there would
843
+ // subscribe it to `user.[object Object]` and quietly stop delivering
844
+ // messages. These endpoints have always loaded user, vehicle, vendor and
845
+ // currentJob, so they must keep doing it without a `with` parameter.
846
+ $controller = new FleetOpsApiDriverControllerProbe();
847
+
848
+ $controller->create(new CreateDriverRequest([
849
+ 'name' => 'Driver One',
850
+ 'vehicle' => 'vehicle_public',
851
+ 'vendor' => 'vendor_public',
852
+ 'job' => 'order_public',
853
+ ]));
854
+
855
+ expect($controller->driver->loaded)->toContain(['user', 'vehicle', 'vendor', 'currentJob']);
856
+
857
+ $updateController = new FleetOpsApiDriverControllerProbe();
858
+ $updateController->update('driver_public', new UpdateDriverRequest(['name' => 'Renamed']));
859
+
860
+ expect($updateController->driver->loaded)->toContain(['user', 'vehicle', 'vendor', 'currentJob'])
861
+ ->and($updateController->findCalls)->toContain(['driver_public', ['user']]);
862
+ });
863
+
864
+ test('api driver controller copies timezone through to the linked user account', function () {
865
+ // `timezone` is documented and accepted on both create and update, but the
866
+ // update only ever copied name, email and phone — so the request validated,
867
+ // answered 200, and dropped it. The driver has no timezone column; the
868
+ // linked user does.
869
+ $create = new FleetOpsApiDriverControllerProbe();
870
+ $create->create(new CreateDriverRequest([
871
+ 'name' => 'Driver One',
872
+ 'timezone' => 'Asia/Singapore',
873
+ ]));
874
+
875
+ $user = new FleetOpsApiDriverUserFake();
876
+ $user->setRawAttributes(['uuid' => 'user-uuid'], true);
877
+
878
+ $driver = new FleetOpsApiDriverFake();
879
+ $driver->setRawAttributes(['uuid' => 'driver-uuid', 'public_id' => 'driver_public', 'user_uuid' => 'user-uuid'], true);
880
+ $driver->userForTest = $user;
881
+ $driver->setRelation('user', $user);
882
+
883
+ $update = new FleetOpsApiDriverControllerProbe();
884
+ $update->driver = $driver;
885
+ $update->update('driver_public', new UpdateDriverRequest([
886
+ 'name' => 'Driver One',
887
+ 'timezone' => 'Europe/Amsterdam',
888
+ ]));
889
+
890
+ expect($create->createdUsers[0])->toMatchArray(['timezone' => 'Asia/Singapore'])
891
+ ->and($user->updates)->toContain([
892
+ 'name' => 'Driver One',
893
+ 'timezone' => 'Europe/Amsterdam',
894
+ ]);
895
+ });
896
+
897
+ test('api driver controller only expands relationships the public contract allows', function () {
898
+ $controller = new FleetOpsApiDriverControllerProbe();
899
+ $request = new CreateDriverRequest(['name' => 'Driver One', 'with' => ['vehicle', 'current_job', 'user', 'company', 'nope']]);
900
+
901
+ $controller->create($request);
902
+
903
+ // `user` and `company` are published as public-id strings. Expanding either
904
+ // would retype a released field, so neither is expandable at all; an unknown
905
+ // name is dropped rather than reaching Eloquent, where it would be a 500.
906
+ expect($request->input('with'))->toBe(['vehicle', 'currentJob']);
907
+ });
@@ -43,7 +43,7 @@ class FleetOpsApiEntityControllerProbe extends EntityController
43
43
  return $value === 'contacts' ? 'contact' : null;
44
44
  }
45
45
 
46
- protected function findPayloadByPublicId(string $publicId): ?Payload
46
+ protected function findPayloadByPublicId(?string $publicId): ?Payload
47
47
  {
48
48
  $this->payload?->setAttribute('lookup_id', $publicId);
49
49
 
@@ -26,7 +26,7 @@ class FleetOpsApiEquipmentControllerProbe extends EquipmentController
26
26
  return $equipment;
27
27
  }
28
28
 
29
- protected function resolveModel(string $modelClass, string $id): Illuminate\Database\Eloquent\Model
29
+ protected function resolveModel(string $modelClass, string $id, ?string $companyUuid = null): Illuminate\Database\Eloquent\Model
30
30
  {
31
31
  if ($this->equipmentNotFound) {
32
32
  throw new ModelNotFoundException();
@@ -37,7 +37,7 @@ class FleetOpsApiEquipmentControllerProbe extends EquipmentController
37
37
  return $this->equipment;
38
38
  }
39
39
 
40
- protected function resolveUuid(string $modelClass, ?string $id): ?string
40
+ protected function resolveUuid(string $modelClass, ?string $id, ?string $companyUuid = null): ?string
41
41
  {
42
42
  $this->resolvedUuids[] = [$modelClass, $id];
43
43