@fleetbase/fleetops-engine 0.6.49 → 0.6.51

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 (271) hide show
  1. package/README.md +185 -67
  2. package/addon/components/contact/panel-header.hbs +11 -48
  3. package/addon/components/customer/create-order-form.hbs +2 -2
  4. package/addon/components/customer/details.hbs +1 -2
  5. package/addon/components/customer/form.hbs +62 -50
  6. package/addon/components/customer/form.js +27 -1
  7. package/addon/components/customer/order-form.hbs +2 -2
  8. package/addon/components/customer/orders.hbs +50 -2
  9. package/addon/components/customer/orders.js +67 -5
  10. package/addon/components/display-place.hbs +2 -2
  11. package/addon/components/driver/form.hbs +1 -1
  12. package/addon/components/home/getting-started-guidance.hbs +113 -0
  13. package/addon/components/home/getting-started-guidance.js +107 -0
  14. package/addon/components/issue/details/comments.hbs +3 -0
  15. package/addon/components/issue/details/comments.js +3 -0
  16. package/addon/components/issue/details/correspondence.hbs +9 -0
  17. package/addon/components/issue/details/correspondence.js +83 -0
  18. package/addon/components/issue/details/documents.hbs +69 -0
  19. package/addon/components/issue/details/documents.js +85 -0
  20. package/addon/components/issue/details.hbs +220 -59
  21. package/addon/components/issue/details.js +245 -0
  22. package/addon/components/issue/form.hbs +19 -1
  23. package/addon/components/issue/panel-header.hbs +43 -0
  24. package/addon/components/issue/panel-header.js +22 -0
  25. package/addon/components/issue/timeline.hbs +40 -0
  26. package/addon/components/issue/timeline.js +45 -0
  27. package/addon/components/leaflet-tracking-marker.js +3 -0
  28. package/addon/components/map/drawer/position-listing.js +3 -0
  29. package/addon/components/map/google-live-map.js +6 -2
  30. package/addon/components/map/leaflet-live-map.hbs +48 -14
  31. package/addon/components/map/leaflet-live-map.js +201 -20
  32. package/addon/components/map/order-list-overlay.hbs +4 -4
  33. package/addon/components/modals/convert-customer-to-vendor.hbs +30 -0
  34. package/addon/components/modals/convert-customer-to-vendor.js +55 -0
  35. package/addon/components/modals/issue-assign.hbs +20 -0
  36. package/addon/components/modals/issue-assign.js +49 -0
  37. package/addon/components/modals/issue-close.hbs +30 -0
  38. package/addon/components/modals/issue-close.js +73 -0
  39. package/addon/components/modals/issue-status.hbs +20 -0
  40. package/addon/components/modals/issue-status.js +43 -0
  41. package/addon/components/modals/order-change-destination.hbs +46 -0
  42. package/addon/components/modals/order-change-destination.js +106 -0
  43. package/addon/components/modals/reset-customer-credentials.js +12 -6
  44. package/addon/components/modals/stop-activity.hbs +28 -0
  45. package/addon/components/modals/stop-activity.js +52 -0
  46. package/addon/components/modals/update-order-activity.hbs +78 -2
  47. package/addon/components/modals/update-order-activity.js +197 -5
  48. package/addon/components/orchestrator/resource-panel.hbs +0 -10
  49. package/addon/components/orchestrator/resource-panel.js +2 -4
  50. package/addon/components/order/activity-list.hbs +2 -2
  51. package/addon/components/order/activity-timeline.hbs +31 -29
  52. package/addon/components/order/details/activity.hbs +3 -3
  53. package/addon/components/order/details/activity.js +13 -2
  54. package/addon/components/order/details/documents.js +2 -0
  55. package/addon/components/order/details/proof.hbs +40 -38
  56. package/addon/components/order/details/proof.js +31 -4
  57. package/addon/components/order/details/purchase-rate.hbs +2 -2
  58. package/addon/components/order/details/route.hbs +11 -3
  59. package/addon/components/order/details/route.js +91 -14
  60. package/addon/components/order/details/tracking.hbs +101 -72
  61. package/addon/components/order/details/tracking.js +149 -2
  62. package/addon/components/order/details.hbs +5 -5
  63. package/addon/components/order/details.js +19 -1
  64. package/addon/components/order/form/details.hbs +3 -3
  65. package/addon/components/order/form/documents.js +11 -4
  66. package/addon/components/order/form/route.hbs +108 -104
  67. package/addon/components/order/form/route.js +37 -1
  68. package/addon/components/order/form/service-rate.hbs +2 -2
  69. package/addon/components/order/header.hbs +1 -1
  70. package/addon/components/order-config-manager/activity-flow.hbs +4 -1
  71. package/addon/components/order-config-manager/activity-flow.js +178 -12
  72. package/addon/components/place/address.js +1 -1
  73. package/addon/components/route-list.hbs +17 -29
  74. package/addon/components/route-list.js +42 -10
  75. package/addon/components/service-rate/details.hbs +42 -1
  76. package/addon/components/service-rate/form.hbs +119 -1
  77. package/addon/components/service-rate/form.js +59 -0
  78. package/addon/components/vehicle/form.js +1 -1
  79. package/addon/components/vehicle/pill.hbs +1 -1
  80. package/addon/components/vendor/personnel-manager.hbs +166 -0
  81. package/addon/components/vendor/personnel-manager.js +143 -0
  82. package/addon/components/widget/live-fleet.hbs +1 -0
  83. package/addon/components/widget/live-fleet.js +2 -7
  84. package/addon/components/widget/revenue-trend.hbs +1 -1
  85. package/addon/components/widget/revenue-trend.js +75 -2
  86. package/addon/controllers/management/contacts/customers/details.js +31 -13
  87. package/addon/controllers/management/contacts/customers.js +16 -5
  88. package/addon/controllers/management/contacts/index/details.js +31 -13
  89. package/addon/controllers/management/contacts/index.js +7 -0
  90. package/addon/controllers/management/issues/index/details.js +52 -6
  91. package/addon/controllers/management/vehicles/index/new.js +1 -1
  92. package/addon/controllers/management/vendors/index/details.js +4 -0
  93. package/addon/controllers/operations/orders/index/details.js +15 -7
  94. package/addon/controllers/operations/orders/index.js +15 -1
  95. package/addon/extension.js +19 -12
  96. package/addon/helpers/format-duration.js +1 -38
  97. package/addon/helpers/place-address-html.js +2 -2
  98. package/addon/initializers/load-leaflet-assets.js +5 -6
  99. package/addon/initializers/patch-ember-leaflet-tooltip-layer.js +51 -0
  100. package/addon/routes/management/issues/index/details.js +1 -1
  101. package/addon/routes/operations/service-rates/index.js +1 -1
  102. package/addon/routes.js +1 -0
  103. package/addon/services/contact-actions.js +231 -1
  104. package/addon/services/geofence.js +163 -19
  105. package/addon/services/getting-started.js +44 -0
  106. package/addon/services/issue-actions.js +71 -0
  107. package/addon/services/leaflet-contextmenu-manager.js +27 -0
  108. package/addon/services/leaflet-map-manager.js +2 -0
  109. package/addon/services/map-adapter/leaflet.js +19 -0
  110. package/addon/services/service-areas.js +3 -0
  111. package/addon/services/vehicle-actions.js +1 -1
  112. package/addon/styles/fleetops-engine.css +1127 -1
  113. package/addon/templates/management/contacts/customers/details.hbs +1 -1
  114. package/addon/templates/management/contacts/index/details.hbs +1 -1
  115. package/addon/templates/management/contacts.hbs +2 -2
  116. package/addon/templates/management/issues/index/details/index.hbs +3 -1
  117. package/addon/templates/management/issues/index/details.hbs +3 -2
  118. package/addon/templates/management/vendors/index/details/personnel.hbs +1 -2
  119. package/addon/templates/operations/orders/index/details/index.hbs +2 -2
  120. package/addon/templates/operations/orders/index.hbs +1 -1
  121. package/addon/templates/operations/service-rates/index/edit.hbs +1 -0
  122. package/addon/templates/operations/service-rates/index/new.hbs +1 -0
  123. package/addon/templates/settings/payments/onboard.hbs +8 -8
  124. package/addon/utils/fleet-ops-options.js +2 -1
  125. package/addon/utils/leaflet-draw-namespace-guard.js +22 -0
  126. package/addon/utils/leaflet-plugin-loader.js +219 -0
  127. package/addon/utils/normalize-order-config-flow.js +41 -0
  128. package/addon/utils/place-address-html.js +3 -2
  129. package/addon/utils/route-visualization.js +54 -7
  130. package/addon/utils/setup-customer-portal.js +4 -64
  131. package/app/components/home/getting-started-guidance.js +1 -0
  132. package/app/components/issue/details/comments.js +1 -0
  133. package/app/components/issue/details/correspondence.js +1 -0
  134. package/app/components/issue/details/documents.js +1 -0
  135. package/app/components/issue/panel-header.js +1 -0
  136. package/app/components/modals/convert-customer-to-vendor.js +1 -0
  137. package/app/components/modals/issue-assign.js +1 -0
  138. package/app/components/modals/issue-close.js +1 -0
  139. package/app/components/modals/issue-status.js +1 -0
  140. package/app/components/modals/order-change-destination.js +1 -0
  141. package/app/components/modals/stop-activity.js +1 -0
  142. package/app/components/vendor/personnel-manager.js +1 -0
  143. package/app/helpers/format-duration.js +1 -1
  144. package/app/services/getting-started.js +1 -0
  145. package/app/utils/leaflet-draw-namespace-guard.js +1 -0
  146. package/app/utils/leaflet-plugin-loader.js +1 -0
  147. package/composer.json +1 -1
  148. package/extension.json +1 -1
  149. package/package.json +5 -5
  150. package/server/migrations/2026_05_21_000001_add_geographic_rules_to_service_rate_fees_table.php +37 -0
  151. package/server/migrations/2026_05_25_000001_add_endpoint_tracking_refs_to_payloads_table.php +50 -0
  152. package/server/migrations/2026_05_27_000001_default_driver_vehicle_statuses_to_available.php +48 -0
  153. package/server/migrations/2026_05_31_000001_add_portal_fields_to_vendor_personnels_table.php +30 -0
  154. package/server/migrations/2026_06_03_000000_ensure_tags_column_on_issues_table.php +31 -0
  155. package/server/migrations/2026_06_03_000001_add_order_uuid_to_issues_table.php +25 -0
  156. package/server/migrations/2026_06_04_000001_backfill_order_transaction_subjects.php +39 -0
  157. package/server/migrations/2026_06_04_000002_backfill_order_transaction_directions.php +24 -0
  158. package/server/resources/views/labels/default.php +57 -49
  159. package/server/resources/views/labels/waypoint-label.php +27 -20
  160. package/server/resources/views/mail/customer-credentials.blade.php +16 -13
  161. package/server/seeders/Testing/ConnectivitySeeder.php +1 -0
  162. package/server/seeders/Testing/FleetSeeder.php +22 -22
  163. package/server/seeders/Testing/OrdersSeeder.php +6 -22
  164. package/server/seeders/Testing/VroomOrchestrationSeeder.php +21 -21
  165. package/server/src/Console/Commands/AuditCustomerUserConflicts.php +75 -0
  166. package/server/src/Exceptions/CustomerUserConflictException.php +13 -0
  167. package/server/src/Expansions/UserFilterExpansion.php +28 -0
  168. package/server/src/Http/Controllers/Api/v1/ContactController.php +17 -2
  169. package/server/src/Http/Controllers/Api/v1/CustomerController.php +876 -0
  170. package/server/src/Http/Controllers/Api/v1/DriverController.php +1 -1
  171. package/server/src/Http/Controllers/Api/v1/IssueController.php +2 -0
  172. package/server/src/Http/Controllers/Api/v1/OrderConfigController.php +54 -0
  173. package/server/src/Http/Controllers/Api/v1/OrderController.php +109 -80
  174. package/server/src/Http/Controllers/Api/v1/PlaceController.php +10 -58
  175. package/server/src/Http/Controllers/Api/v1/ServiceAreaController.php +2 -2
  176. package/server/src/Http/Controllers/Api/v1/VehicleController.php +20 -3
  177. package/server/src/Http/Controllers/Internal/v1/ContactController.php +154 -0
  178. package/server/src/Http/Controllers/Internal/v1/CustomerController.php +116 -6
  179. package/server/src/Http/Controllers/Internal/v1/GettingStartedController.php +17 -0
  180. package/server/src/Http/Controllers/Internal/v1/IssueController.php +251 -0
  181. package/server/src/Http/Controllers/Internal/v1/LiveController.php +101 -66
  182. package/server/src/Http/Controllers/Internal/v1/OrderController.php +530 -52
  183. package/server/src/Http/Controllers/Internal/v1/PlaceController.php +10 -68
  184. package/server/src/Http/Controllers/Internal/v1/ServiceQuoteController.php +1 -1
  185. package/server/src/Http/Controllers/Internal/v1/SettingController.php +2 -2
  186. package/server/src/Http/Controllers/Internal/v1/VehicleController.php +54 -1
  187. package/server/src/Http/Controllers/Internal/v1/VendorController.php +89 -0
  188. package/server/src/Http/Middleware/AuthenticateCustomerToken.php +34 -0
  189. package/server/src/Http/Requests/CreateCustomerOrderRequest.php +63 -0
  190. package/server/src/Http/Requests/CreateCustomerRequest.php +48 -0
  191. package/server/src/Http/Requests/CreateDriverRequest.php +1 -1
  192. package/server/src/Http/Requests/CreateIssueRequest.php +4 -0
  193. package/server/src/Http/Requests/CreateVehicleRequest.php +25 -1
  194. package/server/src/Http/Requests/Internal/CreateDriverRequest.php +1 -1
  195. package/server/src/Http/Requests/UpdateIssueRequest.php +4 -0
  196. package/server/src/Http/Requests/VerifyCreateCustomerRequest.php +29 -0
  197. package/server/src/Http/Resources/Internal/v1/OrderConfig.php +41 -0
  198. package/server/src/Http/Resources/v1/Customer.php +79 -0
  199. package/server/src/Http/Resources/v1/Index/Order.php +12 -9
  200. package/server/src/Http/Resources/v1/Issue.php +25 -0
  201. package/server/src/Http/Resources/v1/Order.php +20 -18
  202. package/server/src/Http/Resources/v1/OrderConfig.php +74 -0
  203. package/server/src/Http/Resources/v1/Payload.php +23 -20
  204. package/server/src/Http/Resources/v1/ServiceArea.php +33 -19
  205. package/server/src/Http/Resources/v1/ServiceRateFee.php +7 -0
  206. package/server/src/Listeners/HandleOrderDispatched.php +2 -2
  207. package/server/src/Mail/CustomerCredentialsMail.php +5 -6
  208. package/server/src/Models/Contact.php +122 -66
  209. package/server/src/Models/Customer.php +64 -0
  210. package/server/src/Models/Driver.php +12 -3
  211. package/server/src/Models/Entity.php +2 -2
  212. package/server/src/Models/Issue.php +47 -6
  213. package/server/src/Models/Order.php +62 -10
  214. package/server/src/Models/Payload.php +17 -1
  215. package/server/src/Models/ServiceArea.php +1 -1
  216. package/server/src/Models/ServiceRate.php +273 -1
  217. package/server/src/Models/ServiceRateFee.php +39 -9
  218. package/server/src/Models/TrackingNumber.php +8 -2
  219. package/server/src/Models/Vehicle.php +18 -2
  220. package/server/src/Models/VendorPersonnel.php +10 -1
  221. package/server/src/Models/Waypoint.php +2 -2
  222. package/server/src/Models/Zone.php +1 -1
  223. package/server/src/Notifications/LateDeparture.php +2 -2
  224. package/server/src/Notifications/ProlongedStoppage.php +2 -2
  225. package/server/src/Notifications/RouteDeviation.php +2 -2
  226. package/server/src/Observers/ContactObserver.php +2 -0
  227. package/server/src/Observers/PurchaseRateObserver.php +20 -0
  228. package/server/src/Observers/ServiceRateObserver.php +6 -6
  229. package/server/src/Observers/ZoneObserver.php +56 -0
  230. package/server/src/Orchestration/Engines/VroomOrchestrationEngine.php +1 -1
  231. package/server/src/Providers/FleetOpsServiceProvider.php +2 -0
  232. package/server/src/Support/Analytics/AbstractAnalytics.php +6 -6
  233. package/server/src/Support/Analytics/FuelEfficiency.php +2 -2
  234. package/server/src/Support/Analytics/OrdersByStatus.php +1 -1
  235. package/server/src/Support/Analytics/RevenueTrend.php +0 -1
  236. package/server/src/Support/Analytics/TopDrivers.php +10 -2
  237. package/server/src/Support/CustomerAuth.php +98 -0
  238. package/server/src/Support/GettingStarted.php +144 -0
  239. package/server/src/Support/LabelPdf.php +52 -0
  240. package/server/src/Support/Metrics/AbstractMetric.php +4 -4
  241. package/server/src/Support/PlaceSearch.php +282 -0
  242. package/server/src/Support/ResolvesOrderServiceStops.php +522 -0
  243. package/server/src/Tracking/TrackingContextBuilder.php +41 -38
  244. package/server/src/Tracking/TrackingIntelligenceService.php +109 -9
  245. package/server/src/Tracking/TrackingStop.php +13 -11
  246. package/server/src/routes.php +76 -10
  247. package/server/tests/AnalyticsRoutesTest.php +8 -0
  248. package/server/tests/CapacityAllocationEngineTest.php +10 -10
  249. package/server/tests/CustomerContactIdentitySafetyTest.php +95 -0
  250. package/server/tests/CustomerEndpointTest.php +199 -0
  251. package/server/tests/DriverVehicleStatusDefaultTest.php +39 -0
  252. package/server/tests/GettingStartedStatusTest.php +21 -0
  253. package/server/tests/LabelPdfRenderingTest.php +23 -0
  254. package/server/tests/LiveControllerViewportTest.php +60 -0
  255. package/server/tests/MetricsRegistryTest.php +4 -8
  256. package/server/tests/OrchestrationPayloadBuilderTest.php +2 -2
  257. package/server/tests/OrderActivityFlowRegressionTest.php +116 -0
  258. package/server/tests/OrderConfigEndpointTest.php +86 -0
  259. package/server/tests/OrderFileAttachmentTest.php +20 -0
  260. package/server/tests/ServiceAreaZoneApiShapeTest.php +78 -0
  261. package/server/tests/VehicleDriverAssignmentTest.php +18 -0
  262. package/translations/ar-ae.yml +9 -0
  263. package/translations/bg-bg.yaml +9 -0
  264. package/translations/en-us.yaml +12 -0
  265. package/translations/es-pa.yaml +9 -0
  266. package/translations/fr-fr.yaml +9 -0
  267. package/translations/mn-mn.yaml +9 -0
  268. package/translations/pt-br.yaml +9 -0
  269. package/translations/ru-ru.yaml +9 -0
  270. package/translations/vi-vn.yaml +9 -0
  271. package/server/migrations/2026_05_13_000001_repair_customer_contact_user_types.php +0 -86
@@ -4,12 +4,23 @@ namespace Fleetbase\FleetOps\Http\Controllers\Internal\v1;
4
4
 
5
5
  use Fleetbase\FleetOps\Exports\ContactExport;
6
6
  use Fleetbase\FleetOps\Http\Controllers\FleetOpsController;
7
+ use Fleetbase\FleetOps\Http\Resources\v1\Vendor as VendorResource;
7
8
  use Fleetbase\FleetOps\Imports\ContactImport;
9
+ use Fleetbase\FleetOps\Mail\CustomerCredentialsMail;
8
10
  use Fleetbase\FleetOps\Models\Contact;
11
+ use Fleetbase\FleetOps\Models\Entity;
12
+ use Fleetbase\FleetOps\Models\Issue;
13
+ use Fleetbase\FleetOps\Models\Order;
14
+ use Fleetbase\FleetOps\Models\PurchaseRate;
15
+ use Fleetbase\FleetOps\Models\Vendor;
16
+ use Fleetbase\FleetOps\Models\VendorPersonnel;
17
+ use Fleetbase\FleetOps\Support\Utils;
9
18
  use Fleetbase\Http\Requests\ExportRequest;
10
19
  use Fleetbase\Http\Requests\ImportRequest;
11
20
  use Fleetbase\Models\User;
12
21
  use Illuminate\Http\Request;
22
+ use Illuminate\Support\Facades\DB;
23
+ use Illuminate\Support\Facades\Mail;
13
24
  use Illuminate\Support\Str;
14
25
  use Maatwebsite\Excel\Facades\Excel;
15
26
 
@@ -28,6 +39,8 @@ class ContactController extends FleetOpsController
28
39
  public function onBeforeCreate(Request $request, array &$input)
29
40
  {
30
41
  $this->resolveUserInput($request, $input);
42
+ $this->assertCustomerIdentityIsAvailable($input);
43
+ $this->assertCustomerPortalCanSendWelcomeEmail($input);
31
44
  }
32
45
 
33
46
  /**
@@ -40,6 +53,7 @@ class ContactController extends FleetOpsController
40
53
  }
41
54
 
42
55
  $this->resolveUserInput($request, $input);
56
+ $this->assertCustomerIdentityIsAvailable($input, $contact);
43
57
  }
44
58
 
45
59
  /**
@@ -49,6 +63,7 @@ class ContactController extends FleetOpsController
49
63
  {
50
64
  if ($contact->type === 'customer') {
51
65
  $contact->normalizeCustomerUser();
66
+ $this->sendCustomerPortalWelcomeEmail($contact);
52
67
  }
53
68
 
54
69
  $customFieldValues = $request->array('contact.custom_field_values');
@@ -93,6 +108,57 @@ class ContactController extends FleetOpsController
93
108
  ]);
94
109
  }
95
110
 
111
+ public function convertToVendor(Request $request, string $id)
112
+ {
113
+ $contact = Contact::where('company_uuid', session('company'))
114
+ ->where(function ($query) use ($id) {
115
+ $query->where('uuid', $id)->orWhere('public_id', $id)->orWhere('id', $id);
116
+ })
117
+ ->firstOrFail();
118
+
119
+ $vendor = DB::transaction(function () use ($contact, $request) {
120
+ $originalType = $contact->type;
121
+ $vendor = Vendor::create([
122
+ 'company_uuid' => $contact->company_uuid,
123
+ 'place_uuid' => $contact->place_uuid,
124
+ 'name' => $request->input('name', $contact->name),
125
+ 'email' => $request->input('email', $contact->email),
126
+ 'phone' => $request->input('phone', $contact->phone),
127
+ 'status' => 'active',
128
+ 'type' => 'customer',
129
+ 'meta' => [
130
+ 'converted_from_contact_uuid' => $contact->uuid,
131
+ 'converted_from_contact_type' => $originalType,
132
+ 'converted_by_uuid' => session('user'),
133
+ 'converted_at' => now()->toISOString(),
134
+ ],
135
+ ]);
136
+
137
+ VendorPersonnel::updateOrCreate(
138
+ ['vendor_uuid' => $vendor->uuid, 'contact_uuid' => $contact->uuid],
139
+ ['role' => 'admin', 'status' => 'active', 'invited_by_uuid' => session('user')]
140
+ );
141
+
142
+ $this->migrateContactCustomerContextToVendor($contact, $vendor);
143
+
144
+ $contact->update([
145
+ 'type' => 'customer',
146
+ 'meta' => array_merge((array) $contact->meta, [
147
+ 'converted_from_type' => $originalType,
148
+ 'converted_to_vendor_uuid' => $vendor->uuid,
149
+ 'converted_to_vendor_public_id' => $vendor->public_id,
150
+ 'converted_to_vendor_at' => now()->toISOString(),
151
+ ]),
152
+ ]);
153
+
154
+ return $vendor;
155
+ });
156
+
157
+ return response()->json([
158
+ 'vendor' => (new VendorResource($vendor->load('personnels')))->resolve(),
159
+ ]);
160
+ }
161
+
96
162
  /**
97
163
  * Export the contacts to excel or csv.
98
164
  *
@@ -131,6 +197,29 @@ class ContactController extends FleetOpsController
131
197
  return response()->json(['status' => 'ok', 'message' => 'Import completed', 'imported' => $importedCount]);
132
198
  }
133
199
 
200
+ private function migrateContactCustomerContextToVendor(Contact $contact, Vendor $vendor): void
201
+ {
202
+ $contactType = Utils::getMutationType($contact);
203
+ $vendorType = Utils::getMutationType($vendor);
204
+ $filter = ['customer_uuid' => $contact->uuid, 'customer_type' => $contactType];
205
+ $replacement = ['customer_uuid' => $vendor->uuid, 'customer_type' => $vendorType];
206
+
207
+ Order::where($filter)->update($replacement);
208
+ PurchaseRate::where($filter)->update($replacement);
209
+ Entity::where($filter)->update($replacement);
210
+
211
+ Issue::where('company_uuid', $contact->company_uuid)
212
+ ->where('meta->customer_portal->customer_uuid', $contact->uuid)
213
+ ->where('meta->customer_portal->customer_type', 'contact')
214
+ ->get()
215
+ ->each(function (Issue $issue) use ($vendor) {
216
+ $meta = (array) $issue->meta;
217
+ data_set($meta, 'customer_portal.customer_uuid', $vendor->uuid);
218
+ data_set($meta, 'customer_portal.customer_type', 'vendor');
219
+ $issue->update(['meta' => $meta]);
220
+ });
221
+ }
222
+
134
223
  private function resolveUserInput(Request $request, array &$input): void
135
224
  {
136
225
  $user = data_get($input, 'user_uuid') ?? data_get($input, 'user') ?? $request->input('contact.user_uuid') ?? $request->input('contact.user');
@@ -146,4 +235,69 @@ class ContactController extends FleetOpsController
146
235
  $input['user_uuid'] = User::where('uuid', $user)->orWhere('public_id', $user)->value('uuid') ?? $user;
147
236
  unset($input['user']);
148
237
  }
238
+
239
+ private function assertCustomerPortalCanSendWelcomeEmail(array $input): void
240
+ {
241
+ $type = data_get($input, 'type', 'contact');
242
+ if ($type !== 'customer' || !data_get($input, 'meta.customer_portal.send_welcome_email')) {
243
+ return;
244
+ }
245
+
246
+ if (!$this->isCustomerPortalInstalled()) {
247
+ throw new \Exception('Customer portal must be installed before sending a customer welcome email.');
248
+ }
249
+ }
250
+
251
+ private function sendCustomerPortalWelcomeEmail(Contact $contact): void
252
+ {
253
+ if (!data_get($contact->meta, 'customer_portal.send_welcome_email')) {
254
+ return;
255
+ }
256
+
257
+ if (!$this->isCustomerPortalInstalled()) {
258
+ throw new \Exception('Customer portal must be installed before sending a customer welcome email.');
259
+ }
260
+
261
+ $user = $contact->getUser() ?? Contact::createUserFromContact($contact, false, true);
262
+ if (!$user) {
263
+ throw new \Exception('Unable to create customer portal login.');
264
+ }
265
+
266
+ $password = Str::random(16);
267
+ $user->changePassword($password);
268
+
269
+ if ($user->status !== 'active') {
270
+ $user->activate();
271
+ }
272
+
273
+ Mail::to($user)->send(new CustomerCredentialsMail($password, $contact));
274
+
275
+ $meta = (array) $contact->meta;
276
+ data_forget($meta, 'customer_portal.send_welcome_email');
277
+ $contact->forceFill(['meta' => $meta])->saveQuietly();
278
+ $contact->setAttribute('meta', $meta);
279
+ }
280
+
281
+ private function isCustomerPortalInstalled(): bool
282
+ {
283
+ return collect(Utils::getInstalledFleetbaseExtensions())
284
+ ->contains(fn ($package) => data_get($package, 'name') === 'fleetbase/customer-portal-api');
285
+ }
286
+
287
+ private function assertCustomerIdentityIsAvailable(array $input, ?Contact $contact = null): void
288
+ {
289
+ $type = data_get($input, 'type', $contact?->type);
290
+ if ($type !== 'customer') {
291
+ return;
292
+ }
293
+
294
+ $customer = $contact ? $contact->replicate() : new Contact();
295
+ if ($contact) {
296
+ $customer->forceFill($contact->getAttributes());
297
+ $customer->exists = $contact->exists;
298
+ }
299
+
300
+ $customer->forceFill($input);
301
+ $customer->assertCustomerIdentityIsAvailable();
302
+ }
149
303
  }
@@ -8,9 +8,78 @@ use Fleetbase\Http\Controllers\Controller;
8
8
  use Fleetbase\Models\User;
9
9
  use Illuminate\Http\Request;
10
10
  use Illuminate\Support\Facades\Mail;
11
+ use Illuminate\Support\Str;
11
12
 
12
13
  class CustomerController extends Controller
13
14
  {
15
+ public function createPortalLogin(Request $request)
16
+ {
17
+ $customer = $this->resolveCustomer($request);
18
+ $user = $this->resolveCustomerUser($customer);
19
+
20
+ if (!$user) {
21
+ return response()->error('Unable to create customer portal login.');
22
+ }
23
+
24
+ if ($user->status !== 'active') {
25
+ $user->activate();
26
+ }
27
+
28
+ return response()->json([
29
+ 'customer' => $this->customerPayload($customer->fresh()),
30
+ ]);
31
+ }
32
+
33
+ public function sendCredentials(Request $request)
34
+ {
35
+ $customer = $this->resolveCustomer($request);
36
+ $user = $this->resolveCustomerUser($customer);
37
+
38
+ if (!$user) {
39
+ return response()->error('Unable to send customer portal credentials.');
40
+ }
41
+
42
+ $password = Str::random(16);
43
+ $user->changePassword($password);
44
+ Mail::to($user)->send(new CustomerCredentialsMail($password, $customer));
45
+
46
+ return response()->json([
47
+ 'customer' => $this->customerPayload($customer->fresh()),
48
+ ]);
49
+ }
50
+
51
+ public function deactivatePortalLogin(Request $request)
52
+ {
53
+ $customer = $this->resolveCustomer($request);
54
+ $user = $customer->user_uuid ? User::where('uuid', $customer->user_uuid)->first() : null;
55
+
56
+ if (!$user) {
57
+ return response()->error('Customer portal login not found.');
58
+ }
59
+
60
+ $user->deactivate();
61
+
62
+ return response()->json([
63
+ 'customer' => $this->customerPayload($customer->fresh()),
64
+ ]);
65
+ }
66
+
67
+ public function reactivatePortalLogin(Request $request)
68
+ {
69
+ $customer = $this->resolveCustomer($request);
70
+ $user = $customer->user_uuid ? User::where('uuid', $customer->user_uuid)->first() : null;
71
+
72
+ if (!$user) {
73
+ return response()->error('Customer portal login not found.');
74
+ }
75
+
76
+ $user->activate();
77
+
78
+ return response()->json([
79
+ 'customer' => $this->customerPayload($customer->fresh()),
80
+ ]);
81
+ }
82
+
14
83
  /**
15
84
  * Resets the password for a specified customer and optionally sends the new credentials via email.
16
85
  *
@@ -72,13 +141,10 @@ class CustomerController extends Controller
72
141
  return response()->error('Passwords do not match.');
73
142
  }
74
143
 
75
- $customer = Contact::where(['uuid' => $customerId, 'company_uuid' => session('company'), 'type' => 'customer'])->first();
76
- if (!$customer) {
77
- return response()->error('Customer not found to change password for.');
78
- }
144
+ $customer = $this->resolveCustomer($request);
79
145
 
80
146
  // Load customer user
81
- $user = $customer->user_uuid ? User::where('uuid', $customer->user_uuid)->first() : $customer->createUser();
147
+ $user = $this->resolveCustomerUser($customer);
82
148
  if (!$user) {
83
149
  return response()->error('Unable to reset customer credentials');
84
150
  }
@@ -91,6 +157,50 @@ class CustomerController extends Controller
91
157
  Mail::to($user)->send(new CustomerCredentialsMail($password, $customer));
92
158
  }
93
159
 
94
- return response()->json(['status' => 'ok']);
160
+ return response()->json([
161
+ 'status' => 'ok',
162
+ 'customer' => $this->customerPayload($customer->fresh()),
163
+ ]);
164
+ }
165
+
166
+ protected function resolveCustomer(Request $request): Contact
167
+ {
168
+ $customerId = $request->input('customer');
169
+
170
+ abort_if(!$customerId, 400, 'No customer specified.');
171
+
172
+ return Contact::where('company_uuid', session('company'))
173
+ ->where(function ($query) use ($customerId) {
174
+ $query->where('uuid', $customerId)
175
+ ->orWhere('public_id', $customerId)
176
+ ->orWhere('id', $customerId);
177
+ })
178
+ ->firstOrFail();
179
+ }
180
+
181
+ protected function resolveCustomerUser(Contact $customer): ?User
182
+ {
183
+ return $customer->user_uuid ? User::where('uuid', $customer->user_uuid)->first() : Contact::createUserFromContact($customer, false, true);
184
+ }
185
+
186
+ protected function customerPayload(Contact $customer): array
187
+ {
188
+ $customer->loadMissing('user');
189
+
190
+ return [
191
+ 'id' => $customer->public_id,
192
+ 'uuid' => $customer->uuid,
193
+ 'user_uuid' => $customer->user_uuid,
194
+ 'user' => $customer->user ? [
195
+ 'id' => $customer->user->public_id,
196
+ 'uuid' => $customer->user->uuid,
197
+ 'name' => $customer->user->name,
198
+ 'email' => $customer->user->email,
199
+ 'phone' => $customer->user->phone,
200
+ 'status' => $customer->user->status,
201
+ 'session_status' => $customer->user->session_status,
202
+ 'avatar_url' => $customer->user->avatar_url,
203
+ ] : null,
204
+ ];
95
205
  }
96
206
  }
@@ -0,0 +1,17 @@
1
+ <?php
2
+
3
+ namespace Fleetbase\FleetOps\Http\Controllers\Internal\v1;
4
+
5
+ use Fleetbase\FleetOps\Support\GettingStarted;
6
+ use Fleetbase\Http\Controllers\Controller;
7
+ use Illuminate\Http\Request;
8
+
9
+ class GettingStartedController extends Controller
10
+ {
11
+ public function status(Request $request)
12
+ {
13
+ return response()->json(
14
+ GettingStarted::forCompany($request->user()->company)->get()
15
+ );
16
+ }
17
+ }
@@ -6,9 +6,15 @@ use Fleetbase\FleetOps\Exports\IssueExport;
6
6
  use Fleetbase\FleetOps\Http\Controllers\FleetOpsController;
7
7
  use Fleetbase\FleetOps\Imports\IssueImport;
8
8
  use Fleetbase\FleetOps\Models\Issue;
9
+ use Fleetbase\FleetOps\Models\Order;
9
10
  use Fleetbase\Http\Requests\ExportRequest;
10
11
  use Fleetbase\Http\Requests\ImportRequest;
12
+ use Fleetbase\Models\Activity;
13
+ use Fleetbase\Models\Comment;
14
+ use Fleetbase\Models\File;
15
+ use Fleetbase\Models\User;
11
16
  use Illuminate\Http\Request;
17
+ use Illuminate\Support\Collection;
12
18
  use Illuminate\Support\Str;
13
19
  use Maatwebsite\Excel\Facades\Excel;
14
20
 
@@ -26,10 +32,255 @@ class IssueController extends FleetOpsController
26
32
  */
27
33
  public function afterSave(Request $request, Issue $issue)
28
34
  {
35
+ if ($issue->assigned_to_uuid) {
36
+ $assignee = User::where('uuid', $issue->assigned_to_uuid)->first();
37
+
38
+ if ($assignee && $assignee->type === 'customer') {
39
+ $issue->assigned_to_uuid = null;
40
+ $issue->saveQuietly();
41
+ }
42
+ }
43
+
29
44
  $customFieldValues = $request->array('issue.custom_field_values');
30
45
  if ($customFieldValues) {
31
46
  $issue->syncCustomFieldValues($customFieldValues);
32
47
  }
48
+
49
+ $uploads = $request->array('issue.files');
50
+ if ($uploads) {
51
+ File::whereIn('uuid', $uploads)->get()->each(function (File $file) use ($issue) {
52
+ $file->setKey($issue);
53
+ });
54
+ }
55
+
56
+ if (!$issue->order_uuid && data_get($issue, 'meta.order_uuid')) {
57
+ $order = Order::where('uuid', data_get($issue, 'meta.order_uuid'))->where('company_uuid', $issue->company_uuid)->first();
58
+ if ($order) {
59
+ $issue->order_uuid = $order->uuid;
60
+ $issue->saveQuietly();
61
+ }
62
+ }
63
+ }
64
+
65
+ /**
66
+ * Return curated activity timeline entries for an issue.
67
+ *
68
+ * @return \Illuminate\Http\JsonResponse
69
+ */
70
+ public function timeline($id)
71
+ {
72
+ $issue = Issue::findById($id, ['reporter', 'assignee']);
73
+
74
+ if (!$issue || $issue->company_uuid !== session('company')) {
75
+ return response()->error('Issue not found for this organization.', 404);
76
+ }
77
+
78
+ $events = collect([$this->makeIssueOpenedEvent($issue)])
79
+ ->merge($this->issueActivityEvents($issue))
80
+ ->merge($this->commentEvents($issue))
81
+ ->merge($this->fileEvents($issue))
82
+ ->sortByDesc('created_at')
83
+ ->values();
84
+
85
+ return response()->json(['events' => $events]);
86
+ }
87
+
88
+ protected function issueActivityEvents(Issue $issue): Collection
89
+ {
90
+ return Activity::with(['causer'])
91
+ ->where('subject_type', Issue::class)
92
+ ->where('subject_id', $issue->uuid)
93
+ ->latest()
94
+ ->get()
95
+ ->flatMap(function (Activity $activity) use ($issue) {
96
+ if ($activity->event === 'created') {
97
+ return [];
98
+ }
99
+
100
+ $events = [];
101
+ foreach (['status', 'priority', 'assigned_to_uuid', 'vehicle_uuid', 'driver_uuid', 'order_uuid', 'type', 'category', 'tags', 'resolved_at'] as $field) {
102
+ $from = data_get($activity->properties, "old.{$field}");
103
+ $to = data_get($activity->properties, "attributes.{$field}");
104
+
105
+ if ($from === $to || $to === null) {
106
+ continue;
107
+ }
108
+
109
+ $events[] = $this->makeFieldChangedEvent($activity, $issue, $field, $from, $to);
110
+ }
111
+
112
+ return $events ?: [$this->makeGenericActivityEvent($activity, $issue)];
113
+ });
114
+ }
115
+
116
+ protected function commentEvents(Issue $issue): Collection
117
+ {
118
+ return Comment::with(['author'])
119
+ ->where('subject_type', Issue::class)
120
+ ->where('subject_uuid', $issue->uuid)
121
+ ->latest()
122
+ ->get()
123
+ ->map(function (Comment $comment) {
124
+ return [
125
+ 'id' => $comment->uuid,
126
+ 'type' => 'correspondence_added',
127
+ 'label' => 'Correspondence added',
128
+ 'description' => Str::limit(strip_tags($comment->content ?? ''), 120),
129
+ 'actor_name' => data_get($comment, 'author.name', 'Someone'),
130
+ 'actor_avatar_url' => data_get($comment, 'author.avatar_url'),
131
+ 'created_at' => $comment->created_at,
132
+ 'icon' => 'comment',
133
+ 'tone' => 'blue',
134
+ 'meta' => [
135
+ 'comment_id' => $comment->public_id ?? $comment->uuid,
136
+ ],
137
+ ];
138
+ });
139
+ }
140
+
141
+ protected function fileEvents(Issue $issue): Collection
142
+ {
143
+ $currentFiles = File::with(['uploader'])
144
+ ->where('subject_type', Issue::class)
145
+ ->where('subject_uuid', $issue->uuid)
146
+ ->latest()
147
+ ->get()
148
+ ->map(function (File $file) {
149
+ return [
150
+ 'id' => $file->uuid,
151
+ 'type' => 'document_uploaded',
152
+ 'label' => 'Document uploaded',
153
+ 'description' => $file->original_filename,
154
+ 'actor_name' => data_get($file, 'uploader.name', 'Someone'),
155
+ 'actor_avatar_url' => data_get($file, 'uploader.avatar_url'),
156
+ 'created_at' => $file->created_at,
157
+ 'icon' => 'file-arrow-up',
158
+ 'tone' => 'purple',
159
+ 'meta' => [
160
+ 'file_id' => $file->public_id ?? $file->uuid,
161
+ 'file_name' => $file->original_filename,
162
+ 'file_url' => $file->url,
163
+ ],
164
+ ];
165
+ });
166
+
167
+ $fileActivities = Activity::with(['causer'])
168
+ ->where('subject_type', File::class)
169
+ ->where(function ($query) use ($issue) {
170
+ $query->where('properties->attributes->subject_uuid', $issue->uuid)
171
+ ->orWhere('properties->old->subject_uuid', $issue->uuid);
172
+ })
173
+ ->latest()
174
+ ->get()
175
+ ->filter(fn (Activity $activity) => $activity->event === 'deleted')
176
+ ->map(function (Activity $activity) {
177
+ $fileName = data_get($activity->properties, 'old.original_filename')
178
+ ?? data_get($activity->properties, 'attributes.original_filename')
179
+ ?? 'Document';
180
+
181
+ return [
182
+ 'id' => $activity->uuid ?? $activity->id,
183
+ 'type' => 'document_removed',
184
+ 'label' => 'Document removed',
185
+ 'description' => $fileName,
186
+ 'actor_name' => data_get($activity, 'causer.name', 'Someone'),
187
+ 'actor_avatar_url' => data_get($activity, 'causer.avatar_url'),
188
+ 'created_at' => $activity->created_at,
189
+ 'icon' => 'file-circle-xmark',
190
+ 'tone' => 'red',
191
+ 'meta' => [
192
+ 'file_name' => $fileName,
193
+ ],
194
+ ];
195
+ });
196
+
197
+ return $currentFiles->merge($fileActivities);
198
+ }
199
+
200
+ protected function makeIssueOpenedEvent(Issue $issue): array
201
+ {
202
+ return [
203
+ 'id' => $issue->uuid . '-opened',
204
+ 'type' => 'issue_opened',
205
+ 'label' => 'Issue opened',
206
+ 'description' => 'Reported by ' . ($issue->reporter_name ?: 'Unknown reporter'),
207
+ 'actor_name' => $issue->reporter_name ?: 'Unknown reporter',
208
+ 'actor_avatar_url' => data_get($issue, 'reporter.avatar_url'),
209
+ 'created_at' => $issue->created_at,
210
+ 'icon' => 'circle-plus',
211
+ 'tone' => 'green',
212
+ 'meta' => [
213
+ 'issue_id' => $issue->public_id,
214
+ ],
215
+ ];
216
+ }
217
+
218
+ protected function makeFieldChangedEvent(Activity $activity, Issue $issue, string $field, $from, $to): array
219
+ {
220
+ $eventMap = [
221
+ 'status' => ['status_changed', 'Status changed', 'arrows-rotate', 'blue'],
222
+ 'priority' => ['priority_changed', 'Priority changed', 'flag', 'orange'],
223
+ 'assigned_to_uuid' => ['assignee_changed', 'Assignee changed', 'user-check', 'indigo'],
224
+ 'vehicle_uuid' => ['vehicle_changed', 'Vehicle linked', 'truck', 'slate'],
225
+ 'driver_uuid' => ['driver_changed', 'Driver linked', 'id-card', 'slate'],
226
+ 'order_uuid' => ['order_changed', 'Order linked', 'box', 'slate'],
227
+ 'resolved_at' => [$to ? 'issue_closed' : 'issue_reopened', $to ? 'Issue closed' : 'Issue re-opened', $to ? 'circle-check' : 'rotate-left', $to ? 'green' : 'orange'],
228
+ ];
229
+
230
+ [$type, $label, $icon, $tone] = $eventMap[$field] ?? ['issue_updated', Str::headline(str_replace('_uuid', '', $field)) . ' changed', 'pen', 'slate'];
231
+
232
+ if ($field === 'status' && in_array($to, ['closed', 'resolved', 'completed'])) {
233
+ [$type, $label, $icon, $tone] = ['issue_closed', 'Issue closed', 'circle-check', 'green'];
234
+ }
235
+
236
+ if ($field === 'status' && $to === 're_opened') {
237
+ [$type, $label, $icon, $tone] = ['issue_reopened', 'Issue re-opened', 'rotate-left', 'orange'];
238
+ }
239
+
240
+ return [
241
+ 'id' => ($activity->uuid ?? $activity->id) . '-' . $field,
242
+ 'type' => $type,
243
+ 'label' => $label,
244
+ 'description' => $this->formatChangeDescription($field, $from, $to),
245
+ 'actor_name' => data_get($activity, 'causer.name', 'Someone'),
246
+ 'actor_avatar_url' => data_get($activity, 'causer.avatar_url'),
247
+ 'created_at' => $activity->created_at,
248
+ 'icon' => $icon,
249
+ 'tone' => $tone,
250
+ 'meta' => [
251
+ 'field' => $field,
252
+ 'from' => $from,
253
+ 'to' => $to,
254
+ 'issue_id' => $issue->public_id,
255
+ ],
256
+ ];
257
+ }
258
+
259
+ protected function makeGenericActivityEvent(Activity $activity, Issue $issue): array
260
+ {
261
+ return [
262
+ 'id' => $activity->uuid ?? $activity->id,
263
+ 'type' => 'issue_updated',
264
+ 'label' => 'Issue updated',
265
+ 'description' => $activity->description,
266
+ 'actor_name' => data_get($activity, 'causer.name', 'Someone'),
267
+ 'actor_avatar_url' => data_get($activity, 'causer.avatar_url'),
268
+ 'created_at' => $activity->created_at,
269
+ 'icon' => 'pen',
270
+ 'tone' => 'slate',
271
+ 'meta' => [
272
+ 'issue_id' => $issue->public_id,
273
+ ],
274
+ ];
275
+ }
276
+
277
+ protected function formatChangeDescription(string $field, $from, $to): string
278
+ {
279
+ $fieldLabel = Str::headline(str_replace('_uuid', '', $field));
280
+ $fromLabel = blank($from) ? 'none' : Str::headline((string) $from);
281
+ $toLabel = blank($to) ? 'none' : Str::headline((string) $to);
282
+
283
+ return "{$fieldLabel} changed from {$fromLabel} to {$toLabel}.";
33
284
  }
34
285
 
35
286
  /**