@fleetbase/fleetops-engine 0.6.59 → 0.6.60

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 (43) hide show
  1. package/RELEASE.md +36 -0
  2. package/addon/components/vehicle/details/schedules.hbs +2 -2
  3. package/addon/components/vehicle/details/schedules.js +7 -1
  4. package/addon/controllers/maintenance/schedules/index/edit.js +10 -1
  5. package/addon/services/vehicle-actions.js +4 -2
  6. package/composer.json +1 -1
  7. package/extension.json +1 -1
  8. package/package.json +1 -1
  9. package/server/config/fleetops.php +44 -0
  10. package/server/config/geocoder.php +1 -1
  11. package/server/migrations/2026_08_08_000001_make_sku_and_provider_transaction_unique_indexes_soft_delete_aware.php +152 -0
  12. package/server/src/Http/Controllers/Api/v1/CustomerController.php +50 -4
  13. package/server/src/Http/Controllers/Api/v1/DriverController.php +57 -2
  14. package/server/src/Http/Controllers/Api/v1/GeofenceController.php +34 -2
  15. package/server/src/Http/Controllers/Api/v1/NavigatorController.php +14 -13
  16. package/server/src/Http/Controllers/Api/v1/TrackingNumberController.php +10 -1
  17. package/server/src/Http/Controllers/Concerns/ResolvesReviewAccountBypass.php +62 -0
  18. package/server/src/Http/Controllers/Internal/v1/DriverController.php +1 -1
  19. package/server/src/Http/Filter/MaintenanceScheduleFilter.php +40 -0
  20. package/server/src/Http/Requests/CreateCustomerRequest.php +9 -1
  21. package/server/src/Http/Requests/CreateFuelReportRequest.php +14 -3
  22. package/server/src/Http/Requests/CreateFuelTransactionRequest.php +64 -1
  23. package/server/src/Http/Requests/CreatePartRequest.php +39 -1
  24. package/server/src/Http/Resources/v1/TrackingNumber.php +33 -0
  25. package/server/src/Models/FuelReport.php +21 -0
  26. package/server/src/Models/Sensor.php +24 -0
  27. package/server/src/Support/FuelProviders/FuelProviderService.php +10 -1
  28. package/server/src/routes.php +1 -1
  29. package/server/tests/ApiCustomerControllerContractsTest.php +168 -0
  30. package/server/tests/ApiDriverControllerContractsTest.php +32 -0
  31. package/server/tests/CompactResourceSerializationTest.php +93 -0
  32. package/server/tests/CustomerEndpointTest.php +4 -1
  33. package/server/tests/Feature/Http/Api/DriverControllerAuthFlowsTest.php +81 -2
  34. package/server/tests/Feature/Http/Api/GeofenceControllerContractsTest.php +99 -2
  35. package/server/tests/Feature/Http/Api/TrackingNumberControllerHelpersTest.php +12 -6
  36. package/server/tests/Feature/Http/Internal/DriverControllerContractsTest.php +58 -0
  37. package/server/tests/FuelProviderTransactionControllerContractsTest.php +17 -0
  38. package/server/tests/RequestContractsTest.php +97 -5
  39. package/server/tests/SmallControllerContractsTest.php +28 -4
  40. package/server/tests/Unit/Http/Filter/MaintenanceScheduleFilterTest.php +69 -0
  41. package/server/tests/Unit/Http/Requests/CreateFuelReportRequestTest.php +21 -0
  42. package/server/tests/Unit/Jobs/GeofenceDwellAndBulkNotifyTest.php +7 -0
  43. package/server/tests/Unit/Models/SensorLastPositionDefaultTest.php +130 -0
@@ -10,25 +10,21 @@ use Illuminate\Http\JsonResponse;
10
10
  class NavigatorController extends Controller
11
11
  {
12
12
  /**
13
- * Retrieve the driver onboard settings.
13
+ * Retrieve the driver onboard settings for an organization.
14
14
  *
15
- * This method retrieves the driver onboard settings for the current company session. If no company session
16
- * is found in the request, an error response is returned. The method retrieves the company ID from the session,
17
- * then fetches the saved driver onboard settings. If settings for the current company are found, they are returned,
18
- * otherwise, default settings are provided.
19
- *
20
- * @return JsonResponse
21
- */
22
-
23
- /**
24
- * Retrieve driver onboard settings.
15
+ * The organization is resolved from its public id. An unknown public id is a client
16
+ * error, not a server error, so it answers 404 rather than dereferencing a null company.
25
17
  *
26
18
  * @return JsonResponse
27
19
  */
28
20
  public function getDriverOnboardSettings($companyId)
29
21
  {
30
- $company = $this->findCompanyByPublicId($companyId);
31
- $driverOnboardSettings = $this->driverOnboardSetting($company->uuid);
22
+ $company = $this->findCompanyByPublicId($companyId);
23
+ if (!$company) {
24
+ return $this->errorResponse('Organization not found.', 404);
25
+ }
26
+
27
+ $driverOnboardSettings = $this->driverOnboardSetting($company->uuid);
32
28
  if (!$driverOnboardSettings) {
33
29
  $driverOnboardSettings = [];
34
30
  }
@@ -50,4 +46,9 @@ class NavigatorController extends Controller
50
46
  {
51
47
  return response()->json($payload);
52
48
  }
49
+
50
+ protected function errorResponse(string $message, int $statusCode): JsonResponse
51
+ {
52
+ return response()->error($message, $statusCode);
53
+ }
53
54
  }
@@ -200,7 +200,16 @@ class TrackingNumberController extends Controller
200
200
  }
201
201
  }
202
202
 
203
- return Utils::findModel($tables, $where);
203
+ // No raw-row fallback. Utils::findModel() was called with the ARRAY of table names
204
+ // and, when nothing matched, passed that array to DB::table() — which stringified
205
+ // it to the literal table name "Array" and threw SQLSTATE[42S02]. Every
206
+ // unresolvable code became a 500 instead of the 400 this method's caller already
207
+ // handles.
208
+ //
209
+ // The fallback could not have helped anyway: qrModelResource() serializes through
210
+ // a typed resource keyed on the model class, and a raw stdClass row has none. The
211
+ // mapped models above are the endpoint's whole contract.
212
+ return null;
204
213
  }
205
214
 
206
215
  protected function qrModelResource($model)
@@ -0,0 +1,62 @@
1
+ <?php
2
+
3
+ namespace Fleetbase\FleetOps\Http\Controllers\Concerns;
4
+
5
+ use Illuminate\Support\Facades\Log;
6
+
7
+ /**
8
+ * Accepts a fixed verification code, but only for explicitly designated accounts.
9
+ *
10
+ * App store reviewers cannot receive our SMS or email, so a fixed code has to keep
11
+ * working for them — including in production, which is where a review build is
12
+ * tested. Refusing the bypass outside production made review impossible; refusing
13
+ * it for everyone except named accounts keeps review working while removing the
14
+ * real hazard, which was that anyone holding the code could authenticate as any
15
+ * driver or customer.
16
+ */
17
+ trait ResolvesReviewAccountBypass
18
+ {
19
+ /**
20
+ * @param string $codeKey config key holding the bypass code
21
+ * @param string $accountsKey config key holding the allowlisted identities
22
+ * @param string|null $identity identity being authenticated, already normalised
23
+ * @param string|null $code submitted verification code
24
+ * @param string $channel label for the audit log entry
25
+ */
26
+ protected static function reviewAccountBypassMatches(
27
+ string $codeKey,
28
+ string $accountsKey,
29
+ ?string $identity,
30
+ ?string $code,
31
+ string $channel,
32
+ ): bool {
33
+ $bypassCode = config($codeKey);
34
+
35
+ // `!== null && !== ''` rather than `!empty()`: `!empty('0')` is false, so a
36
+ // configured code of "0" would otherwise be silently ignored.
37
+ if ($bypassCode === null || $bypassCode === '' || $code === null || $code === '' || $identity === null || $identity === '') {
38
+ return false;
39
+ }
40
+
41
+ $accounts = array_map(
42
+ static fn ($account) => strtolower(trim((string) $account)),
43
+ (array) config($accountsKey, [])
44
+ );
45
+
46
+ if (!in_array(strtolower(trim($identity)), $accounts, true)) {
47
+ return false;
48
+ }
49
+
50
+ // Constant-time, so a wrong code cannot be recovered by timing the response.
51
+ if (!hash_equals((string) $bypassCode, (string) $code)) {
52
+ return false;
53
+ }
54
+
55
+ Log::warning('[Fleetbase] Verification bypass accepted for a review account.', [
56
+ 'channel' => $channel,
57
+ 'identity' => $identity,
58
+ ]);
59
+
60
+ return true;
61
+ }
62
+ }
@@ -665,7 +665,7 @@ class DriverController extends FleetOpsController
665
665
 
666
666
  // Find and verify code
667
667
  $verificationCode = static::verificationCodeExists($user, $code, $for);
668
- if (!$verificationCode && $code !== config('fleetops.navigator.bypass_verification_code')) {
668
+ if (!$verificationCode && !ApiDriverController::verificationBypassMatches($identity, $code)) {
669
669
  return response()->error('Invalid verification code!');
670
670
  }
671
671
 
@@ -0,0 +1,40 @@
1
+ <?php
2
+
3
+ namespace Fleetbase\FleetOps\Http\Filter;
4
+
5
+ use Fleetbase\FleetOps\Support\Utils;
6
+ use Fleetbase\Http\Filter\Filter;
7
+ use Illuminate\Support\Str;
8
+
9
+ class MaintenanceScheduleFilter extends Filter
10
+ {
11
+ public function queryForInternal(): void
12
+ {
13
+ $this->builder->where('company_uuid', $this->session->get('company'));
14
+ }
15
+
16
+ public function queryForPublic(): void
17
+ {
18
+ $this->queryForInternal();
19
+ }
20
+
21
+ public function subjectType(?string $subjectType): void
22
+ {
23
+ if (!$subjectType) {
24
+ return;
25
+ }
26
+
27
+ if (!Str::contains($subjectType, ['\\', ':'])) {
28
+ $subjectType = 'fleet-ops:' . $subjectType;
29
+ }
30
+
31
+ $this->builder->where('subject_type', Utils::getMutationType($subjectType));
32
+ }
33
+
34
+ public function subjectUuid(?string $subjectUuid): void
35
+ {
36
+ if ($subjectUuid) {
37
+ $this->builder->where('subject_uuid', $subjectUuid);
38
+ }
39
+ }
40
+ }
@@ -23,7 +23,15 @@ class CreateCustomerRequest extends FleetbaseRequest
23
23
  {
24
24
  return [
25
25
  'identity' => 'required|string',
26
- 'code' => 'required|exists:verification_codes,code',
26
+ // Presence only — authorizing the code is the controller's job, and its
27
+ // check is strictly stronger: it matches code + for + meta->identity,
28
+ // whereas `exists:verification_codes,code` accepts any live code issued
29
+ // for any purpose to any user. The rule is also already unenforced on the
30
+ // proxy path, since verifyCode() with for=fleetops_create_customer calls
31
+ // create(CreateCustomerRequest::createFrom($request)), which never runs
32
+ // validateResolved(). Keeping it here only blocks the configured
33
+ // non-production testing bypass (fleetops.customers.verification_bypass_code).
34
+ 'code' => 'required|string',
27
35
  'name' => 'required|string',
28
36
  'password' => 'required|string|min:8',
29
37
  'email' => [
@@ -19,10 +19,21 @@ class CreateFuelReportRequest extends FleetbaseRequest
19
19
  */
20
20
  public function rules(): array
21
21
  {
22
+ // requiredIf(POST), not required: UpdateFuelReportRequest extends this class, so a
23
+ // flat `required` made every partial update fail — PUT /v1/fuel-reports/{id} with
24
+ // just {"status": "approved"} answered "The driver field is required."
25
+ //
26
+ // `driver` is the clearest case: the update action's $request->only() list does not
27
+ // even include it, so validation demanded a field the endpoint then discarded.
28
+ // CreateFuelTransactionRequest solves the same create/update inheritance with
29
+ // Rule::requiredIf(isMethod('POST')); a plain conditional is used here because
30
+ // illuminate/validation is not autoloadable in the package's test harness.
31
+ $requiredOnCreate = $this->isMethod('POST') ? ['required'] : ['sometimes'];
32
+
22
33
  return [
23
- 'driver' => ['required'],
24
- 'odometer' => ['required'],
25
- 'volume' => ['required'],
34
+ 'driver' => $requiredOnCreate,
35
+ 'odometer' => $requiredOnCreate,
36
+ 'volume' => $requiredOnCreate,
26
37
  'metric_unit' => ['nullable'],
27
38
  'location' => ['nullable'],
28
39
  'amount' => ['nullable'],
@@ -2,6 +2,7 @@
2
2
 
3
3
  namespace Fleetbase\FleetOps\Http\Requests;
4
4
 
5
+ use Fleetbase\FleetOps\Models\FuelProviderTransaction;
5
6
  use Fleetbase\Http\Requests\FleetbaseRequest;
6
7
  use Illuminate\Validation\Rule;
7
8
 
@@ -16,7 +17,7 @@ class CreateFuelTransactionRequest extends FleetbaseRequest
16
17
  {
17
18
  return [
18
19
  'provider' => [Rule::requiredIf($this->isMethod('POST')), 'string'],
19
- 'provider_transaction_id' => [Rule::requiredIf($this->isMethod('POST')), 'string'],
20
+ 'provider_transaction_id' => [Rule::requiredIf($this->isMethod('POST')), 'string', $this->uniqueProviderTransactionIdRule()],
20
21
  'connection' => ['nullable', 'string'],
21
22
  'fuel_report' => ['nullable', 'string'],
22
23
  'vehicle' => ['nullable', 'string'],
@@ -47,4 +48,66 @@ class CreateFuelTransactionRequest extends FleetbaseRequest
47
48
  'meta' => ['nullable', 'array'],
48
49
  ];
49
50
  }
51
+
52
+ public function messages(): array
53
+ {
54
+ return [
55
+ 'provider_transaction_id.unique' => 'A fuel transaction with this provider transaction id already exists for this provider.',
56
+ ];
57
+ }
58
+
59
+ /**
60
+ * Mirrors the fuel_provider_txn_company_provider_unique index.
61
+ *
62
+ * Provider transaction ids are natural idempotency keys, so a re-sent batch
63
+ * used to raise an unhandled UniqueConstraintViolationException — an HTTP 500
64
+ * rather than a duplicate signal the client could act on.
65
+ *
66
+ * @return \Illuminate\Validation\Rules\Unique
67
+ */
68
+ protected function uniqueProviderTransactionIdRule()
69
+ {
70
+ $provider = $this->resolveProvider();
71
+
72
+ $rule = Rule::unique('fuel_provider_transactions', 'provider_transaction_id')
73
+ ->where(function ($query) use ($provider) {
74
+ $query->where('company_uuid', session('company'));
75
+ $query->where('provider', $provider);
76
+
77
+ return $query->whereNull('deleted_at');
78
+ });
79
+
80
+ // On PUT the record re-sends its own provider_transaction_id. The v1 route
81
+ // parameter is a public_id (fuel_provider_transaction_xxxxx), not a uuid.
82
+ $id = $this->route('id');
83
+ if (!$this->isMethod('POST') && filled($id)) {
84
+ $rule->ignore($id, 'public_id');
85
+ }
86
+
87
+ return $rule;
88
+ }
89
+
90
+ /**
91
+ * The uniqueness scope is per provider, but `provider` is only required on
92
+ * POST — a PUT may change the transaction id while leaving the provider out of
93
+ * the body. Fall back to the stored value so the scope is never null, which
94
+ * would otherwise compare against the wrong set of rows.
95
+ */
96
+ protected function resolveProvider(): ?string
97
+ {
98
+ if ($this->filled('provider')) {
99
+ return $this->input('provider');
100
+ }
101
+
102
+ $id = $this->route('id');
103
+ if (blank($id)) {
104
+ return null;
105
+ }
106
+
107
+ return FuelProviderTransaction::where('company_uuid', session('company'))
108
+ ->where(function ($query) use ($id) {
109
+ $query->where('public_id', $id)->orWhere('uuid', $id);
110
+ })
111
+ ->value('provider');
112
+ }
50
113
  }
@@ -15,7 +15,7 @@ class CreatePartRequest extends FleetbaseRequest
15
15
  public function rules(): array
16
16
  {
17
17
  return [
18
- 'sku' => ['nullable', 'string'],
18
+ 'sku' => ['nullable', 'string', $this->uniqueSkuRule()],
19
19
  'name' => [Rule::requiredIf($this->isMethod('POST')), 'string'],
20
20
  'manufacturer' => ['nullable', 'string'],
21
21
  'model' => ['nullable', 'string'],
@@ -37,4 +37,42 @@ class CreatePartRequest extends FleetbaseRequest
37
37
  'meta' => ['nullable', 'array'],
38
38
  ];
39
39
  }
40
+
41
+ public function messages(): array
42
+ {
43
+ return [
44
+ 'sku.unique' => 'A part with this SKU already exists.',
45
+ ];
46
+ }
47
+
48
+ /**
49
+ * Mirrors the parts_company_uuid_active_sku_unique index.
50
+ *
51
+ * Without this rule a duplicate SKU reached the driver and came back as an
52
+ * unhandled UniqueConstraintViolationException — an HTTP 500 with no field
53
+ * attribution, since public v1 controllers have no QueryException catch.
54
+ *
55
+ * `nullable` runs first, so this never fires on a null SKU; that matches the
56
+ * nullable column and MySQL's tolerance of repeated NULLs in a unique index.
57
+ *
58
+ * @return \Illuminate\Validation\Rules\Unique
59
+ */
60
+ protected function uniqueSkuRule()
61
+ {
62
+ $rule = Rule::unique('parts', 'sku')
63
+ ->where(function ($query) {
64
+ $query->where('company_uuid', session('company'));
65
+
66
+ return $query->whereNull('deleted_at');
67
+ });
68
+
69
+ // On PUT the record re-sends its own SKU, so it must not collide with
70
+ // itself. The v1 route parameter is a public_id (part_xxxxx), not a uuid.
71
+ $id = $this->route('id');
72
+ if (!$this->isMethod('POST') && filled($id)) {
73
+ $rule->ignore($id, 'public_id');
74
+ }
75
+
76
+ return $rule;
77
+ }
40
78
  }
@@ -30,6 +30,17 @@ class TrackingNumber extends FleetbaseResource
30
30
  'status' => $this->last_status,
31
31
  'status_code' => $this->last_status_code,
32
32
  'qr_code' => $this->qr_code,
33
+ // The raw text encoded inside qr_code, exposed ONLY in debug mode.
34
+ //
35
+ // qr_code is a base64 PNG generated from owner_uuid (TrackingNumber::newBarcode),
36
+ // and the endpoints that consume a scanned code match on that uuid. A client
37
+ // scans the image to obtain it; an automated contract run cannot, so debug
38
+ // builds publish the value beside the image rather than the API growing a
39
+ // decode endpoint or handing out uuids generally.
40
+ //
41
+ // `when()` omits the key entirely when false — it is absent, not null — so a
42
+ // production response is byte-identical to before.
43
+ 'qr_code_content' => $this->when(static::exposesQrCodeContent(), fn () => $this->owner_uuid),
33
44
  'barcode' => $this->barcode,
34
45
  'url' => Utils::consoleUrl('track-order', ['order' => $this->tracking_number]),
35
46
  'type' => Utils::getTypeFromClassName($this->owner_type),
@@ -38,6 +49,28 @@ class TrackingNumber extends FleetbaseResource
38
49
  ];
39
50
  }
40
51
 
52
+ /**
53
+ * Whether the QR code's decoded content may be published.
54
+ *
55
+ * Debug mode only, and fails closed: any failure to determine the debug state — no
56
+ * container, an application without the accessor — answers false rather than
57
+ * defaulting to exposure.
58
+ */
59
+ protected static function exposesQrCodeContent(): bool
60
+ {
61
+ try {
62
+ $app = app();
63
+
64
+ if (!is_object($app) || !method_exists($app, 'hasDebugModeEnabled')) {
65
+ return false;
66
+ }
67
+
68
+ return (bool) $app->hasDebugModeEnabled();
69
+ } catch (\Throwable $e) {
70
+ return false;
71
+ }
72
+ }
73
+
41
74
  /**
42
75
  * Transform the resource into an webhook payload.
43
76
  *
@@ -6,6 +6,7 @@ use Fleetbase\Casts\Json;
6
6
  use Fleetbase\FleetOps\Casts\Point;
7
7
  use Fleetbase\FleetOps\Support\Utils;
8
8
  use Fleetbase\LaravelMysqlSpatial\Eloquent\SpatialTrait;
9
+ use Fleetbase\LaravelMysqlSpatial\Types\Point as SpatialPoint;
9
10
  use Fleetbase\Models\Model;
10
11
  use Fleetbase\Models\User;
11
12
  use Fleetbase\Traits\HasApiModelBehavior;
@@ -149,6 +150,26 @@ class FuelReport extends Model
149
150
  return $this->belongsTo(User::class, 'reported_by_uuid');
150
151
  }
151
152
 
153
+ /**
154
+ * Bootstrap the model.
155
+ *
156
+ * `location` is declared nullable in CreateFuelReportRequest but the column is
157
+ * POINT NOT NULL, so a report created without one failed outright with
158
+ * SQLSTATE[HY000] 1364 "Field 'location' doesn't have a default value" — a 500 for
159
+ * a request the validator had just accepted. Mirrors Device and Sensor, which
160
+ * default their spatial columns the same way.
161
+ */
162
+ protected static function boot()
163
+ {
164
+ parent::boot();
165
+
166
+ static::creating(function (self $fuelReport) {
167
+ if (empty($fuelReport->location)) {
168
+ $fuelReport->location = new SpatialPoint(0, 0);
169
+ }
170
+ });
171
+ }
172
+
152
173
  /**
153
174
  * Get the driver's name assigned to vehicle.
154
175
  *
@@ -147,6 +147,30 @@ class Sensor extends Model
147
147
  */
148
148
  protected $spatialFields = ['last_position'];
149
149
 
150
+ /**
151
+ * Bootstrap the model.
152
+ *
153
+ * The same migration that made devices.last_position NOT NULL did it to
154
+ * sensors.last_position too, but only Device got a default. Creating a sensor
155
+ * without a position therefore failed outright:
156
+ *
157
+ * SQLSTATE[HY000]: General error: 1364 Field 'last_position' doesn't have a
158
+ * default value
159
+ *
160
+ * Position is not something a caller has when registering a sensor, so default
161
+ * it to POINT(0,0) on create, exactly as Device does.
162
+ */
163
+ protected static function boot()
164
+ {
165
+ parent::boot();
166
+
167
+ static::creating(function (self $sensor) {
168
+ if (empty($sensor->last_position)) {
169
+ $sensor->last_position = new SpatialPoint(0, 0);
170
+ }
171
+ });
172
+ }
173
+
150
174
  /**
151
175
  * The attributes that should be cast to native types.
152
176
  *
@@ -173,8 +173,17 @@ class FuelProviderService
173
173
  return DB::transaction(function () use ($connection, $payload) {
174
174
  $provider = $payload['provider'] ?? $connection->provider;
175
175
  $providerTransactionId = $payload['provider_transaction_id'];
176
+ // The match must include company_uuid. Provider transaction ids are only
177
+ // unique within the account they were issued for, so keying on
178
+ // (provider, provider_transaction_id) alone let one company's sync find
179
+ // and overwrite another company's transaction — reassigning its
180
+ // company_uuid. Mirrors fuel_provider_txn_company_provider_unique.
176
181
  $transaction = FuelProviderTransaction::updateOrCreate(
177
- ['provider' => $provider, 'provider_transaction_id' => $providerTransactionId],
182
+ [
183
+ 'company_uuid' => $connection->company_uuid,
184
+ 'provider' => $provider,
185
+ 'provider_transaction_id' => $providerTransactionId,
186
+ ],
178
187
  array_merge($payload, [
179
188
  'company_uuid' => $connection->company_uuid,
180
189
  'fuel_provider_connection_uuid' => $connection->uuid,
@@ -237,7 +237,7 @@ Route::prefix(config('fleetops.api.routing.prefix'))->namespace('Fleetbase\Fleet
237
237
  $router->get('events', 'GeofenceController@events');
238
238
  $router->get('inventory', 'GeofenceController@inventory');
239
239
  $router->get('dwell-report', 'GeofenceController@dwellReport');
240
- $router->get('driver/{driverUuid}/history', 'GeofenceController@driverHistory');
240
+ $router->get('driver/{driverId}/history', 'GeofenceController@driverHistory');
241
241
  });
242
242
  // service-rates routes
243
243
  $router->group(['prefix' => 'service-rates'], function () use ($router) {