@fleetbase/fleetops-engine 0.6.59 → 0.6.61

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 (91) hide show
  1. package/RELEASE.md +25 -0
  2. package/addon/components/ai/route-preview-map.js +1 -5
  3. package/addon/components/customer/orders.js +2 -1
  4. package/addon/components/driver/card.hbs +49 -0
  5. package/addon/components/driver/card.js +30 -0
  6. package/addon/components/map/leaflet-live-map.js +4 -19
  7. package/addon/components/modals/place-details.hbs +1 -1
  8. package/addon/components/modals/point-map.hbs +1 -1
  9. package/addon/components/orchestrator-workbench.js +2 -1
  10. package/addon/components/order/form/route.js +3 -0
  11. package/addon/components/order-tracking-lookup.js +2 -1
  12. package/addon/components/place/details.hbs +1 -1
  13. package/addon/components/positions-replay.js +4 -1
  14. package/addon/components/service-area/details.hbs +1 -1
  15. package/addon/components/vehicle/details/schedules.hbs +2 -2
  16. package/addon/components/vehicle/details/schedules.js +7 -1
  17. package/addon/components/widget/live-fleet.js +7 -7
  18. package/addon/components/zone/details.hbs +1 -1
  19. package/addon/controllers/maintenance/schedules/index/edit.js +10 -1
  20. package/addon/controllers/management/drivers/index.js +22 -1
  21. package/addon/controllers/settings/map.js +16 -0
  22. package/addon/helpers/leaflet-tile-url.js +19 -0
  23. package/addon/services/map-settings.js +22 -0
  24. package/addon/services/vehicle-actions.js +4 -2
  25. package/addon/templates/management/drivers/index.hbs +67 -33
  26. package/addon/templates/settings/map.hbs +10 -0
  27. package/addon/utils/leaflet-tile-url.js +31 -0
  28. package/addon/utils/prepare-place-for-save.js +13 -0
  29. package/app/components/driver/card.js +1 -0
  30. package/app/helpers/leaflet-tile-url.js +1 -0
  31. package/app/utils/leaflet-tile-url.js +1 -0
  32. package/composer.json +1 -1
  33. package/extension.json +1 -1
  34. package/index.js +11 -0
  35. package/package.json +2 -1
  36. package/server/config/fleetops.php +44 -0
  37. package/server/config/geocoder.php +1 -1
  38. package/server/migrations/2026_08_08_000001_make_sku_and_provider_transaction_unique_indexes_soft_delete_aware.php +152 -0
  39. package/server/src/Http/Controllers/Api/v1/CustomerController.php +50 -4
  40. package/server/src/Http/Controllers/Api/v1/DriverController.php +277 -5
  41. package/server/src/Http/Controllers/Api/v1/GeofenceController.php +34 -2
  42. package/server/src/Http/Controllers/Api/v1/ManifestController.php +250 -0
  43. package/server/src/Http/Controllers/Api/v1/NavigatorController.php +14 -13
  44. package/server/src/Http/Controllers/Api/v1/TrackingNumberController.php +10 -1
  45. package/server/src/Http/Controllers/Api/v1/VehicleController.php +6 -0
  46. package/server/src/Http/Controllers/Concerns/ResolvesReviewAccountBypass.php +62 -0
  47. package/server/src/Http/Controllers/Internal/v1/DriverController.php +1 -1
  48. package/server/src/Http/Controllers/Internal/v1/SettingController.php +22 -0
  49. package/server/src/Http/Filter/FuelReportFilter.php +29 -0
  50. package/server/src/Http/Filter/IssueFilter.php +29 -0
  51. package/server/src/Http/Filter/MaintenanceScheduleFilter.php +40 -0
  52. package/server/src/Http/Requests/CreateCustomerRequest.php +9 -1
  53. package/server/src/Http/Requests/CreateFuelReportRequest.php +14 -3
  54. package/server/src/Http/Requests/CreateFuelTransactionRequest.php +64 -1
  55. package/server/src/Http/Requests/CreatePartRequest.php +39 -1
  56. package/server/src/Http/Requests/CreateVehicleRequest.php +10 -5
  57. package/server/src/Http/Resources/v1/Manifest.php +52 -0
  58. package/server/src/Http/Resources/v1/ManifestStop.php +47 -0
  59. package/server/src/Http/Resources/v1/OrderConfig.php +51 -4
  60. package/server/src/Http/Resources/v1/TrackingNumber.php +33 -0
  61. package/server/src/Models/FuelReport.php +21 -0
  62. package/server/src/Models/Place.php +1 -1
  63. package/server/src/Models/Sensor.php +24 -0
  64. package/server/src/Support/FuelProviders/FuelProviderService.php +10 -1
  65. package/server/src/routes.php +22 -1
  66. package/server/tests/ApiCustomerControllerContractsTest.php +168 -0
  67. package/server/tests/ApiDriverControllerContractsTest.php +65 -0
  68. package/server/tests/ApiDriverPasswordContractsTest.php +457 -0
  69. package/server/tests/ApiManifestControllerContractsTest.php +599 -0
  70. package/server/tests/ApiVehicleControllerContractsTest.php +35 -0
  71. package/server/tests/CompactResourceSerializationTest.php +99 -0
  72. package/server/tests/CustomerEndpointTest.php +4 -1
  73. package/server/tests/Feature/Http/Api/DriverControllerAuthFlowsTest.php +81 -2
  74. package/server/tests/Feature/Http/Api/DriverControllerSessionHelpersTest.php +61 -0
  75. package/server/tests/Feature/Http/Api/GeofenceControllerContractsTest.php +99 -2
  76. package/server/tests/Feature/Http/Api/TrackingNumberControllerHelpersTest.php +12 -6
  77. package/server/tests/Feature/Http/Internal/DriverControllerContractsTest.php +58 -0
  78. package/server/tests/Feature/Http/Internal/PlaceControllerTest.php +45 -0
  79. package/server/tests/FuelProviderTransactionControllerContractsTest.php +17 -0
  80. package/server/tests/RequestContractsTest.php +102 -5
  81. package/server/tests/SettingControllerContractsTest.php +33 -0
  82. package/server/tests/SmallControllerContractsTest.php +28 -4
  83. package/server/tests/Unit/Http/Filter/FuelReportFilterTest.php +135 -0
  84. package/server/tests/Unit/Http/Filter/IssueFilterTest.php +43 -0
  85. package/server/tests/Unit/Http/Filter/MaintenanceScheduleFilterTest.php +69 -0
  86. package/server/tests/Unit/Http/Requests/CreateFuelReportRequestTest.php +21 -0
  87. package/server/tests/Unit/Http/Resources/ManifestResourceTest.php +129 -0
  88. package/server/tests/Unit/Http/Resources/OrderConfigResourceTest.php +91 -0
  89. package/server/tests/Unit/Jobs/GeofenceDwellAndBulkNotifyTest.php +7 -0
  90. package/server/tests/Unit/Models/SensorLastPositionDefaultTest.php +130 -0
  91. package/translations/en-us.yaml +4 -0
@@ -39,6 +39,8 @@ use Illuminate\Support\Str;
39
39
 
40
40
  class DriverController extends Controller
41
41
  {
42
+ use \Fleetbase\FleetOps\Http\Controllers\Concerns\ResolvesReviewAccountBypass;
43
+
42
44
  /**
43
45
  * Creates a new Fleetbase Driver resource.
44
46
  *
@@ -164,8 +166,14 @@ class DriverController extends Controller
164
166
  // get request input
165
167
  $input = $request->except(['name', 'password', 'email', 'phone', 'location', 'altitude', 'heading', 'speed', 'meta']);
166
168
 
167
- // get user details for driver
168
- $userDetails = $request->only(['name', 'password', 'email', 'phone']);
169
+ /*
170
+ * Deliberately no `password` here. Setting one through a general update
171
+ * meant anyone holding a driver's token — or an unlocked handset —
172
+ * could take the account without proving they knew the existing
173
+ * password. Changing a password is its own operation with its own
174
+ * proof: see changePassword(), forgotPassword() and resetPassword().
175
+ */
176
+ $userDetails = $request->only(['name', 'email', 'phone']);
169
177
 
170
178
  // update driver user details
171
179
  $driverUser = $driver->getUser();
@@ -438,6 +446,15 @@ class DriverController extends Controller
438
446
  */
439
447
  public function registerDevice(?string $id = null, ?Request $request = null)
440
448
  {
449
+ // Laravel does NOT inject a class-typed parameter that declares a default value —
450
+ // RouteDependencyResolverTrait skips it and the default (null) is used. So the
451
+ // router always called this with $request === null, which made
452
+ // POST /v1/drivers/{id}/register-device fail with "Call to a member function
453
+ // input() on null" and POST /v1/drivers/register-device answer 404 (the driver was
454
+ // looked up by a null user_uuid). The default has to stay, because the internal
455
+ // controller delegates to this method with an id only.
456
+ $request = $request ?? request();
457
+
441
458
  try {
442
459
  // With an id (…/{id}/register-device) look the driver up directly; without
443
460
  // one (…/register-device and the internal delegation) resolve the driver
@@ -611,7 +628,7 @@ class DriverController extends Controller
611
628
 
612
629
  // find and verify code
613
630
  $verificationCode = VerificationCode::where(['subject_uuid' => $user->uuid, 'code' => $code, 'for' => $for])->exists();
614
- if (!$verificationCode && $code !== config('fleetops.navigator.bypass_verification_code')) {
631
+ if (!$verificationCode && !static::verificationBypassMatches($identity, $code)) {
615
632
  return response()->apiError('Invalid verification code!');
616
633
  }
617
634
 
@@ -924,7 +941,24 @@ class DriverController extends Controller
924
941
 
925
942
  protected function createUser(array $userDetails): User
926
943
  {
927
- return User::create($userDetails);
944
+ /*
945
+ * `password` is guarded on User, so mass assignment drops it without a
946
+ * word. The create endpoint has always accepted and validated one, so a
947
+ * driver created through the API could never sign in with the password
948
+ * their operator chose for them. Set it after the fact, where the
949
+ * model's mutator hashes it.
950
+ */
951
+ $password = $userDetails['password'] ?? null;
952
+ unset($userDetails['password']);
953
+
954
+ $user = User::create($userDetails);
955
+
956
+ if (is_string($password) && strlen($password)) {
957
+ $user->password = $password;
958
+ $user->save();
959
+ }
960
+
961
+ return $user;
928
962
  }
929
963
 
930
964
  protected function getUuid(array|string $table, array $where, array $options = []): mixed
@@ -960,7 +994,21 @@ class DriverController extends Controller
960
994
  */
961
995
  protected function currentDriver(?Request $request): Driver
962
996
  {
963
- return Driver::where('user_uuid', optional(optional($request)->user())->uuid)->firstOrFail();
997
+ // $request->user() is never populated on the public API: the `fleetbase.api`
998
+ // middleware calls Auth::setSession($apiCredential), which writes the session keys
999
+ // but leaves $login false, so no user resolver is ever bound. Reading it alone
1000
+ // meant POST /v1/drivers/register-device looked a driver up by a null user_uuid
1001
+ // and answered 404 for every consumer of the route.
1002
+ //
1003
+ // Auth::getUserFromSession() encodes this same order, but it also calls auth() and
1004
+ // session()->has(), neither of which exists in the SQLite test harness — so the
1005
+ // two sources are read directly here.
1006
+ $user = optional($request)->user();
1007
+ if (!$user instanceof User) {
1008
+ $user = User::where('uuid', session('user'))->first();
1009
+ }
1010
+
1011
+ return Driver::where('user_uuid', optional($user)->uuid)->firstOrFail();
964
1012
  }
965
1013
 
966
1014
  protected function queryDrivers(Request $request)
@@ -1035,6 +1083,36 @@ class DriverController extends Controller
1035
1083
  return $company;
1036
1084
  }
1037
1085
 
1086
+ /**
1087
+ * Whether the supplied code matches the configured testing bypass code.
1088
+ *
1089
+ * Three conditions, all required, mirroring the console equivalent in
1090
+ * Fleetbase\Http\Controllers\Internal\v1\AuthController::authenticateWithVerificationCode:
1091
+ *
1092
+ * - a bypass code must actually be configured. The previous
1093
+ * `$code !== config(...)` comparison meant that on a default install --
1094
+ * where the config resolves to null -- omitting `code` entirely made the
1095
+ * check `null !== null`, i.e. false, so the guard never fired and any
1096
+ * caller with a valid org API credential could mint a driver token for
1097
+ * any driver in the install without a code at all;
1098
+ * - the app must not be in production, so a code left set in a deployed
1099
+ * .env cannot be used against a live fleet;
1100
+ * - the comparison is constant-time.
1101
+ *
1102
+ * Shared with Internal\v1\DriverController so both verify-code paths cannot
1103
+ * drift apart.
1104
+ */
1105
+ public static function verificationBypassMatches(?string $identity, ?string $code): bool
1106
+ {
1107
+ return static::reviewAccountBypassMatches(
1108
+ 'fleetops.navigator.bypass_verification_code',
1109
+ 'fleetops.navigator.review_accounts',
1110
+ $identity,
1111
+ $code,
1112
+ 'navigator'
1113
+ );
1114
+ }
1115
+
1038
1116
  /**
1039
1117
  * Patches phone number with international code.
1040
1118
  */
@@ -1122,4 +1200,198 @@ class DriverController extends Controller
1122
1200
  }
1123
1201
  }
1124
1202
  }
1203
+
1204
+ /**
1205
+ * Change the authenticated driver's password, proving the current one.
1206
+ *
1207
+ * A password change is an authorisation decision, not an attribute update.
1208
+ * Requiring the existing password is what stops a borrowed handset or a
1209
+ * leaked token from becoming a permanent account takeover, and re-issuing
1210
+ * the caller's token afterwards is what stops every *other* session from
1211
+ * outliving the change.
1212
+ */
1213
+ public function changePassword(Request $request, string $id)
1214
+ {
1215
+ $current = $request->input('current_password');
1216
+ $password = $request->input('password');
1217
+
1218
+ if (!$current || !$password) {
1219
+ return response()->apiError('current_password and password are required.', 400);
1220
+ }
1221
+
1222
+ if (strlen($password) < 8) {
1223
+ return response()->apiError('Password must be at least 8 characters.', 400);
1224
+ }
1225
+
1226
+ if ($request->filled('password_confirmation') && $request->input('password_confirmation') !== $password) {
1227
+ return response()->apiError('Password confirmation does not match.', 422);
1228
+ }
1229
+
1230
+ try {
1231
+ $driver = $this->findDriver($id, ['user']);
1232
+ } catch (\Illuminate\Database\Eloquent\ModelNotFoundException $exception) {
1233
+ return response()->apiError('Driver resource not found.', 404);
1234
+ }
1235
+
1236
+ $user = static::findDriverUserRecord($driver);
1237
+ if (!$user) {
1238
+ return response()->apiError('Driver has no user account.', 422);
1239
+ }
1240
+
1241
+ if (!static::passwordMatches($user, $current)) {
1242
+ // Same wording whether the password was wrong or the account is odd
1243
+ // — a change endpoint should not become an oracle.
1244
+ return response()->apiError('The current password is incorrect.', 422);
1245
+ }
1246
+
1247
+ $user->password = $password;
1248
+ $user->save();
1249
+
1250
+ /*
1251
+ * Every other session dies with the old password. The caller keeps
1252
+ * working by being handed a fresh token in the same response, so a
1253
+ * driver who changes their password mid-shift is not signed out of the
1254
+ * device in their hand.
1255
+ */
1256
+ $user->tokens()->delete();
1257
+ $token = $user->createToken($request->input('device_name', 'navigator'));
1258
+
1259
+ return response()->json([
1260
+ 'status' => 'ok',
1261
+ 'token' => $token->plainTextToken,
1262
+ ]);
1263
+ }
1264
+
1265
+ /**
1266
+ * Send a password reset code to a driver who cannot sign in.
1267
+ *
1268
+ * Answers identically whether or not the identity exists: a reset endpoint
1269
+ * that 404s on an unknown phone number is a way to enumerate a company's
1270
+ * drivers.
1271
+ */
1272
+ public function forgotPassword(Request $request)
1273
+ {
1274
+ $identity = $request->input('identity');
1275
+ if (!$identity) {
1276
+ return response()->apiError('Identity is required.', 400);
1277
+ }
1278
+
1279
+ $user = static::findDriverUserByIdentity($identity);
1280
+ if (!$user) {
1281
+ return response()->json(['status' => 'ok']);
1282
+ }
1283
+
1284
+ try {
1285
+ static::sendResetCode($user, $identity);
1286
+ } catch (\Throwable $e) {
1287
+ return response()->apiError(static::debugEnabled() ? $e->getMessage() : 'Unable to send reset code.');
1288
+ }
1289
+
1290
+ return response()->json(['status' => 'ok']);
1291
+ }
1292
+
1293
+ /**
1294
+ * Set a new password using a code sent by forgotPassword().
1295
+ */
1296
+ public function resetPassword(Request $request)
1297
+ {
1298
+ $identity = $request->input('identity');
1299
+ $code = $request->input('code');
1300
+ $password = $request->input('password');
1301
+
1302
+ if (!$identity || !$code || !$password) {
1303
+ return response()->apiError('identity, code, and password are required.', 400);
1304
+ }
1305
+
1306
+ if (strlen($password) < 8) {
1307
+ return response()->apiError('Password must be at least 8 characters.', 400);
1308
+ }
1309
+
1310
+ $user = static::findDriverUserByIdentity($identity);
1311
+ if (!$user) {
1312
+ return response()->apiError('Invalid or expired reset code.', 422);
1313
+ }
1314
+
1315
+ $verification = static::findResetCode($user, $code);
1316
+
1317
+ if (!$verification) {
1318
+ // One message for a wrong code, an expired code and an unknown
1319
+ // identity alike.
1320
+ return response()->apiError('Invalid or expired reset code.', 422);
1321
+ }
1322
+
1323
+ $user->password = $password;
1324
+ $user->save();
1325
+
1326
+ $verification->delete();
1327
+ $user->tokens()->delete();
1328
+
1329
+ return response()->json(['status' => 'ok']);
1330
+ }
1331
+
1332
+ /** Whether the application is in debug mode, so an error may be echoed. */
1333
+ protected static function debugEnabled(): bool
1334
+ {
1335
+ return app()->hasDebugModeEnabled();
1336
+ }
1337
+
1338
+ /**
1339
+ * Load a driver's user account with every column.
1340
+ *
1341
+ * The `user` relation selects a named subset of columns, and `password` is
1342
+ * not among them — reading it through the relation yields an empty string,
1343
+ * so a password comparison would fail for everyone regardless of what they
1344
+ * typed. Anything checking a password has to load the record itself.
1345
+ */
1346
+ protected static function findDriverUserRecord(Driver $driver): ?User
1347
+ {
1348
+ if (!$driver->user_uuid) {
1349
+ return null;
1350
+ }
1351
+
1352
+ return User::where('uuid', $driver->user_uuid)->first();
1353
+ }
1354
+
1355
+ /** Whether a plaintext password matches the stored hash. */
1356
+ protected static function passwordMatches(User $user, string $plain): bool
1357
+ {
1358
+ return Hash::check($plain, $user->password);
1359
+ }
1360
+
1361
+ /** The unexpired reset code for this user, if the one supplied matches. */
1362
+ protected static function findResetCode(User $user, string $code)
1363
+ {
1364
+ return VerificationCode::where([
1365
+ 'subject_uuid' => $user->uuid,
1366
+ 'code' => $code,
1367
+ 'for' => 'driver_password_reset',
1368
+ ])->where('expires_at', '>', now())->first();
1369
+ }
1370
+
1371
+ /** Sends the reset code by whichever channel the identity names. */
1372
+ protected static function sendResetCode(User $user, string $identity): void
1373
+ {
1374
+ // if/else rather than an early return: a `return` sitting after a call
1375
+ // that can throw is a line no test can reach.
1376
+ if (Utils::isEmail($identity)) {
1377
+ VerificationCode::generateEmailVerificationFor($user, 'driver_password_reset', [
1378
+ 'subject' => config('app.name') . ' password reset',
1379
+ 'messageCallback' => fn ($v) => 'Your ' . config('app.name') . ' password reset code is ' . $v->code,
1380
+ ]);
1381
+ } else {
1382
+ VerificationCode::generateSmsVerificationFor($user, 'driver_password_reset', [
1383
+ 'messageCallback' => fn ($v) => 'Your ' . config('app.name') . ' password reset code is ' . $v->code,
1384
+ ]);
1385
+ }
1386
+ }
1387
+
1388
+ /** Resolve a driver's user account from an email address or a phone number. */
1389
+ protected static function findDriverUserByIdentity(string $identity): ?User
1390
+ {
1391
+ $isEmail = Utils::isEmail($identity);
1392
+ $column = $isEmail ? 'email' : 'phone';
1393
+ $value = $isEmail ? $identity : static::phone($identity);
1394
+
1395
+ return User::whereHas('driver')->where($column, $value)->first();
1396
+ }
1125
1397
  }
@@ -4,6 +4,7 @@ namespace Fleetbase\FleetOps\Http\Controllers\Api\v1;
4
4
 
5
5
  use Fleetbase\FleetOps\Models\GeofenceEventLog;
6
6
  use Fleetbase\FleetOps\Support\Utils;
7
+ use Fleetbase\FleetOps\Models\Driver;
7
8
  use Fleetbase\Http\Controllers\Controller;
8
9
  use Illuminate\Http\JsonResponse;
9
10
  use Illuminate\Http\Request;
@@ -201,13 +202,22 @@ class GeofenceController extends Controller
201
202
  *
202
203
  * Returns the geofence event history for a specific driver.
203
204
  */
204
- public function driverHistory(Request $request, string $driverUuid): JsonResponse
205
+ public function driverHistory(Request $request, string $driverId): JsonResponse
205
206
  {
206
207
  $companyUuid = session('company');
207
208
  $perPage = min((int) $request->input('per_page', 50), 200);
208
209
 
210
+ // Addressed by public_id, like every other public endpoint. The route parameter
211
+ // was named driverUuid and matched against driver_uuid directly, so this was the
212
+ // one place in the public API asking a caller for an internal identifier — which
213
+ // the API does not hand out in the first place, making it unusable.
214
+ $driver = $this->findDriverForHistory($driverId);
215
+ if (!$driver) {
216
+ return response()->json(['error' => 'Driver resource not found.'], 404);
217
+ }
218
+
209
219
  $events = $this->geofenceEventLogQuery($companyUuid)
210
- ->where('driver_uuid', $driverUuid)
220
+ ->where('driver_uuid', $driver->uuid)
211
221
  ->with(['driver.vehicle', 'vehicle', 'order'])
212
222
  ->orderBy('occurred_at', 'desc')
213
223
  ->paginate($perPage);
@@ -217,6 +227,28 @@ class GeofenceController extends Controller
217
227
  return response()->json($events);
218
228
  }
219
229
 
230
+ /**
231
+ * Resolve the driver a history request addresses.
232
+ *
233
+ * Public callers use the public_id, like every other v1 endpoint. The console reaches
234
+ * the same method through the internal route with a uuid, so that is accepted there
235
+ * and ONLY there — the public contract stays public_id-only rather than quietly
236
+ * taking either.
237
+ *
238
+ * A seam, like geofenceEventLogQuery above, so the contract tests can drive this
239
+ * without a database.
240
+ */
241
+ protected function findDriverForHistory(string $driverId): ?Driver
242
+ {
243
+ $driver = Driver::where('public_id', $driverId)->first();
244
+
245
+ if (!$driver && \Fleetbase\Support\Http::isInternalRequest()) {
246
+ $driver = Driver::where('uuid', $driverId)->first();
247
+ }
248
+
249
+ return $driver;
250
+ }
251
+
220
252
  protected function serializeEvent(GeofenceEventLog $event): array
221
253
  {
222
254
  $driver = $event->driver;
@@ -0,0 +1,250 @@
1
+ <?php
2
+
3
+ namespace Fleetbase\FleetOps\Http\Controllers\Api\v1;
4
+
5
+ use Fleetbase\FleetOps\Http\Resources\v1\Manifest as ManifestResource;
6
+ use Fleetbase\FleetOps\Http\Resources\v1\ManifestStop as ManifestStopResource;
7
+ use Fleetbase\FleetOps\Models\Driver;
8
+ use Fleetbase\FleetOps\Models\Manifest;
9
+ use Fleetbase\FleetOps\Models\ManifestStop;
10
+ use Fleetbase\FleetOps\Support\Utils;
11
+ use Fleetbase\Http\Controllers\Controller;
12
+ use Illuminate\Http\Request;
13
+
14
+ /**
15
+ * Driver-facing manifests.
16
+ *
17
+ * A manifest is a driver's route: an order-agnostic sequence of stops, which
18
+ * may span several orders or none the driver has seen as an order. Everything a
19
+ * driver app needs to *run* a route lived only behind the console's internal
20
+ * namespace, so a driver could be assigned a route they had no way to read.
21
+ *
22
+ * These endpoints are deliberately narrow. A driver may read their own
23
+ * manifests, update the stops on them, and re-sequence the ones they have not
24
+ * done yet. They may not create, cancel or delete a manifest — that is dispatch
25
+ * work, and it stays on the internal namespace.
26
+ */
27
+ class ManifestController extends Controller
28
+ {
29
+ /**
30
+ * GET /v1/drivers/{id}/manifests.
31
+ *
32
+ * Defaults to what a driver actually needs on shift — today and anything
33
+ * still unfinished — rather than their whole history, which on a busy fleet
34
+ * is thousands of rows nobody asked for.
35
+ */
36
+ public function forDriver(Request $request, string $id)
37
+ {
38
+ $driver = static::findDriverRecord($id);
39
+ if (!$driver) {
40
+ return response()->apiError('Driver resource not found.', 404);
41
+ }
42
+
43
+ $query = static::manifestsFor($driver);
44
+
45
+ if ($request->filled('status')) {
46
+ $query->whereIn('status', Utils::arrayFrom($request->input('status')));
47
+ }
48
+
49
+ if ($request->filled('on')) {
50
+ $query->whereDate('scheduled_date', $request->input('on'));
51
+ }
52
+
53
+ return ManifestResource::collection($query->limit((int) $request->input('limit', 30))->get());
54
+ }
55
+
56
+ /**
57
+ * GET /v1/manifests/{id} — the route, with its stops in sequence.
58
+ */
59
+ public function show(string $id)
60
+ {
61
+ $manifest = static::findManifest($id);
62
+ if (!$manifest) {
63
+ return response()->apiError('Manifest resource not found.', 404);
64
+ }
65
+
66
+ $manifest->load(['driver', 'vehicle', 'stops' => fn ($q) => $q->orderBy('sequence')->with(['place', 'order.trackingNumber'])]);
67
+
68
+ return new ManifestResource($manifest);
69
+ }
70
+
71
+ /**
72
+ * PATCH /v1/manifest-stops/{id}.
73
+ *
74
+ * Status changes go through the model's own transitions rather than being
75
+ * written as a column, because arriving and completing carry side effects —
76
+ * timestamps, and the manifest completing itself once its last stop does.
77
+ */
78
+ public function updateStop(Request $request, string $id)
79
+ {
80
+ $stop = static::findStop($id);
81
+ if (!$stop) {
82
+ return response()->apiError('Manifest stop resource not found.', 404);
83
+ }
84
+
85
+ $status = $request->input('status');
86
+ if ($status) {
87
+ // Checked before it is applied: an unknown status must change
88
+ // nothing, not fall through a match and then be refused.
89
+ if (!in_array($status, ['arrived', 'completed', 'skipped'], true)) {
90
+ return response()->apiError('Status must be one of: arrived, completed, skipped.', 422);
91
+ }
92
+
93
+ match ($status) {
94
+ 'arrived' => $stop->markArrived(),
95
+ 'completed' => $stop->markCompleted(),
96
+ default => $stop->markSkipped(),
97
+ };
98
+ }
99
+
100
+ if ($request->filled('meta')) {
101
+ $stop->update(['meta' => $request->input('meta')]);
102
+ }
103
+
104
+ $stop->load(['place', 'order.trackingNumber']);
105
+
106
+ return new ManifestStopResource($stop);
107
+ }
108
+
109
+ /**
110
+ * POST /v1/manifests/{id}/optimize — re-sequence the stops still to do.
111
+ *
112
+ * This is the *driver's* optimise, not the orchestrator's. The orchestrator
113
+ * allocates orders across a fleet and produces manifests; this reorders the
114
+ * stops on one manifest that is already assigned, which is a different job
115
+ * with a different owner.
116
+ *
117
+ * **It is a nearest-neighbour heuristic, not a solved routing problem.** It
118
+ * walks from the driver's current position to the closest remaining stop,
119
+ * then to the closest from there, by straight-line distance. That is
120
+ * typically a large improvement over an arbitrary order and is not
121
+ * guaranteed optimal; calling it anything stronger would be a claim the
122
+ * implementation does not support. Completed and skipped stops keep their
123
+ * place — a route already driven is not re-planned.
124
+ */
125
+ public function optimize(Request $request, string $id)
126
+ {
127
+ $manifest = static::findManifest($id);
128
+ if (!$manifest) {
129
+ return response()->apiError('Manifest resource not found.', 404);
130
+ }
131
+
132
+ $manifest->load(['stops' => fn ($q) => $q->orderBy('sequence')->with('place')]);
133
+
134
+ $done = $manifest->stops->filter(fn ($s) => in_array($s->status, ['completed', 'skipped'], true))->values();
135
+ $pending = $manifest->stops->reject(fn ($s) => in_array($s->status, ['completed', 'skipped'], true))->values();
136
+
137
+ if ($pending->count() < 3) {
138
+ // Two stops have only one order, and one has none. Re-sequencing
139
+ // either is work with no possible result.
140
+ return $this->show($id);
141
+ }
142
+
143
+ $from = $request->filled(['latitude', 'longitude'])
144
+ ? ['lat' => (float) $request->input('latitude'), 'lon' => (float) $request->input('longitude')]
145
+ : static::coordinatesOf($pending->first());
146
+
147
+ $remaining = $pending->all();
148
+ $ordered = [];
149
+
150
+ while (count($remaining)) {
151
+ $closestIndex = 0;
152
+ $closestCost = null;
153
+
154
+ foreach ($remaining as $index => $stop) {
155
+ $to = static::coordinatesOf($stop);
156
+ if (!$from || !$to) {
157
+ continue;
158
+ }
159
+
160
+ $cost = static::distanceBetween($from, $to);
161
+
162
+ if ($closestCost === null || $cost < $closestCost) {
163
+ $closestCost = $cost;
164
+ $closestIndex = $index;
165
+ }
166
+ }
167
+
168
+ $next = $remaining[$closestIndex];
169
+ $ordered[] = $next;
170
+ $from = static::coordinatesOf($next) ?? $from;
171
+ unset($remaining[$closestIndex]);
172
+ $remaining = array_values($remaining);
173
+ }
174
+
175
+ // Done stops keep the front of the sequence; the rest follow in the new
176
+ // order. Sequence stays dense so nothing downstream has to cope with
177
+ // gaps.
178
+ $sequence = 1;
179
+ foreach ($done as $stop) {
180
+ $stop->update(['sequence' => $sequence++]);
181
+ }
182
+ foreach ($ordered as $stop) {
183
+ $stop->update(['sequence' => $sequence++]);
184
+ }
185
+
186
+ return $this->show($id);
187
+ }
188
+
189
+ /** The driver's manifests, newest first. Separated so it can be stubbed. */
190
+ protected static function manifestsFor(Driver $driver)
191
+ {
192
+ return Manifest::where('driver_uuid', $driver->uuid)
193
+ ->with(['driver', 'vehicle'])
194
+ ->orderBy('scheduled_date', 'desc');
195
+ }
196
+
197
+ protected static function findDriverRecord(string $id): ?Driver
198
+ {
199
+ return Driver::where('public_id', $id)->orWhere('uuid', $id)->first();
200
+ }
201
+
202
+ protected static function findManifest(string $id): ?Manifest
203
+ {
204
+ return Manifest::where('public_id', $id)->orWhere('uuid', $id)->first();
205
+ }
206
+
207
+ protected static function findStop(string $id): ?ManifestStop
208
+ {
209
+ return ManifestStop::where('public_id', $id)->orWhere('uuid', $id)->first();
210
+ }
211
+
212
+ /**
213
+ * Straight-line distance between two coordinates, in metres.
214
+ *
215
+ * Deliberately not a routing call. A nearest-neighbour walk compares every
216
+ * remaining stop at every step, so a twenty-stop route asks roughly four
217
+ * hundred distance questions — as road lookups that is four hundred network
218
+ * round trips for one tap, which is not a thing to do on a handset waiting
219
+ * on a driver.
220
+ *
221
+ * Straight-line ordering and road ordering rarely disagree about which of
222
+ * several stops is nearest, and where they do the result is still a valid
223
+ * route, just not the shortest one. Since the method is a heuristic either
224
+ * way, the cheap version is the honest choice.
225
+ */
226
+ protected static function distanceBetween(array $from, array $to): float
227
+ {
228
+ $earthRadius = 6371000.0;
229
+
230
+ $lat1 = deg2rad($from['lat']);
231
+ $lat2 = deg2rad($to['lat']);
232
+ $dLat = $lat2 - $lat1;
233
+ $dLon = deg2rad($to['lon'] - $from['lon']);
234
+
235
+ $a = sin($dLat / 2) ** 2 + cos($lat1) * cos($lat2) * sin($dLon / 2) ** 2;
236
+
237
+ return $earthRadius * 2 * atan2(sqrt($a), sqrt(1 - $a));
238
+ }
239
+
240
+ /** Latitude and longitude of a stop's place, when it has one. */
241
+ protected static function coordinatesOf(?ManifestStop $stop): ?array
242
+ {
243
+ $location = data_get($stop, 'place.location');
244
+ if (!$location) {
245
+ return null;
246
+ }
247
+
248
+ return ['lat' => (float) $location->getLat(), 'lon' => (float) $location->getLng()];
249
+ }
250
+ }
@@ -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
  }