@fleetbase/fleetops-engine 0.6.48 → 0.6.50

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 (266) hide show
  1. package/addon/components/customer/create-order-form.hbs +2 -2
  2. package/addon/components/customer/form.hbs +2 -2
  3. package/addon/components/customer/order-form.hbs +2 -2
  4. package/addon/components/customer/orders.hbs +2 -2
  5. package/addon/components/display-place.hbs +2 -2
  6. package/addon/components/driver/details.hbs +5 -1
  7. package/addon/components/driver/form.hbs +11 -1
  8. package/addon/components/fleet-listing-panel.hbs +2 -2
  9. package/addon/components/fleet-listing-panel.js +10 -0
  10. package/addon/components/map/google-live-map.js +18 -75
  11. package/addon/components/map/leaflet-live-map.hbs +187 -37
  12. package/addon/components/map/leaflet-live-map.js +184 -20
  13. package/addon/components/modals/order-change-destination.hbs +46 -0
  14. package/addon/components/modals/order-change-destination.js +106 -0
  15. package/addon/components/modals/stop-activity.hbs +28 -0
  16. package/addon/components/modals/stop-activity.js +52 -0
  17. package/addon/components/modals/update-order-activity.hbs +45 -3
  18. package/addon/components/modals/update-order-activity.js +143 -5
  19. package/addon/components/orchestrator/phase-builder.hbs +21 -0
  20. package/addon/components/orchestrator/phase-builder.js +13 -0
  21. package/addon/components/orchestrator/resource-panel.hbs +0 -10
  22. package/addon/components/orchestrator/resource-panel.js +2 -4
  23. package/addon/components/orchestrator-workbench.js +1 -0
  24. package/addon/components/order/activity-list.hbs +2 -2
  25. package/addon/components/order/activity-timeline.hbs +31 -29
  26. package/addon/components/order/details/activity.hbs +3 -3
  27. package/addon/components/order/details/activity.js +11 -0
  28. package/addon/components/order/details/documents.js +2 -0
  29. package/addon/components/order/details/purchase-rate.hbs +2 -2
  30. package/addon/components/order/details/route.hbs +11 -3
  31. package/addon/components/order/details/route.js +91 -14
  32. package/addon/components/order/details/tracking.hbs +101 -72
  33. package/addon/components/order/details/tracking.js +149 -2
  34. package/addon/components/order/form/details.hbs +3 -3
  35. package/addon/components/order/form/documents.js +11 -4
  36. package/addon/components/order/form/route.hbs +108 -104
  37. package/addon/components/order/form/route.js +37 -1
  38. package/addon/components/order/form/service-rate.hbs +2 -2
  39. package/addon/components/order/form.hbs +2 -2
  40. package/addon/components/order-config-manager/activity-flow.hbs +4 -1
  41. package/addon/components/order-config-manager/activity-flow.js +178 -12
  42. package/addon/components/place/address.js +1 -1
  43. package/addon/components/route-list.hbs +17 -17
  44. package/addon/components/route-list.js +42 -10
  45. package/addon/components/service-rate/details.hbs +41 -0
  46. package/addon/components/service-rate/form.hbs +118 -0
  47. package/addon/components/service-rate/form.js +55 -0
  48. package/addon/components/vehicle/form.js +1 -1
  49. package/addon/components/vehicle/pill.hbs +1 -1
  50. package/addon/components/widget/fleet-ops-key-metrics.hbs +19 -17
  51. package/addon/components/widget/fleet-ops-key-metrics.js +26 -0
  52. package/addon/components/widget/fuel-efficiency.hbs +26 -0
  53. package/addon/components/widget/fuel-efficiency.js +43 -0
  54. package/addon/components/widget/geofence-violations.hbs +44 -0
  55. package/addon/components/widget/geofence-violations.js +55 -0
  56. package/addon/components/widget/issues-insights.hbs +43 -0
  57. package/addon/components/widget/issues-insights.js +62 -0
  58. package/addon/components/widget/kpi-active-orders.hbs +10 -0
  59. package/addon/components/widget/kpi-active-orders.js +5 -0
  60. package/addon/components/widget/kpi-aov.hbs +10 -0
  61. package/addon/components/widget/kpi-aov.js +5 -0
  62. package/addon/components/widget/kpi-distance.hbs +10 -0
  63. package/addon/components/widget/kpi-distance.js +5 -0
  64. package/addon/components/widget/kpi-drivers-online.hbs +10 -0
  65. package/addon/components/widget/kpi-drivers-online.js +5 -0
  66. package/addon/components/widget/kpi-earnings.hbs +10 -0
  67. package/addon/components/widget/kpi-earnings.js +5 -0
  68. package/addon/components/widget/kpi-open-issues.hbs +11 -0
  69. package/addon/components/widget/kpi-open-issues.js +5 -0
  70. package/addon/components/widget/kpi-tile.hbs +59 -0
  71. package/addon/components/widget/kpi-tile.js +168 -0
  72. package/addon/components/widget/live-fleet.hbs +122 -0
  73. package/addon/components/widget/live-fleet.js +365 -0
  74. package/addon/components/widget/maintenance-overview.hbs +45 -0
  75. package/addon/components/widget/maintenance-overview.js +40 -0
  76. package/addon/components/widget/on-time-delivery.hbs +27 -0
  77. package/addon/components/widget/on-time-delivery.js +73 -0
  78. package/addon/components/widget/operations-pulse.hbs +51 -0
  79. package/addon/components/widget/operations-pulse.js +173 -0
  80. package/addon/components/widget/orders-by-status.hbs +26 -0
  81. package/addon/components/widget/orders-by-status.js +54 -0
  82. package/addon/components/widget/revenue-trend.hbs +34 -0
  83. package/addon/components/widget/revenue-trend.js +67 -0
  84. package/addon/components/widget/top-drivers.hbs +44 -0
  85. package/addon/components/widget/top-drivers.js +54 -0
  86. package/addon/controllers/management/vehicles/index/new.js +1 -1
  87. package/addon/controllers/operations/orders/index/details.js +4 -4
  88. package/addon/controllers/operations/orders/index.js +14 -0
  89. package/addon/controllers/settings/routing.js +26 -0
  90. package/addon/extension.js +172 -3
  91. package/addon/helpers/place-address-html.js +2 -2
  92. package/addon/routes/operations/service-rates/index.js +1 -1
  93. package/addon/services/geofence.js +163 -19
  94. package/addon/services/leaflet-contextmenu-manager.js +27 -0
  95. package/addon/services/map-adapter/google.js +13 -2
  96. package/addon/services/vehicle-actions.js +1 -1
  97. package/addon/styles/fleetops-engine.css +205 -88
  98. package/addon/templates/maintenance/equipment/index.hbs +1 -1
  99. package/addon/templates/maintenance/parts/index.hbs +1 -1
  100. package/addon/templates/maintenance/schedules/index.hbs +1 -1
  101. package/addon/templates/management/vehicles/index.hbs +1 -1
  102. package/addon/templates/settings/avatars.hbs +1 -1
  103. package/addon/templates/settings/custom-fields.hbs +1 -1
  104. package/addon/templates/settings/map.hbs +1 -1
  105. package/addon/templates/settings/navigator-app.hbs +1 -1
  106. package/addon/templates/settings/notifications.hbs +1 -1
  107. package/addon/templates/settings/orchestrator.hbs +1 -1
  108. package/addon/templates/settings/payments/index.hbs +1 -1
  109. package/addon/templates/settings/payments/onboard.hbs +1 -1
  110. package/addon/templates/settings/routing.hbs +22 -2
  111. package/addon/templates/settings/scheduling.hbs +1 -1
  112. package/addon/utils/fleet-ops-options.js +2 -1
  113. package/addon/utils/live-map-card-content.js +171 -0
  114. package/addon/utils/normalize-order-config-flow.js +41 -0
  115. package/addon/utils/place-address-html.js +3 -2
  116. package/addon/utils/route-visualization.js +54 -7
  117. package/app/components/modals/order-change-destination.js +1 -0
  118. package/app/components/modals/stop-activity.js +1 -0
  119. package/app/components/widget/fuel-efficiency.js +1 -0
  120. package/app/components/widget/geofence-violations.js +1 -0
  121. package/app/components/widget/issues-insights.js +1 -0
  122. package/app/components/widget/kpi-active-orders.js +1 -0
  123. package/app/components/widget/kpi-aov.js +1 -0
  124. package/app/components/widget/kpi-distance.js +1 -0
  125. package/app/components/widget/kpi-drivers-online.js +1 -0
  126. package/app/components/widget/kpi-earnings.js +1 -0
  127. package/app/components/widget/kpi-open-issues.js +1 -0
  128. package/app/components/widget/kpi-tile.js +1 -0
  129. package/app/components/widget/live-fleet.js +1 -0
  130. package/app/components/widget/maintenance-overview.js +1 -0
  131. package/app/components/widget/on-time-delivery.js +1 -0
  132. package/app/components/widget/operations-pulse.js +1 -0
  133. package/app/components/widget/orders-by-status.js +1 -0
  134. package/app/components/widget/revenue-trend.js +1 -0
  135. package/app/components/widget/top-drivers.js +1 -0
  136. package/app/utils/live-map-card-content.js +1 -0
  137. package/composer.json +1 -1
  138. package/extension.json +1 -1
  139. package/package.json +3 -3
  140. package/pnpm-workspace.yaml +1 -0
  141. package/server/config/telematics.php +111 -1
  142. package/server/migrations/2026_05_18_000001_add_license_expiry_to_drivers_table.php +25 -0
  143. package/server/migrations/2026_05_21_000001_add_geographic_rules_to_service_rate_fees_table.php +37 -0
  144. package/server/migrations/2026_05_25_000001_add_endpoint_tracking_refs_to_payloads_table.php +50 -0
  145. package/server/migrations/2026_05_27_000001_default_driver_vehicle_statuses_to_available.php +48 -0
  146. package/server/resources/views/labels/default.php +57 -49
  147. package/server/resources/views/labels/waypoint-label.php +27 -20
  148. package/server/seeders/Testing/Concerns/SeedsTestingData.php +183 -0
  149. package/server/seeders/Testing/ConnectivitySeeder.php +213 -0
  150. package/server/seeders/Testing/FleetSeeder.php +341 -0
  151. package/server/seeders/Testing/MaintenanceSeeder.php +238 -0
  152. package/server/seeders/Testing/NetworkSeeder.php +250 -0
  153. package/server/seeders/Testing/OrdersSeeder.php +198 -0
  154. package/server/seeders/Testing/TestingSeeder.php +40 -0
  155. package/server/seeders/{VroomOrchestrationSeeder.php → Testing/VroomOrchestrationSeeder.php} +29 -22
  156. package/server/src/Console/Commands/AuditCustomerUserConflicts.php +75 -0
  157. package/server/src/Console/Commands/ProcessOperationalAlerts.php +279 -0
  158. package/server/src/Exceptions/CustomerUserConflictException.php +13 -0
  159. package/server/src/Http/Controllers/Api/v1/ContactController.php +17 -2
  160. package/server/src/Http/Controllers/Api/v1/CustomerController.php +876 -0
  161. package/server/src/Http/Controllers/Api/v1/DriverController.php +1 -1
  162. package/server/src/Http/Controllers/Api/v1/OrderConfigController.php +54 -0
  163. package/server/src/Http/Controllers/Api/v1/OrderController.php +98 -70
  164. package/server/src/Http/Controllers/Api/v1/ServiceAreaController.php +2 -2
  165. package/server/src/Http/Controllers/Api/v1/VehicleController.php +20 -3
  166. package/server/src/Http/Controllers/Internal/v1/AnalyticsController.php +119 -0
  167. package/server/src/Http/Controllers/Internal/v1/ContactController.php +19 -0
  168. package/server/src/Http/Controllers/Internal/v1/LiveController.php +101 -66
  169. package/server/src/Http/Controllers/Internal/v1/MetricsController.php +82 -3
  170. package/server/src/Http/Controllers/Internal/v1/OrderController.php +441 -32
  171. package/server/src/Http/Controllers/Internal/v1/ServiceQuoteController.php +1 -1
  172. package/server/src/Http/Controllers/Internal/v1/SettingController.php +46 -1
  173. package/server/src/Http/Controllers/Internal/v1/VehicleController.php +54 -1
  174. package/server/src/Http/Middleware/AuthenticateCustomerToken.php +34 -0
  175. package/server/src/Http/Requests/CreateCustomerOrderRequest.php +63 -0
  176. package/server/src/Http/Requests/CreateCustomerRequest.php +48 -0
  177. package/server/src/Http/Requests/CreateDriverRequest.php +1 -1
  178. package/server/src/Http/Requests/CreateVehicleRequest.php +25 -1
  179. package/server/src/Http/Requests/Internal/CreateDriverRequest.php +1 -1
  180. package/server/src/Http/Requests/VerifyCreateCustomerRequest.php +29 -0
  181. package/server/src/Http/Resources/Internal/v1/OrderConfig.php +41 -0
  182. package/server/src/Http/Resources/v1/Customer.php +79 -0
  183. package/server/src/Http/Resources/v1/Driver.php +2 -0
  184. package/server/src/Http/Resources/v1/Fleet.php +4 -0
  185. package/server/src/Http/Resources/v1/Index/Driver.php +48 -0
  186. package/server/src/Http/Resources/v1/Index/Vehicle.php +74 -1
  187. package/server/src/Http/Resources/v1/OrderConfig.php +74 -0
  188. package/server/src/Http/Resources/v1/Payload.php +23 -20
  189. package/server/src/Http/Resources/v1/ServiceArea.php +33 -19
  190. package/server/src/Http/Resources/v1/ServiceRateFee.php +7 -0
  191. package/server/src/Listeners/HandleOrderDispatched.php +2 -2
  192. package/server/src/Models/Contact.php +92 -15
  193. package/server/src/Models/Customer.php +64 -0
  194. package/server/src/Models/Driver.php +14 -3
  195. package/server/src/Models/Entity.php +2 -2
  196. package/server/src/Models/Order.php +29 -4
  197. package/server/src/Models/OrderConfig.php +14 -11
  198. package/server/src/Models/Payload.php +17 -1
  199. package/server/src/Models/ServiceRate.php +273 -1
  200. package/server/src/Models/ServiceRateFee.php +39 -9
  201. package/server/src/Models/TrackingNumber.php +8 -2
  202. package/server/src/Models/Vehicle.php +18 -2
  203. package/server/src/Models/Waypoint.php +2 -2
  204. package/server/src/Notifications/LateDeparture.php +48 -0
  205. package/server/src/Notifications/ProlongedStoppage.php +48 -0
  206. package/server/src/Notifications/RouteDeviation.php +48 -0
  207. package/server/src/Observers/ContactObserver.php +2 -0
  208. package/server/src/Observers/ServiceRateObserver.php +6 -6
  209. package/server/src/Orchestration/Engines/VroomOrchestrationEngine.php +29 -2
  210. package/server/src/Providers/FleetOpsServiceProvider.php +6 -0
  211. package/server/src/Providers/NotificationServiceProvider.php +3 -0
  212. package/server/src/Support/Analytics/AbstractAnalytics.php +77 -0
  213. package/server/src/Support/Analytics/FuelEfficiency.php +83 -0
  214. package/server/src/Support/Analytics/GeofenceViolations.php +63 -0
  215. package/server/src/Support/Analytics/IssuesInsights.php +67 -0
  216. package/server/src/Support/Analytics/LiveFleet.php +127 -0
  217. package/server/src/Support/Analytics/MaintenanceOverview.php +70 -0
  218. package/server/src/Support/Analytics/OnTimeDelivery.php +75 -0
  219. package/server/src/Support/Analytics/OperationsPulse.php +86 -0
  220. package/server/src/Support/Analytics/OrdersByStatus.php +64 -0
  221. package/server/src/Support/Analytics/RevenueTrend.php +78 -0
  222. package/server/src/Support/Analytics/TopDrivers.php +92 -0
  223. package/server/src/Support/CustomerAuth.php +98 -0
  224. package/server/src/Support/LabelPdf.php +52 -0
  225. package/server/src/Support/Metrics/AbstractMetric.php +170 -0
  226. package/server/src/Support/Metrics/ActiveLiveOrdersMetric.php +28 -0
  227. package/server/src/Support/Metrics/AvgOrderValueMetric.php +53 -0
  228. package/server/src/Support/Metrics/DriversOnlineMetric.php +30 -0
  229. package/server/src/Support/Metrics/EarningsMetric.php +30 -0
  230. package/server/src/Support/Metrics/FuelCostsMetric.php +30 -0
  231. package/server/src/Support/Metrics/MoneyMetric.php +30 -0
  232. package/server/src/Support/Metrics/OpenIssuesMetric.php +35 -0
  233. package/server/src/Support/Metrics/OrdersCanceledMetric.php +35 -0
  234. package/server/src/Support/Metrics/OrdersCompletedMetric.php +35 -0
  235. package/server/src/Support/Metrics/OrdersInProgressMetric.php +51 -0
  236. package/server/src/Support/Metrics/OrdersScheduledMetric.php +37 -0
  237. package/server/src/Support/Metrics/Registry.php +46 -0
  238. package/server/src/Support/Metrics/ResolvedIssuesMetric.php +35 -0
  239. package/server/src/Support/Metrics/TotalCustomersMetric.php +29 -0
  240. package/server/src/Support/Metrics/TotalDistanceTraveledMetric.php +35 -0
  241. package/server/src/Support/Metrics/TotalDriversMetric.php +28 -0
  242. package/server/src/Support/Metrics/TotalTimeTraveledMetric.php +35 -0
  243. package/server/src/Support/Metrics.php +46 -249
  244. package/server/src/Support/ResolvesOrderServiceStops.php +512 -0
  245. package/server/src/Support/Telematics/Providers/AfaqyProvider.php +290 -0
  246. package/server/src/Support/Telematics/Providers/SafeeProvider.php +333 -0
  247. package/server/src/Tracking/TrackingContextBuilder.php +41 -38
  248. package/server/src/Tracking/TrackingIntelligenceService.php +109 -9
  249. package/server/src/Tracking/TrackingStop.php +13 -11
  250. package/server/src/routes.php +50 -0
  251. package/server/tests/AnalyticsRoutesTest.php +41 -0
  252. package/server/tests/CapacityAllocationEngineTest.php +10 -10
  253. package/server/tests/CustomerContactIdentitySafetyTest.php +54 -0
  254. package/server/tests/CustomerEndpointTest.php +199 -0
  255. package/server/tests/DriverVehicleStatusDefaultTest.php +39 -0
  256. package/server/tests/LabelPdfRenderingTest.php +23 -0
  257. package/server/tests/LiveControllerViewportTest.php +60 -0
  258. package/server/tests/MetricsRegistryTest.php +84 -0
  259. package/server/tests/OrchestrationPayloadBuilderTest.php +2 -2
  260. package/server/tests/OrderConfigEndpointTest.php +86 -0
  261. package/server/tests/OrderFileAttachmentTest.php +20 -0
  262. package/server/tests/ServiceAreaZoneApiShapeTest.php +34 -0
  263. package/server/tests/VehicleDriverAssignmentTest.php +18 -0
  264. package/server/tests/VroomOrchestrationEngineTest.php +91 -0
  265. package/translations/en-us.yaml +38 -0
  266. package/server/migrations/2026_05_13_000001_repair_customer_contact_user_types.php +0 -86
@@ -0,0 +1,876 @@
1
+ <?php
2
+
3
+ namespace Fleetbase\FleetOps\Http\Controllers\Api\v1;
4
+
5
+ use Fleetbase\FleetOps\Exceptions\UserAlreadyExistsException;
6
+ use Fleetbase\FleetOps\Http\Requests\CreateCustomerOrderRequest;
7
+ use Fleetbase\FleetOps\Http\Requests\CreateCustomerRequest;
8
+ use Fleetbase\FleetOps\Http\Requests\UpdateContactRequest;
9
+ use Fleetbase\FleetOps\Http\Requests\VerifyCreateCustomerRequest;
10
+ use Fleetbase\FleetOps\Http\Resources\v1\Customer as CustomerResource;
11
+ use Fleetbase\FleetOps\Http\Resources\v1\Order as OrderResource;
12
+ use Fleetbase\FleetOps\Http\Resources\v1\Place as PlaceResource;
13
+ use Fleetbase\FleetOps\Models\Contact;
14
+ use Fleetbase\FleetOps\Models\Order;
15
+ use Fleetbase\FleetOps\Models\OrderConfig;
16
+ use Fleetbase\FleetOps\Models\Payload;
17
+ use Fleetbase\FleetOps\Models\Place;
18
+ use Fleetbase\FleetOps\Models\ServiceQuote;
19
+ use Fleetbase\FleetOps\Support\CustomerAuth;
20
+ use Fleetbase\FleetOps\Support\Utils;
21
+ use Fleetbase\Http\Controllers\Controller;
22
+ use Fleetbase\Models\File;
23
+ use Fleetbase\Models\User;
24
+ use Fleetbase\Models\UserDevice;
25
+ use Fleetbase\Models\VerificationCode;
26
+ use Illuminate\Http\JsonResponse;
27
+ use Illuminate\Http\Request;
28
+ use Illuminate\Support\Facades\Hash;
29
+ use Illuminate\Support\Str;
30
+
31
+ /**
32
+ * Customer-facing Fleet-Ops API.
33
+ *
34
+ * Mirrors the Storefront customer surface (signup with verification code,
35
+ * email/password login, SMS login, customer-scoped orders & places) but
36
+ * authenticates entirely with the Fleet-Ops API credential (`flb_live_…`) on
37
+ * the public routes and a Sanctum `Customer-Token` (issued by signup/login)
38
+ * on the authenticated routes. The company tenancy boundary is enforced by
39
+ * the standard `fleetbase.api` middleware that resolves the API credential
40
+ * to a `company_uuid` and sets it on the session.
41
+ */
42
+ class CustomerController extends Controller
43
+ {
44
+ /* ============================================================
45
+ | Public auth flows (API credential only, no Customer-Token)
46
+ * ============================================================ */
47
+
48
+ /**
49
+ * Send an email or SMS verification code so a new customer can complete signup.
50
+ *
51
+ * @return JsonResponse
52
+ */
53
+ public function requestCreationCode(VerifyCreateCustomerRequest $request)
54
+ {
55
+ $mode = $request->input('mode', 'email');
56
+ $identity = $request->input('identity');
57
+ $isEmail = Utils::isEmail($identity);
58
+
59
+ if ($mode === 'email' && !$isEmail) {
60
+ return response()->apiError('Invalid email provided for identity.');
61
+ }
62
+
63
+ if ($mode === 'sms') {
64
+ $identity = static::phone($identity);
65
+ }
66
+
67
+ $sessionCompany = session('company');
68
+ if (!$sessionCompany) {
69
+ return response()->apiError('No company resolved from API credential.', 500);
70
+ }
71
+
72
+ // Optional profile fields the client can include up front so the
73
+ // verification email greets the customer by name (and so a later
74
+ // `create()` doesn't need to overwrite stub values).
75
+ $providedName = trim((string) $request->input('name', ''));
76
+ $providedPhone = $request->filled('phone') ? static::phone($request->input('phone')) : null;
77
+
78
+ // The verification code needs a persisted subject so the mail renderer
79
+ // can resolve the polymorphic `subject` relation (the verification blade
80
+ // template references `$user->name`). Look up — or create — the User
81
+ // before sending. `create()` later backfills password + remaining fields
82
+ // on this same row when the customer confirms the code.
83
+ $subject = $isEmail
84
+ ? User::where('email', $identity)->whereNull('deleted_at')->withoutGlobalScopes()->first()
85
+ : User::where('phone', $identity)->whereNull('deleted_at')->withoutGlobalScopes()->first();
86
+
87
+ if (!$subject) {
88
+ // `password` and `type` are guarded on User; assign type after create
89
+ // via setUserType (saves the row).
90
+ $subject = User::create([
91
+ 'company_uuid' => $sessionCompany,
92
+ 'name' => $providedName !== '' ? $providedName : $identity,
93
+ 'email' => $isEmail ? $identity : null,
94
+ 'phone' => $isEmail ? $providedPhone : $identity,
95
+ ]);
96
+ $subject->setUserType('customer');
97
+ } elseif ($providedName !== '' && (!$subject->name || $subject->name === $subject->email)) {
98
+ // Existing stub user from a prior incomplete signup — refresh the
99
+ // greeting name if the client supplied one.
100
+ $subject->name = $providedName;
101
+ if ($isEmail && $providedPhone && !$subject->phone) {
102
+ $subject->phone = $providedPhone;
103
+ }
104
+ $subject->save();
105
+ }
106
+
107
+ $meta = ['identity' => $identity];
108
+
109
+ try {
110
+ if ($mode === 'email') {
111
+ VerificationCode::generateEmailVerificationFor($subject, 'fleetops_create_customer', [
112
+ 'subject' => config('app.name') . ' verification code',
113
+ 'messageCallback' => fn ($verification) => 'Your ' . config('app.name') . ' verification code is ' . $verification->code,
114
+ 'meta' => $meta,
115
+ ]);
116
+ } else {
117
+ VerificationCode::generateSmsVerificationFor($subject, 'fleetops_create_customer', [
118
+ 'messageCallback' => fn ($verification) => 'Your ' . config('app.name') . ' verification code is ' . $verification->code,
119
+ 'meta' => $meta,
120
+ ]);
121
+ }
122
+ } catch (\Twilio\Exceptions\RestException $e) {
123
+ return response()->apiError($e->getMessage());
124
+ } catch (\Exception $e) {
125
+ return response()->apiError(app()->hasDebugModeEnabled() ? $e->getMessage() : 'Error sending verification code.');
126
+ }
127
+
128
+ return response()->json(['status' => 'ok']);
129
+ }
130
+
131
+ /**
132
+ * Create a new customer (Contact + User) after verifying their code.
133
+ */
134
+ public function create(CreateCustomerRequest $request)
135
+ {
136
+ $code = $request->input('code');
137
+ $identity = $request->input('identity');
138
+ $isEmail = Utils::isEmail($identity);
139
+ if (!$isEmail) {
140
+ $identity = static::phone($identity);
141
+ }
142
+
143
+ // Verify the code is one we sent for this identity.
144
+ $verificationCode = VerificationCode::where([
145
+ 'code' => $code,
146
+ 'for' => 'fleetops_create_customer',
147
+ 'meta->identity' => $identity,
148
+ ])->exists();
149
+ if (!$verificationCode) {
150
+ return response()->apiError('Invalid verification code provided.');
151
+ }
152
+
153
+ $sessionCompany = session('company');
154
+ if (!$sessionCompany) {
155
+ return response()->apiError('No company resolved from API credential.', 500);
156
+ }
157
+
158
+ // Attach to existing User if one matches the identity, otherwise create one.
159
+ $user = null;
160
+ if ($isEmail) {
161
+ $user = User::where('email', $identity)->whereNull('deleted_at')->withoutGlobalScopes()->first();
162
+ } elseif (Str::startsWith($identity, '+')) {
163
+ $user = User::where('phone', $identity)->whereNull('deleted_at')->withoutGlobalScopes()->first();
164
+ }
165
+
166
+ if (!$user) {
167
+ // `password` and `type` are guarded on User; assign them after create
168
+ // (setUserType saves the type, setPasswordAttribute hashes plaintext).
169
+ $user = User::create([
170
+ 'company_uuid' => $sessionCompany,
171
+ 'name' => $request->input('name'),
172
+ 'email' => $isEmail ? $identity : $request->input('email'),
173
+ 'phone' => $isEmail ? static::phone($request->input('phone')) : $identity,
174
+ ]);
175
+ $user->password = $request->input('password');
176
+ $user->save();
177
+ $user->setUserType('customer');
178
+ } else {
179
+ // User row exists. If it's a stub created during request-creation-code
180
+ // (no password set yet), backfill name + password + email/phone from
181
+ // the signup form. If a password is already set this is an existing
182
+ // account — only attach a new Contact, don't overwrite credentials.
183
+ if (!$user->password) {
184
+ $update = ['name' => $request->input('name')];
185
+ if ($isEmail && !$user->phone && $request->filled('phone')) {
186
+ $update['phone'] = static::phone($request->input('phone'));
187
+ }
188
+ if (!$isEmail && !$user->email && $request->filled('email')) {
189
+ $update['email'] = $request->input('email');
190
+ }
191
+ $user->fill($update);
192
+ $user->password = $request->input('password'); // mutator hashes
193
+ $user->save();
194
+ if (!$user->type) {
195
+ $user->setUserType('customer');
196
+ }
197
+ }
198
+ }
199
+
200
+ // `meta` is a client-owned free-form bag — we pass through whatever the
201
+ // client sent without injecting controller-side keys. The API should
202
+ // only stamp meta when the backend itself needs the data to operate
203
+ // (cf. Storefront's `meta.storefront_id` for query scoping).
204
+ $input = [
205
+ 'type' => 'customer',
206
+ 'company_uuid' => $sessionCompany,
207
+ 'user_uuid' => $user->uuid,
208
+ 'name' => $request->input('name') ?: $user->name,
209
+ 'title' => $request->input('title'),
210
+ 'email' => $isEmail ? $identity : ($request->input('email') ?: $user->email),
211
+ 'phone' => $isEmail ? static::phone($request->input('phone')) : $identity,
212
+ 'meta' => (array) $request->input('meta', []),
213
+ ];
214
+
215
+ // Handle photo as either file id or base64 data.
216
+ $photo = $request->input('photo');
217
+ if ($photo) {
218
+ if (Utils::isPublicId($photo)) {
219
+ $file = File::where('public_id', $photo)->first();
220
+ if ($file) {
221
+ $input['photo_uuid'] = $file->uuid;
222
+ }
223
+ }
224
+ if (Utils::isBase64String($photo)) {
225
+ $path = implode('/', ['uploads', $sessionCompany, 'customers']);
226
+ $file = File::createFromBase64($photo, null, $path);
227
+ if ($file) {
228
+ $input['photo_uuid'] = $file->uuid;
229
+ }
230
+ }
231
+ }
232
+
233
+ // Reuse an existing customer-Contact for this user+company if one exists
234
+ // (idempotent re-signup), otherwise create one.
235
+ $contact = Contact::where([
236
+ 'company_uuid' => $sessionCompany,
237
+ 'user_uuid' => $user->uuid,
238
+ 'type' => 'customer',
239
+ ])->first();
240
+ if ($contact) {
241
+ $contact->fill(array_filter($input, fn ($v) => $v !== null && $v !== ''))->save();
242
+ } else {
243
+ try {
244
+ $contact = Contact::create($input);
245
+ } catch (UserAlreadyExistsException $e) {
246
+ $contact = Contact::where([
247
+ 'company_uuid' => $sessionCompany,
248
+ 'user_uuid' => $user->uuid,
249
+ 'type' => 'customer',
250
+ ])->first();
251
+ if (!$contact) {
252
+ return response()->apiError($e->getMessage());
253
+ }
254
+ } catch (\Exception $e) {
255
+ return response()->apiError($e->getMessage());
256
+ }
257
+ }
258
+
259
+ // Optionally link a default Place to the new customer. Accepts either:
260
+ // - a string public_id of an existing Place in this company
261
+ // - a Place-shaped object using canonical fields:
262
+ // name, street1, street2, city, province, postal_code, country,
263
+ // neighborhood, district, building, phone, meta
264
+ // The created Place is owned by the customer Contact (polymorphic via
265
+ // owner_uuid + owner_type). Idempotent: only acts when no place is
266
+ // already linked.
267
+ if (!$contact->place_uuid) {
268
+ $place = $this->resolveCustomerPlace($request->input('place'), $contact, $sessionCompany);
269
+ if ($place) {
270
+ $contact->place_uuid = $place->uuid;
271
+ $contact->save();
272
+ }
273
+ }
274
+
275
+ $token = $user->createToken($contact->uuid);
276
+ $contact->token = $token->plainTextToken;
277
+
278
+ return new CustomerResource($contact);
279
+ }
280
+
281
+ /**
282
+ * Resolve the customer's default Place reference. Accepts either:
283
+ * - a string public_id of an existing Place (must be in the same company)
284
+ * - an array of canonical Place fields (Place::$fillable subset)
285
+ *
286
+ * Returns null when no place data was provided. Mirrors the convention
287
+ * used by other v1 controllers that accept a `place` reference.
288
+ */
289
+ protected function resolveCustomerPlace($input, Contact $contact, string $companyUuid): ?Place
290
+ {
291
+ if (empty($input)) {
292
+ return null;
293
+ }
294
+
295
+ if (is_string($input)) {
296
+ return Place::where(['public_id' => $input, 'company_uuid' => $companyUuid])->first();
297
+ }
298
+
299
+ if (!is_array($input)) {
300
+ return null;
301
+ }
302
+
303
+ // Filter the supplied attributes to Place's fillable list — never accept
304
+ // arbitrary client-specific keys at this surface.
305
+ $allowed = ['name', 'street1', 'street2', 'city', 'province', 'postal_code', 'neighborhood', 'district', 'building', 'security_access_code', 'country', 'phone', 'meta', 'type'];
306
+ $attributes = array_intersect_key($input, array_flip($allowed));
307
+
308
+ if (!array_filter($attributes, fn ($v) => $v !== null && $v !== '')) {
309
+ return null;
310
+ }
311
+
312
+ return Place::create(array_merge(
313
+ [
314
+ 'company_uuid' => $companyUuid,
315
+ 'owner_uuid' => $contact->uuid,
316
+ 'owner_type' => get_class($contact),
317
+ ],
318
+ $attributes,
319
+ ));
320
+ }
321
+
322
+ /**
323
+ * Authenticate an existing customer with email/phone + password.
324
+ */
325
+ public function login(Request $request)
326
+ {
327
+ $identity = $request->input('identity');
328
+ $password = $request->input('password');
329
+ if (!$identity || !$password) {
330
+ return response()->apiError('Identity and password are required.', 400);
331
+ }
332
+
333
+ $user = User::where('email', $identity)
334
+ ->orWhere('phone', static::phone($identity))
335
+ ->first();
336
+
337
+ if (!$user || !$user->password || !Hash::check($password, $user->password)) {
338
+ return response()->apiError('Authentication failed using credentials provided.', 401);
339
+ }
340
+
341
+ $sessionCompany = session('company');
342
+ if (!$sessionCompany) {
343
+ return response()->apiError('No company resolved from API credential.', 500);
344
+ }
345
+
346
+ $contact = Contact::firstOrCreate(
347
+ [
348
+ 'user_uuid' => $user->uuid,
349
+ 'company_uuid' => $sessionCompany,
350
+ 'type' => 'customer',
351
+ ],
352
+ [
353
+ 'name' => $user->name,
354
+ 'phone' => $user->phone,
355
+ 'email' => $user->email,
356
+ ]
357
+ );
358
+
359
+ $token = $user->createToken($contact->uuid);
360
+ $contact->token = $token->plainTextToken;
361
+
362
+ return new CustomerResource($contact);
363
+ }
364
+
365
+ /**
366
+ * Send an SMS verification code so a customer can log in without password.
367
+ */
368
+ public function loginWithPhone(Request $request)
369
+ {
370
+ $phone = static::phone($request->input('phone') ?? $request->input('identity'));
371
+
372
+ $user = User::where('phone', $phone)->whereNull('deleted_at')->withoutGlobalScopes()->first();
373
+ if (!$user) {
374
+ return response()->apiError('No customer with this phone number found.');
375
+ }
376
+
377
+ try {
378
+ VerificationCode::generateSmsVerificationFor($user, 'fleetops_customer_login', [
379
+ 'messageCallback' => fn ($verification) => 'Your ' . config('app.name') . ' verification code is ' . $verification->code,
380
+ ]);
381
+
382
+ return response()->json(['status' => 'ok', 'method' => 'sms']);
383
+ } catch (\Throwable $e) {
384
+ if ($user->email) {
385
+ try {
386
+ VerificationCode::generateEmailVerificationFor($user, 'fleetops_customer_login', [
387
+ 'subject' => config('app.name') . ' verification code',
388
+ 'messageCallback' => fn ($verification) => 'Your ' . config('app.name') . ' verification code is ' . $verification->code,
389
+ ]);
390
+
391
+ return response()->json(['status' => 'ok', 'method' => 'email']);
392
+ } catch (\Throwable $inner) {
393
+ return response()->apiError('Unable to send verification code.');
394
+ }
395
+ }
396
+ }
397
+
398
+ return response()->apiError('Unable to send verification code.');
399
+ }
400
+
401
+ /**
402
+ * Verify the SMS/email code from {@see loginWithPhone} and issue a token.
403
+ */
404
+ public function verifyCode(Request $request)
405
+ {
406
+ $identity = Utils::isEmail($request->input('identity')) ? $request->input('identity') : static::phone($request->input('identity'));
407
+ $code = $request->input('code');
408
+ $for = $request->input('for', 'fleetops_customer_login');
409
+
410
+ if ($for === 'fleetops_create_customer') {
411
+ return $this->create($request);
412
+ }
413
+
414
+ $user = User::where('phone', $identity)->orWhere('email', $identity)->first();
415
+ if (!$user) {
416
+ return response()->apiError('Unable to verify code.');
417
+ }
418
+
419
+ $verificationCode = VerificationCode::where([
420
+ 'subject_uuid' => $user->uuid,
421
+ 'code' => $code,
422
+ 'for' => $for,
423
+ ])->exists();
424
+ if (!$verificationCode) {
425
+ return response()->apiError('Invalid verification code.');
426
+ }
427
+
428
+ $sessionCompany = session('company');
429
+ $contact = Contact::firstOrCreate(
430
+ [
431
+ 'user_uuid' => $user->uuid,
432
+ 'company_uuid' => $sessionCompany,
433
+ 'type' => 'customer',
434
+ ],
435
+ [
436
+ 'name' => $user->name,
437
+ 'phone' => $user->phone,
438
+ 'email' => $user->email,
439
+ ]
440
+ );
441
+
442
+ $token = $user->createToken($contact->uuid);
443
+ $contact->token = $token->plainTextToken;
444
+
445
+ return new CustomerResource($contact);
446
+ }
447
+
448
+ /**
449
+ * Send a password-reset code to the customer's email or phone.
450
+ */
451
+ public function forgotPassword(Request $request)
452
+ {
453
+ $identity = $request->input('identity');
454
+ if (!$identity) {
455
+ return response()->apiError('Identity is required.', 400);
456
+ }
457
+
458
+ $isEmail = Utils::isEmail($identity);
459
+ $user = $isEmail
460
+ ? User::where('email', $identity)->first()
461
+ : User::where('phone', static::phone($identity))->first();
462
+
463
+ if (!$user) {
464
+ // Don't leak account existence — return success regardless.
465
+ return response()->json(['status' => 'ok']);
466
+ }
467
+
468
+ $meta = ['identity' => $isEmail ? $identity : static::phone($identity)];
469
+ try {
470
+ if ($isEmail) {
471
+ VerificationCode::generateEmailVerificationFor($user, 'fleetops_customer_password_reset', [
472
+ 'subject' => config('app.name') . ' password reset',
473
+ 'messageCallback' => fn ($v) => 'Your ' . config('app.name') . ' password reset code is ' . $v->code,
474
+ 'meta' => $meta,
475
+ ]);
476
+ } else {
477
+ VerificationCode::generateSmsVerificationFor($user, 'fleetops_customer_password_reset', [
478
+ 'messageCallback' => fn ($v) => 'Your ' . config('app.name') . ' password reset code is ' . $v->code,
479
+ 'meta' => $meta,
480
+ ]);
481
+ }
482
+ } catch (\Throwable $e) {
483
+ return response()->apiError(app()->hasDebugModeEnabled() ? $e->getMessage() : 'Unable to send reset code.');
484
+ }
485
+
486
+ return response()->json(['status' => 'ok']);
487
+ }
488
+
489
+ /**
490
+ * Verify the password-reset code and set a new password.
491
+ */
492
+ public function resetPassword(Request $request)
493
+ {
494
+ $identity = $request->input('identity');
495
+ $code = $request->input('code');
496
+ $password = $request->input('password');
497
+ if (!$identity || !$code || !$password) {
498
+ return response()->apiError('identity, code, and password are required.', 400);
499
+ }
500
+ if (strlen($password) < 8) {
501
+ return response()->apiError('Password must be at least 8 characters.', 400);
502
+ }
503
+
504
+ $isEmail = Utils::isEmail($identity);
505
+ $needle = $isEmail ? $identity : static::phone($identity);
506
+
507
+ $verificationCode = VerificationCode::where([
508
+ 'code' => $code,
509
+ 'for' => 'fleetops_customer_password_reset',
510
+ 'meta->identity' => $needle,
511
+ ])->first();
512
+ if (!$verificationCode) {
513
+ return response()->apiError('Invalid reset code.');
514
+ }
515
+
516
+ $user = $isEmail
517
+ ? User::where('email', $needle)->first()
518
+ : User::where('phone', $needle)->first();
519
+ if (!$user) {
520
+ return response()->apiError('Account not found.');
521
+ }
522
+
523
+ // setPasswordAttribute mutator hashes plaintext; don't pre-hash here.
524
+ $user->password = $password;
525
+ $user->save();
526
+ // Invalidate all existing sessions for this user after a password reset.
527
+ $user->tokens()->delete();
528
+ $verificationCode->delete();
529
+
530
+ return response()->json(['status' => 'ok']);
531
+ }
532
+
533
+ /* ============================================================
534
+ | Authenticated flows (require Customer-Token)
535
+ * ============================================================ */
536
+
537
+ /**
538
+ * Return the authenticated customer's profile.
539
+ */
540
+ public function me()
541
+ {
542
+ $customer = CustomerAuth::current();
543
+ if (!$customer) {
544
+ return response()->apiError('Not authenticated.', 401);
545
+ }
546
+
547
+ return new CustomerResource($customer);
548
+ }
549
+
550
+ /**
551
+ * Update the authenticated customer's profile (Contact + linked User fields).
552
+ */
553
+ public function updateMe(UpdateContactRequest $request)
554
+ {
555
+ $customer = CustomerAuth::current();
556
+ if (!$customer) {
557
+ return response()->apiError('Not authenticated.', 401);
558
+ }
559
+
560
+ $input = $request->only(['name', 'title', 'email', 'phone', 'meta']);
561
+ if (isset($input['phone'])) {
562
+ $input['phone'] = static::phone($input['phone']);
563
+ }
564
+
565
+ // Photo handling.
566
+ $photo = $request->input('photo');
567
+ if ($photo) {
568
+ if (Utils::isPublicId($photo)) {
569
+ $file = File::where('public_id', $photo)->first();
570
+ if ($file) {
571
+ $input['photo_uuid'] = $file->uuid;
572
+ }
573
+ }
574
+ if (Utils::isBase64String($photo)) {
575
+ $path = implode('/', ['uploads', session('company'), 'customers']);
576
+ $file = File::createFromBase64($photo, null, $path);
577
+ if ($file) {
578
+ $input['photo_uuid'] = $file->uuid;
579
+ }
580
+ }
581
+ if ($photo === 'REMOVE') {
582
+ $input['photo_uuid'] = null;
583
+ }
584
+ }
585
+
586
+ try {
587
+ $customer->update($input);
588
+ } catch (\Exception $e) {
589
+ return response()->apiError($e->getMessage());
590
+ }
591
+
592
+ // Mirror critical fields on the linked User row so login works after edits.
593
+ if ($customer->user_uuid) {
594
+ $userUpdate = array_filter([
595
+ 'name' => $input['name'] ?? null,
596
+ 'email' => $input['email'] ?? null,
597
+ 'phone' => $input['phone'] ?? null,
598
+ ], fn ($v) => $v !== null);
599
+ if (!empty($userUpdate)) {
600
+ User::where('uuid', $customer->user_uuid)->update($userUpdate);
601
+ }
602
+ }
603
+
604
+ return new CustomerResource($customer->fresh());
605
+ }
606
+
607
+ /**
608
+ * Revoke the Customer-Token used on this request.
609
+ */
610
+ public function logout(Request $request)
611
+ {
612
+ $customer = CustomerAuth::current();
613
+ if (!$customer) {
614
+ return response()->apiError('Not authenticated.', 401);
615
+ }
616
+
617
+ $tokenString = $request->header(CustomerAuth::HEADER);
618
+ $accessToken = $tokenString ? \Laravel\Sanctum\PersonalAccessToken::findToken($tokenString) : null;
619
+ if ($accessToken) {
620
+ $accessToken->delete();
621
+ }
622
+
623
+ return response()->json(['status' => 'ok']);
624
+ }
625
+
626
+ /**
627
+ * Revoke ALL tokens for the authenticated customer's user (sign out everywhere).
628
+ */
629
+ public function logoutAll()
630
+ {
631
+ $customer = CustomerAuth::current();
632
+ if (!$customer || !$customer->user_uuid) {
633
+ return response()->apiError('Not authenticated.', 401);
634
+ }
635
+
636
+ $user = User::where('uuid', $customer->user_uuid)->first();
637
+ if ($user) {
638
+ $user->tokens()->delete();
639
+ }
640
+
641
+ return response()->json(['status' => 'ok']);
642
+ }
643
+
644
+ /**
645
+ * List the authenticated customer's orders.
646
+ */
647
+ public function orders(Request $request)
648
+ {
649
+ $customer = CustomerAuth::current();
650
+ if (!$customer) {
651
+ return response()->apiError('Not authenticated.', 401);
652
+ }
653
+
654
+ $results = Order::queryWithRequest($request, function (&$query) use ($customer) {
655
+ $query->where('customer_uuid', $customer->uuid)
656
+ ->whereNull('deleted_at')
657
+ ->withoutGlobalScopes();
658
+ });
659
+
660
+ return OrderResource::collection($results);
661
+ }
662
+
663
+ /**
664
+ * Fetch one order owned by the authenticated customer.
665
+ */
666
+ public function findOrder(string $id)
667
+ {
668
+ $customer = CustomerAuth::current();
669
+ if (!$customer) {
670
+ return response()->apiError('Not authenticated.', 401);
671
+ }
672
+
673
+ try {
674
+ $order = Order::findRecordOrFail($id);
675
+ } catch (\Illuminate\Database\Eloquent\ModelNotFoundException $e) {
676
+ return response()->apiError('Order not found.', 404);
677
+ }
678
+
679
+ if ($order->customer_uuid !== $customer->uuid) {
680
+ return response()->apiError('Order not found.', 404);
681
+ }
682
+
683
+ return new OrderResource($order);
684
+ }
685
+
686
+ /**
687
+ * Create an Order on behalf of the authenticated customer.
688
+ *
689
+ * Accepts the canonical Fleet-Ops Order create shape (the same fields as
690
+ * `POST /v1/orders` would accept from an operator): `type` / `order_config`,
691
+ * `scheduled_at`, `notes`, `meta`, plus either a top-level `payload`
692
+ * (object or public_id) or top-level `pickup` / `dropoff` / `waypoints` /
693
+ * `entities` that the controller rolls into a Payload — using the
694
+ * Payload model's canonical setters for parity with OrderController.
695
+ *
696
+ * `customer_uuid` + `customer_type` are forced from the Customer-Token;
697
+ * any client-supplied `customer` field is ignored. `status` is forced to
698
+ * `created` (customers cannot self-dispatch).
699
+ */
700
+ public function createOrder(CreateCustomerOrderRequest $request)
701
+ {
702
+ $customer = CustomerAuth::current();
703
+ if (!$customer) {
704
+ return response()->apiError('Not authenticated.', 401);
705
+ }
706
+
707
+ $sessionCompany = session('company');
708
+ if (!$sessionCompany) {
709
+ return response()->apiError('No company resolved from API credential.', 500);
710
+ }
711
+
712
+ // Resolve the order config for this order. Mirrors OrderController::create.
713
+ $orderConfig = OrderConfig::resolveFromIdentifier($request->only(['type', 'order_config']))
714
+ ?: OrderConfig::where('company_uuid', $sessionCompany)->first();
715
+ if (!$orderConfig) {
716
+ return response()->apiError('No order config available for this company.', 422);
717
+ }
718
+
719
+ // Build the Payload (matching the operator OrderController convention).
720
+ // - `payload` may be an array of {pickup, dropoff, return, waypoints, entities}
721
+ // - `payload` may be a string public_id referencing an existing Payload
722
+ // - Or top-level pickup/dropoff/return/waypoints/entities are accepted
723
+ $payloadUuid = null;
724
+ if ($request->isArray('payload')) {
725
+ $payloadInput = (array) $request->input('payload');
726
+ $payloadUuid = $this->buildPayloadFromInput($payloadInput, $sessionCompany)->uuid;
727
+ } elseif ($request->isString('payload')) {
728
+ $payloadUuid = Utils::getUuid('payloads', [
729
+ 'public_id' => $request->input('payload'),
730
+ 'company_uuid' => $sessionCompany,
731
+ ]);
732
+ } else {
733
+ $payloadInput = $request->only(['pickup', 'dropoff', 'return', 'waypoints', 'entities']);
734
+ if (array_filter($payloadInput, fn ($v) => $v !== null && $v !== '' && $v !== [])) {
735
+ $payloadUuid = $this->buildPayloadFromInput($payloadInput, $sessionCompany)->uuid;
736
+ }
737
+ }
738
+
739
+ $order = Order::create([
740
+ 'company_uuid' => $sessionCompany,
741
+ 'customer_uuid' => $customer->uuid,
742
+ 'customer_type' => Utils::getModelClassName('contact'),
743
+ 'payload_uuid' => $payloadUuid,
744
+ 'order_config_uuid' => $orderConfig->uuid,
745
+ 'type' => $orderConfig->key,
746
+ 'status' => 'created',
747
+ 'scheduled_at' => $request->input('scheduled_at'),
748
+ 'notes' => $request->input('notes'),
749
+ 'internal_id' => $request->input('internal_id'),
750
+ 'meta' => (array) $request->input('meta', []),
751
+ ]);
752
+
753
+ // If the customer picked a ServiceQuote up front, consume it now to
754
+ // lock the pricing onto the order's PurchaseRate (mirrors how
755
+ // OrderController::create handles `service_quote`).
756
+ $serviceQuote = ServiceQuote::resolveFromRequest($request);
757
+ if ($serviceQuote instanceof ServiceQuote) {
758
+ $order->purchaseServiceQuote($serviceQuote);
759
+ }
760
+
761
+ return new OrderResource($order->fresh(['payload', 'payload.pickup', 'payload.dropoff', 'payload.entities']));
762
+ }
763
+
764
+ /**
765
+ * Build a Payload from the canonical {pickup, dropoff, return, waypoints,
766
+ * entities} shape. Identical to OrderController::create's payload-building
767
+ * branch so customer-created orders are indistinguishable from operator-
768
+ * created ones at the data layer.
769
+ */
770
+ protected function buildPayloadFromInput(array $payloadInput, string $companyUuid): Payload
771
+ {
772
+ $payload = new Payload();
773
+ $entities = data_get($payloadInput, 'entities', []);
774
+ $waypoints = data_get($payloadInput, 'waypoints', []);
775
+ $pickup = data_get($payloadInput, 'pickup');
776
+ $dropoff = data_get($payloadInput, 'dropoff');
777
+ $return = data_get($payloadInput, 'return');
778
+
779
+ $payload->company_uuid = $companyUuid;
780
+
781
+ if ($pickup) {
782
+ $payload->setPickup($pickup, [
783
+ 'callback' => function ($pickup, $payload) {
784
+ $payload->setCurrentWaypoint($pickup);
785
+ },
786
+ ]);
787
+ }
788
+ if ($dropoff) {
789
+ $payload->setDropoff($dropoff);
790
+ }
791
+ if ($return) {
792
+ $payload->setReturn($return);
793
+ }
794
+
795
+ $payload->save();
796
+
797
+ $payload->setWaypoints($waypoints);
798
+ $payload->setEntities($entities);
799
+
800
+ $firstWaypoint = $payload->getPickupOrFirstWaypoint();
801
+ if ($firstWaypoint instanceof Place) {
802
+ $payload->setCurrentWaypoint($firstWaypoint);
803
+ }
804
+
805
+ return $payload;
806
+ }
807
+
808
+ /**
809
+ * List the authenticated customer's saved places.
810
+ */
811
+ public function places(Request $request)
812
+ {
813
+ $customer = CustomerAuth::current();
814
+ if (!$customer) {
815
+ return response()->apiError('Not authenticated.', 401);
816
+ }
817
+
818
+ $results = Place::queryWithRequest($request, function (&$query) use ($customer) {
819
+ $query->where('owner_uuid', $customer->uuid);
820
+ });
821
+
822
+ return PlaceResource::collection($results);
823
+ }
824
+
825
+ /**
826
+ * Register a push-notification device for the authenticated customer's user.
827
+ */
828
+ public function registerDevice(Request $request)
829
+ {
830
+ $customer = CustomerAuth::current();
831
+ if (!$customer) {
832
+ return response()->apiError('Not authenticated.', 401);
833
+ }
834
+
835
+ $device = UserDevice::firstOrCreate(
836
+ [
837
+ 'token' => $request->input('token'),
838
+ 'platform' => $request->input('platform', $request->input('os')),
839
+ ],
840
+ [
841
+ 'user_uuid' => $customer->user_uuid,
842
+ 'platform' => $request->input('platform', $request->input('os')),
843
+ 'token' => $request->input('token'),
844
+ 'status' => 'active',
845
+ ]
846
+ );
847
+
848
+ return response()->json([
849
+ 'status' => 'ok',
850
+ 'device' => $device->public_id,
851
+ ]);
852
+ }
853
+
854
+ /* ============================================================
855
+ | Helpers
856
+ * ============================================================ */
857
+
858
+ /**
859
+ * Normalize a phone number to international format (with leading `+`).
860
+ */
861
+ public static function phone(?string $phone = null): string
862
+ {
863
+ if ($phone === null) {
864
+ $phone = request()->input('phone', '');
865
+ }
866
+ $phone = trim((string) $phone);
867
+ if ($phone === '') {
868
+ return '';
869
+ }
870
+ if (!Str::startsWith($phone, '+')) {
871
+ $phone = '+' . ltrim($phone, '+');
872
+ }
873
+
874
+ return $phone;
875
+ }
876
+ }