@fleetbase/fleetops-engine 0.6.67 → 0.6.69
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.
- package/RELEASE.md +11 -15
- package/addon/components/contact/form.hbs +13 -2
- package/addon/components/customer/details.hbs +17 -17
- package/addon/components/customer/form.hbs +13 -2
- package/addon/components/customer/form.js +0 -56
- package/addon/components/driver/details.hbs +13 -19
- package/addon/components/driver/form.hbs +20 -35
- package/addon/components/driver/form.js +0 -58
- package/addon/components/fuel-integration/form.hbs +2 -2
- package/addon/components/fuel-integration/form.js +8 -0
- package/addon/components/fuel-integration/hub.js +1 -1
- package/addon/components/modals/driver-form.hbs +2 -2
- package/addon/components/modals/reset-profile-credentials.hbs +23 -0
- package/addon/components/modals/reset-profile-credentials.js +70 -0
- package/addon/components/profile-identity-hint.hbs +13 -0
- package/addon/components/profile-identity-hint.js +49 -0
- package/addon/controllers/management/drivers/index.js +4 -0
- package/addon/extension.js +4 -2
- package/addon/services/contact-actions.js +50 -89
- package/addon/services/driver-actions.js +98 -0
- package/addon/templates/connectivity/fuel-providers/details/index.hbs +1 -1
- package/addon/utils/fuel-transaction.js +2 -0
- package/addon/utils/profile-login-actions.js +172 -0
- package/app/components/modals/{reset-customer-credentials.js → reset-profile-credentials.js} +1 -1
- package/app/components/profile-identity-hint.js +1 -0
- package/composer.json +1 -1
- package/docs/SAFEE.md +5 -0
- package/docs/TELEMATICS_QUEUES.md +59 -8
- package/extension.json +1 -1
- package/package.json +3 -3
- package/scripts/pest-bootstrap.php +12 -0
- package/server/config/fuel-providers.php +53 -0
- package/server/migrations/2026_09_21_000001_convert_profile_only_users_to_managed_accounts.php +132 -0
- package/server/resources/views/mail/driver-credentials.blade.php +21 -0
- package/server/src/Console/Commands/FixCustomerCompanies.php +1 -1
- package/server/src/Console/Commands/FixDriverCompanies.php +1 -1
- package/server/src/Exceptions/ProfileIdentityConflictException.php +34 -0
- package/server/src/Http/Controllers/Api/v1/CustomerController.php +14 -0
- package/server/src/Http/Controllers/Api/v1/DriverController.php +56 -39
- package/server/src/Http/Controllers/Internal/v1/ContactController.php +5 -17
- package/server/src/Http/Controllers/Internal/v1/CustomerController.php +40 -4
- package/server/src/Http/Controllers/Internal/v1/DriverController.php +182 -162
- package/server/src/Http/Controllers/Internal/v1/FleetOpsLookupController.php +47 -0
- package/server/src/Http/Controllers/Internal/v1/OrderController.php +156 -27
- package/server/src/Http/Requests/CreateDriverRequest.php +6 -2
- package/server/src/Http/Requests/Internal/CreateDriverRequest.php +9 -18
- package/server/src/Http/Resources/v1/Contact.php +3 -0
- package/server/src/Http/Resources/v1/Driver.php +3 -0
- package/server/src/Jobs/PollTelematicTelemetry.php +36 -0
- package/server/src/Jobs/SyncTelematicDevicesJob.php +2 -2
- package/server/src/Mail/DriverCredentialsMail.php +55 -0
- package/server/src/Models/Contact.php +55 -90
- package/server/src/Models/Sensor.php +3 -5
- package/server/src/Observers/ContactObserver.php +2 -22
- package/server/src/Observers/DriverObserver.php +5 -6
- package/server/src/Providers/FleetOpsServiceProvider.php +15 -0
- package/server/src/Support/Ai/Capabilities/AbstractFleetOpsAICapability.php +62 -5
- package/server/src/Support/Ai/Capabilities/AssetStatusCapability.php +24 -5
- package/server/src/Support/Ai/Capabilities/CreateOrderPreviewCapability.php +6 -2
- package/server/src/Support/Ai/Capabilities/OrderInsightsCapability.php +40 -7
- package/server/src/Support/Ai/Capabilities/SearchResourcesCapability.php +86 -24
- package/server/src/Support/Ai/FleetOpsAiConsoleCommands.php +122 -0
- package/server/src/Support/Ai/Tools/CreateOrderTool.php +86 -0
- package/server/src/Support/Ai/Tools/ImportOrdersTool.php +53 -0
- package/server/src/Support/Ai/Tools/OptimizeOrderRouteTool.php +70 -0
- package/server/src/Support/Ai/Tools/SearchResourcesTool.php +73 -0
- package/server/src/Support/FuelProviders/Providers/SascoFuelProvider.php +272 -0
- package/server/src/Support/ProfileAccountManager.php +405 -0
- package/server/src/Support/Telematics/TelematicService.php +6 -4
- package/server/src/Support/Telematics/Telemetry/Inbox.php +27 -9
- package/server/src/routes.php +5 -0
- package/server/tests/AiOperationalQueryCapabilityTest.php +1 -0
- package/server/tests/ApiCustomerControllerContractsTest.php +21 -0
- package/server/tests/ApiDriverControllerContractsTest.php +102 -20
- package/server/tests/CommandContractsTest.php +4 -4
- package/server/tests/ControllerHelperContractsTest.php +3 -11
- package/server/tests/Feature/Http/AfaqyRealtimeIngestionTest.php +5 -0
- package/server/tests/Feature/Http/Api/DriverControllerAuthFlowsTest.php +36 -0
- package/server/tests/Feature/Http/Api/DriverControllerSessionHelpersTest.php +19 -6
- package/server/tests/Feature/Http/Api/ProfileOnlyUserConversionMigrationTest.php +201 -0
- package/server/tests/Feature/Http/Internal/ContactControllerEndpointsTest.php +6 -4
- package/server/tests/Feature/Http/Internal/CustomerControllerCredentialsTest.php +6 -2
- package/server/tests/Feature/Http/Internal/DriverControllerContractsTest.php +23 -0
- package/server/tests/Feature/Http/Internal/DriverControllerCreateRecordTest.php +42 -17
- package/server/tests/Feature/Http/Internal/DriverControllerExistingUserAdoptionTest.php +168 -277
- package/server/tests/Feature/Http/Internal/DriverControllerLoginManagementTest.php +716 -0
- package/server/tests/Feature/Http/Internal/OrderControllerContractsTest.php +10 -3
- package/server/tests/Feature/Http/Internal/OrderControllerImportFromFilesTest.php +18 -0
- package/server/tests/Feature/Http/Internal/OrderControllerTenantScopingTest.php +411 -0
- package/server/tests/Feature/Http/Internal/OrderControllerUpstreamNotFoundTest.php +8 -20
- package/server/tests/Feature/Http/ManualTelemetryPollingTest.php +88 -4
- package/server/tests/InternalCustomerControllerContractsTest.php +32 -1
- package/server/tests/ModelAccessorContractsTest.php +82 -20
- package/server/tests/ObserverContractsTest.php +27 -2
- package/server/tests/RequestContractsTest.php +19 -11
- package/server/tests/SupportJobAndAiCoverageTest.php +3 -3
- package/server/tests/TelematicControllerContractsTest.php +22 -4
- package/server/tests/Unit/Models/ContactCustomerUserTest.php +90 -14
- package/server/tests/Unit/Models/ContactTest.php +48 -5
- package/server/tests/Unit/Models/SensorTest.php +2 -2
- package/server/tests/Unit/Models/SmallModelSurfacesTest.php +31 -32
- package/server/tests/Unit/Observers/ObserverListenerSeamsTest.php +7 -2
- package/server/tests/Unit/Providers/FleetOpsAiRegistrationTest.php +15 -2
- package/server/tests/Unit/Support/Ai/AssetStatusCapabilityQueriesTest.php +42 -0
- package/server/tests/Unit/Support/Ai/Capabilities/OrderInsightsCapabilityTest.php +19 -1
- package/server/tests/Unit/Support/Ai/Capabilities/SearchResourcesCapabilityTest.php +85 -1
- package/server/tests/Unit/Support/Ai/FleetOpsAiConsoleCommandsTest.php +31 -0
- package/server/tests/Unit/Support/Ai/Tools/FleetOpsAiToolsTest.php +246 -0
- package/server/tests/Unit/Support/AiCapabilityPermissionsTest.php +3 -3
- package/server/tests/Unit/Support/ProfileAccountManagerTest.php +643 -0
- package/server/tests/Unit/Support/SascoFuelProviderTest.php +338 -0
- package/translations/en-us.yaml +37 -2
- package/translations/uk-ua.yaml +3475 -0
- package/addon/components/modals/reset-customer-credentials.hbs +0 -18
- package/addon/components/modals/reset-customer-credentials.js +0 -55
|
@@ -0,0 +1,272 @@
|
|
|
1
|
+
<?php
|
|
2
|
+
|
|
3
|
+
namespace Fleetbase\FleetOps\Support\FuelProviders\Providers;
|
|
4
|
+
|
|
5
|
+
use Fleetbase\FleetOps\Models\FuelProviderConnection;
|
|
6
|
+
use Illuminate\Http\Client\Response;
|
|
7
|
+
use Illuminate\Support\Carbon;
|
|
8
|
+
use Illuminate\Support\Collection;
|
|
9
|
+
use Illuminate\Support\Facades\Cache;
|
|
10
|
+
use Illuminate\Support\Facades\Http;
|
|
11
|
+
|
|
12
|
+
class SascoFuelProvider extends AbstractFuelProvider
|
|
13
|
+
{
|
|
14
|
+
public const BASE_URLS = [
|
|
15
|
+
'sandbox' => 'https://loyalty-api-qc.sasco.sa',
|
|
16
|
+
'production' => 'https://loyalty-api.sasco.com.sa',
|
|
17
|
+
];
|
|
18
|
+
|
|
19
|
+
public const LOGIN_ENDPOINT = 'apigateway/api/identity/user/b2badmin-login';
|
|
20
|
+
public const VEHICLES_ENDPOINT = 'apigateway/api/loyaltycore/b2bexternaladmin/b2bvehicles';
|
|
21
|
+
public const TRANSACTIONS_ENDPOINT = 'apigateway/api/analytics/v1/b2bexternal/drivers-transactions';
|
|
22
|
+
|
|
23
|
+
/** Fallback token lifetime when the JWT carries no readable exp claim. */
|
|
24
|
+
protected const DEFAULT_TOKEN_TTL = 3600;
|
|
25
|
+
|
|
26
|
+
public function key(): string
|
|
27
|
+
{
|
|
28
|
+
return 'sasco';
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
public function name(): string
|
|
32
|
+
{
|
|
33
|
+
return 'SASCO';
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
public function authenticate(FuelProviderConnection $connection): array
|
|
37
|
+
{
|
|
38
|
+
$credentials = (array) $connection->credentials;
|
|
39
|
+
$response = Http::baseUrl($this->baseUrl($connection))
|
|
40
|
+
->timeout((int) data_get($credentials, 'timeout', 30))
|
|
41
|
+
->acceptJson()
|
|
42
|
+
->asJson()
|
|
43
|
+
->post(self::LOGIN_ENDPOINT, [
|
|
44
|
+
'username' => trim((string) data_get($credentials, 'username')),
|
|
45
|
+
'password' => (string) data_get($credentials, 'password'),
|
|
46
|
+
]);
|
|
47
|
+
|
|
48
|
+
$token = $response->json('accessToken');
|
|
49
|
+
if (!$response->successful() || !is_string($token) || $token === '') {
|
|
50
|
+
return [
|
|
51
|
+
'success' => false,
|
|
52
|
+
'message' => $this->errorMessage($response, 'SASCO login failed. Check the B2B admin username and password.'),
|
|
53
|
+
'status' => $response->status(),
|
|
54
|
+
];
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
Cache::put($this->tokenCacheKey($connection), $token, $this->tokenTtl($token));
|
|
58
|
+
|
|
59
|
+
return ['success' => true, 'message' => 'SASCO login successful.', 'token' => $token];
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
public function testConnection(FuelProviderConnection $connection): array
|
|
63
|
+
{
|
|
64
|
+
$metadata = [
|
|
65
|
+
'environment' => $connection->environment ?? 'production',
|
|
66
|
+
'host' => parse_url($this->baseUrl($connection), PHP_URL_HOST),
|
|
67
|
+
];
|
|
68
|
+
|
|
69
|
+
try {
|
|
70
|
+
$response = $this->get($connection, self::VEHICLES_ENDPOINT, ['Page_size' => 1, 'Current_page' => 1]);
|
|
71
|
+
$metadata['status'] = $response->status();
|
|
72
|
+
|
|
73
|
+
if (!$response->successful() || !is_array($response->json('vehicles'))) {
|
|
74
|
+
return [
|
|
75
|
+
'success' => false,
|
|
76
|
+
'message' => $this->errorMessage($response, 'Unable to connect to SASCO.'),
|
|
77
|
+
'metadata' => $metadata,
|
|
78
|
+
];
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
return [
|
|
82
|
+
'success' => true,
|
|
83
|
+
'message' => 'SASCO connection successful.',
|
|
84
|
+
'metadata' => array_merge($metadata, ['total_vehicles' => $response->json('totalItems')]),
|
|
85
|
+
];
|
|
86
|
+
} catch (\Throwable $e) {
|
|
87
|
+
return ['success' => false, 'message' => $e->getMessage(), 'metadata' => $metadata];
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
public function listVehicles(FuelProviderConnection $connection, array $options = []): Collection
|
|
92
|
+
{
|
|
93
|
+
return $this->paginatedGet($connection, self::VEHICLES_ENDPOINT, 'vehicles', $options);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
public function listTransactions(FuelProviderConnection $connection, Carbon $from, Carbon $to, array $options = []): Collection
|
|
97
|
+
{
|
|
98
|
+
$params = [
|
|
99
|
+
'FromDate' => $from->toDateString(),
|
|
100
|
+
'ToDate' => $to->toDateString(),
|
|
101
|
+
];
|
|
102
|
+
|
|
103
|
+
if (array_key_exists('is_trip', $options)) {
|
|
104
|
+
$params['IsTrip'] = $options['is_trip'] ? 'true' : 'false';
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
return $this->paginatedGet($connection, self::TRANSACTIONS_ENDPOINT, 'driverTransactions', array_merge($options, $params))
|
|
108
|
+
->map(fn ($payload) => $this->normalizeTransaction($payload));
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
protected function baseUrl(FuelProviderConnection $connection): string
|
|
112
|
+
{
|
|
113
|
+
$credentials = (array) $connection->credentials;
|
|
114
|
+
$override = trim((string) data_get($credentials, 'base_url', ''));
|
|
115
|
+
|
|
116
|
+
return rtrim($override ?: self::BASE_URLS[$connection->environment ?? 'production'], '/');
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
protected function headers(FuelProviderConnection $connection): array
|
|
120
|
+
{
|
|
121
|
+
return ['Authorization' => 'Bearer ' . $this->token($connection)];
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* Returns a cached access token, logging in when none is cached.
|
|
126
|
+
*/
|
|
127
|
+
protected function token(FuelProviderConnection $connection): string
|
|
128
|
+
{
|
|
129
|
+
$token = Cache::get($this->tokenCacheKey($connection));
|
|
130
|
+
if (is_string($token) && $token !== '') {
|
|
131
|
+
return $token;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
$result = $this->authenticate($connection);
|
|
135
|
+
if (!$result['success']) {
|
|
136
|
+
throw new \RuntimeException($result['message']);
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
return $result['token'];
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* Keyed by host and username rather than connection uuid so unsaved
|
|
144
|
+
* connections built for credential tests share the same cache entry.
|
|
145
|
+
*/
|
|
146
|
+
protected function tokenCacheKey(FuelProviderConnection $connection): string
|
|
147
|
+
{
|
|
148
|
+
$credentials = (array) $connection->credentials;
|
|
149
|
+
|
|
150
|
+
return 'fuel-provider:sasco:token:' . hash('sha256', $this->baseUrl($connection) . '|' . trim((string) data_get($credentials, 'username')));
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
protected function tokenTtl(string $token): int
|
|
154
|
+
{
|
|
155
|
+
$segments = explode('.', $token);
|
|
156
|
+
$payload = isset($segments[1]) ? json_decode((string) base64_decode(strtr($segments[1], '-_', '+/')), true) : null;
|
|
157
|
+
$expires = (int) data_get($payload, 'exp', 0);
|
|
158
|
+
|
|
159
|
+
if ($expires <= 0) {
|
|
160
|
+
return self::DEFAULT_TOKEN_TTL;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
// Refresh a minute early so a request never goes out with a token about to lapse.
|
|
164
|
+
return max(1, $expires - now()->timestamp - 60);
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/**
|
|
168
|
+
* Sends an authenticated GET, re-authenticating once when SASCO rejects a cached token.
|
|
169
|
+
*/
|
|
170
|
+
protected function get(FuelProviderConnection $connection, string $endpoint, array $params = []): Response
|
|
171
|
+
{
|
|
172
|
+
$response = $this->client($connection)->get($endpoint, $params);
|
|
173
|
+
|
|
174
|
+
if ($response->status() === 401) {
|
|
175
|
+
Cache::forget($this->tokenCacheKey($connection));
|
|
176
|
+
$response = $this->client($connection)->get($endpoint, $params);
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
return $response;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
protected function paginatedGet(FuelProviderConnection $connection, string $endpoint, string $dataKey, array $params = []): Collection
|
|
183
|
+
{
|
|
184
|
+
$page = max(1, (int) data_get($params, 'page', 1));
|
|
185
|
+
$pageSize = max(1, (int) data_get($params, 'page_size', 100));
|
|
186
|
+
$maxPages = (int) data_get($params, 'max_pages', 100);
|
|
187
|
+
$query = collect($params)->except(['page', 'page_size', 'max_pages', 'is_trip'])->all();
|
|
188
|
+
|
|
189
|
+
$items = collect();
|
|
190
|
+
|
|
191
|
+
do {
|
|
192
|
+
$response = $this->get($connection, $endpoint, array_merge($query, ['Page_size' => $pageSize, 'Current_page' => $page]));
|
|
193
|
+
|
|
194
|
+
if (!$response->successful()) {
|
|
195
|
+
throw new \RuntimeException($this->errorMessage($response, "SASCO {$endpoint} request failed."));
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
$data = $response->json($dataKey);
|
|
199
|
+
if (!is_array($data)) {
|
|
200
|
+
throw new \RuntimeException("SASCO {$endpoint} returned an invalid response.");
|
|
201
|
+
}
|
|
202
|
+
$items = $items->merge($data);
|
|
203
|
+
|
|
204
|
+
$total = (int) $response->json('totalItems', 0);
|
|
205
|
+
$hasMore = count($data) > 0 && $items->count() < $total;
|
|
206
|
+
$page++;
|
|
207
|
+
} while ($hasMore && $page <= $maxPages);
|
|
208
|
+
|
|
209
|
+
if ($hasMore) {
|
|
210
|
+
throw new \RuntimeException('SASCO returned more pages than the sync limit. Choose a shorter date range and retry; the import was not marked complete.');
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
return $items;
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
protected function errorMessage(Response $response, string $fallback): string
|
|
217
|
+
{
|
|
218
|
+
$message = $response->json('message') ?? $response->json('title') ?? $response->json('error');
|
|
219
|
+
|
|
220
|
+
return is_string($message) && $message !== '' ? $message : $fallback;
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
/**
|
|
224
|
+
* Saudi plates are printed as three letters and up to four digits, e.g. "NXD 1240".
|
|
225
|
+
*/
|
|
226
|
+
protected function plateNumber(array $plate): ?string
|
|
227
|
+
{
|
|
228
|
+
$number = $this->compactIdentifier($plate['plateNumber'] ?? null);
|
|
229
|
+
$letters = collect([$plate['firstPlateLetter'] ?? null, $plate['secondPlateLetter'] ?? null, $plate['thirdPlateLetter'] ?? null])
|
|
230
|
+
->map(fn ($letter) => $this->compactIdentifier($letter))
|
|
231
|
+
->filter()
|
|
232
|
+
->implode('');
|
|
233
|
+
|
|
234
|
+
return $this->compactIdentifier(trim($letters . ' ' . $number));
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
protected function normalizeTransaction(array $transaction): array
|
|
238
|
+
{
|
|
239
|
+
$plate = (array) ($transaction['vehiclePlate'] ?? []);
|
|
240
|
+
$providerId = $this->compactIdentifier($transaction['transactionNumber'] ?? null);
|
|
241
|
+
$fallbackId = $this->transactionHash([
|
|
242
|
+
$transaction['createdAt'] ?? null,
|
|
243
|
+
$plate,
|
|
244
|
+
$transaction['stationName'] ?? null,
|
|
245
|
+
$transaction['noOfLitters'] ?? null,
|
|
246
|
+
$transaction['amount'] ?? null,
|
|
247
|
+
$transaction['driverPhone'] ?? null,
|
|
248
|
+
]);
|
|
249
|
+
|
|
250
|
+
return [
|
|
251
|
+
'provider' => $this->key(),
|
|
252
|
+
'provider_transaction_id' => (string) ($providerId ?: $fallbackId),
|
|
253
|
+
'plate_number' => $this->plateNumber($plate),
|
|
254
|
+
'trip_number' => $this->compactIdentifier($transaction['tripId'] ?? null),
|
|
255
|
+
'station_name' => $this->compactIdentifier($transaction['stationName'] ?? null),
|
|
256
|
+
'transaction_at' => $this->dateFrom($transaction['createdAt'] ?? null),
|
|
257
|
+
'volume' => $transaction['noOfLitters'] ?? null,
|
|
258
|
+
'metric_unit' => 'l',
|
|
259
|
+
'amount' => $this->minorCurrencyUnit($transaction['amount'] ?? null),
|
|
260
|
+
'currency' => 'SAR',
|
|
261
|
+
'normalized_payload' => [
|
|
262
|
+
'fuel_type' => $transaction['fuelType'] ?? null,
|
|
263
|
+
'driver_name' => $transaction['driverName'] ?? null,
|
|
264
|
+
'driver_phone' => $transaction['driverPhone'] ?? null,
|
|
265
|
+
'type' => $transaction['type'] ?? null,
|
|
266
|
+
'period' => $transaction['period'] ?? null,
|
|
267
|
+
'fuel_image_url' => $transaction['fuelImageUrl'] ?? null,
|
|
268
|
+
],
|
|
269
|
+
'raw_payload' => $transaction,
|
|
270
|
+
];
|
|
271
|
+
}
|
|
272
|
+
}
|
|
@@ -0,0 +1,405 @@
|
|
|
1
|
+
<?php
|
|
2
|
+
|
|
3
|
+
namespace Fleetbase\FleetOps\Support;
|
|
4
|
+
|
|
5
|
+
use Fleetbase\FleetOps\Exceptions\ProfileIdentityConflictException;
|
|
6
|
+
use Fleetbase\FleetOps\Mail\CustomerCredentialsMail;
|
|
7
|
+
use Fleetbase\FleetOps\Mail\DriverCredentialsMail;
|
|
8
|
+
use Fleetbase\FleetOps\Models\Contact;
|
|
9
|
+
use Fleetbase\FleetOps\Models\Driver;
|
|
10
|
+
use Fleetbase\Models\Company;
|
|
11
|
+
use Fleetbase\Models\CompanyUser;
|
|
12
|
+
use Fleetbase\Models\User;
|
|
13
|
+
use Fleetbase\Services\SmsService;
|
|
14
|
+
use Illuminate\Support\Facades\Mail;
|
|
15
|
+
use Illuminate\Support\Str;
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Manages the login accounts behind driver, contact and customer profiles.
|
|
19
|
+
*
|
|
20
|
+
* A profile either owns a managed account (user type `driver`, `contact` or
|
|
21
|
+
* `customer`), which it creates, updates and deletes, or it is linked to a
|
|
22
|
+
* staff account (type `user` or `admin`) of the same organization. A staff
|
|
23
|
+
* account is managed in IAM, so a profile never changes its email or phone,
|
|
24
|
+
* resets its password, deactivates it or deletes it.
|
|
25
|
+
*/
|
|
26
|
+
class ProfileAccountManager
|
|
27
|
+
{
|
|
28
|
+
/**
|
|
29
|
+
* User types owned by a profile.
|
|
30
|
+
*/
|
|
31
|
+
public const MANAGED_TYPES = ['driver', 'contact', 'customer'];
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* The organization role given to each managed account type.
|
|
35
|
+
*/
|
|
36
|
+
public const ROLES = [
|
|
37
|
+
'driver' => 'Driver',
|
|
38
|
+
'contact' => 'Fleet-Ops Contact',
|
|
39
|
+
'customer' => 'Fleet-Ops Customer',
|
|
40
|
+
];
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Whether the account is owned by a driver, contact or customer profile.
|
|
44
|
+
*/
|
|
45
|
+
public static function isManagedAccount(?object $user): bool
|
|
46
|
+
{
|
|
47
|
+
return $user !== null && in_array(data_get($user, 'type'), static::MANAGED_TYPES, true);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Whether the account is a team member's account managed in IAM.
|
|
52
|
+
*/
|
|
53
|
+
public static function isStaffAccount(?object $user): bool
|
|
54
|
+
{
|
|
55
|
+
return $user !== null && !static::isManagedAccount($user);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Find the active account holding the given email or phone.
|
|
60
|
+
*/
|
|
61
|
+
public static function findAccountByIdentity(?string $email, ?string $phone, ?string $ignoreUserUuid = null): ?User
|
|
62
|
+
{
|
|
63
|
+
$email = static::normalizeEmail($email);
|
|
64
|
+
$phone = static::normalizePhone($phone);
|
|
65
|
+
|
|
66
|
+
if (!$email && !$phone) {
|
|
67
|
+
return null;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
return User::where(function ($query) use ($email, $phone) {
|
|
71
|
+
if ($email) {
|
|
72
|
+
$query->orWhere('email', $email);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
if ($phone) {
|
|
76
|
+
$query->orWhere('phone', $phone);
|
|
77
|
+
}
|
|
78
|
+
})
|
|
79
|
+
->when($ignoreUserUuid, fn ($query) => $query->where('uuid', '!=', $ignoreUserUuid))
|
|
80
|
+
->whereNull('deleted_at')
|
|
81
|
+
->first();
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Describe what would happen to an email/phone entered on a new or existing
|
|
86
|
+
* profile: `staff` is the team member the profile would link to, `conflict`
|
|
87
|
+
* is why the email/phone cannot be used.
|
|
88
|
+
*
|
|
89
|
+
* @return array{account: ?User, staff: ?User, conflict: ?string}
|
|
90
|
+
*/
|
|
91
|
+
public static function lookup(string $companyUuid, string $type, ?string $email, ?string $phone, ?User $currentUser = null): array
|
|
92
|
+
{
|
|
93
|
+
$result = ['account' => null, 'staff' => null, 'conflict' => null];
|
|
94
|
+
$account = static::findAccountByIdentity($email, $phone, $currentUser?->uuid);
|
|
95
|
+
|
|
96
|
+
if (!$account) {
|
|
97
|
+
return $result;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
$result['account'] = $account;
|
|
101
|
+
$field = static::matchedField($account, $email);
|
|
102
|
+
|
|
103
|
+
// An existing profile can't swap its account for another one
|
|
104
|
+
if ($currentUser) {
|
|
105
|
+
$result['conflict'] = static::takenMessage($field);
|
|
106
|
+
|
|
107
|
+
return $result;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
try {
|
|
111
|
+
static::assertAccountCanHoldProfile($account, $companyUuid, $type, $field);
|
|
112
|
+
} catch (ProfileIdentityConflictException $e) {
|
|
113
|
+
$result['conflict'] = $e->getMessage();
|
|
114
|
+
|
|
115
|
+
return $result;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
if (static::isStaffAccount($account)) {
|
|
119
|
+
$result['staff'] = $account;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
return $result;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* Resolve the account for a new profile. A staff member of the organization
|
|
127
|
+
* or a managed account of the same type with the email/phone is linked;
|
|
128
|
+
* otherwise a managed account is created.
|
|
129
|
+
*
|
|
130
|
+
* @param array $attributes extra user attributes: password, status, timezone, avatar_uuid, country, ip_address, meta
|
|
131
|
+
*
|
|
132
|
+
* @throws ProfileIdentityConflictException
|
|
133
|
+
*/
|
|
134
|
+
public static function resolveForProfile(string $companyUuid, string $type, ?string $name, ?string $email, ?string $phone, array $attributes = []): User
|
|
135
|
+
{
|
|
136
|
+
$account = static::findAccountByIdentity($email, $phone);
|
|
137
|
+
|
|
138
|
+
if ($account) {
|
|
139
|
+
static::assertAccountCanHoldProfile($account, $companyUuid, $type, static::matchedField($account, $email));
|
|
140
|
+
|
|
141
|
+
if (static::isManagedAccount($account)) {
|
|
142
|
+
static::attachToCompany($account, $companyUuid, $type);
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
return $account;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
return static::createManagedAccount($companyUuid, $type, $name, $email, $phone, $attributes);
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/**
|
|
152
|
+
* Create a managed account and add it to the organization without an invite.
|
|
153
|
+
*/
|
|
154
|
+
public static function createManagedAccount(string $companyUuid, string $type, ?string $name, ?string $email, ?string $phone, array $attributes = []): User
|
|
155
|
+
{
|
|
156
|
+
$company = Company::where('uuid', $companyUuid)->first();
|
|
157
|
+
$password = $attributes['password'] ?? null;
|
|
158
|
+
$userData = array_filter([
|
|
159
|
+
'company_uuid' => $companyUuid,
|
|
160
|
+
'name' => $name,
|
|
161
|
+
'email' => static::normalizeEmail($email),
|
|
162
|
+
'phone' => static::normalizePhone($phone),
|
|
163
|
+
'username' => User::generateUsername($name ?: Str::random(6)),
|
|
164
|
+
'timezone' => $attributes['timezone'] ?? $company?->timezone ?? date_default_timezone_get(),
|
|
165
|
+
'status' => $attributes['status'] ?? 'pending',
|
|
166
|
+
'avatar_uuid' => $attributes['avatar_uuid'] ?? null,
|
|
167
|
+
'country' => $attributes['country'] ?? null,
|
|
168
|
+
'ip_address' => $attributes['ip_address'] ?? null,
|
|
169
|
+
'meta' => $attributes['meta'] ?? null,
|
|
170
|
+
], fn ($value) => $value !== null);
|
|
171
|
+
|
|
172
|
+
$user = new User($userData);
|
|
173
|
+
$user->password = $password ?: Str::random(16);
|
|
174
|
+
$user->save();
|
|
175
|
+
$user->setType($type);
|
|
176
|
+
|
|
177
|
+
static::attachToCompany($user, $companyUuid, $type);
|
|
178
|
+
|
|
179
|
+
return $user;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/**
|
|
183
|
+
* Push the profile's proxy fields to its account. A staff account only
|
|
184
|
+
* takes the name: its email and phone are its console identity.
|
|
185
|
+
*
|
|
186
|
+
* @throws ProfileIdentityConflictException
|
|
187
|
+
*/
|
|
188
|
+
public static function syncProxyFields(?User $user, array $attributes): bool
|
|
189
|
+
{
|
|
190
|
+
if (!$user) {
|
|
191
|
+
return false;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
$fields = static::isStaffAccount($user) ? ['name'] : ['name', 'email', 'phone', 'timezone', 'avatar_uuid', 'password'];
|
|
195
|
+
$input = array_intersect_key($attributes, array_flip($fields));
|
|
196
|
+
|
|
197
|
+
if (array_key_exists('email', $input)) {
|
|
198
|
+
$input['email'] = static::normalizeEmail($input['email']);
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
if (array_key_exists('phone', $input)) {
|
|
202
|
+
$input['phone'] = static::normalizePhone($input['phone']);
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
$input = array_filter($input, fn ($value, $key) => $value !== null || in_array($key, ['email', 'phone'], true), ARRAY_FILTER_USE_BOTH);
|
|
206
|
+
$input = array_filter($input, fn ($value, $key) => $key === 'password' || $user->{$key} !== $value, ARRAY_FILTER_USE_BOTH);
|
|
207
|
+
|
|
208
|
+
if (empty($input)) {
|
|
209
|
+
return false;
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
$changedEmail = array_key_exists('email', $input) ? $input['email'] : null;
|
|
213
|
+
$changedPhone = array_key_exists('phone', $input) ? $input['phone'] : null;
|
|
214
|
+
$taken = static::findAccountByIdentity($changedEmail, $changedPhone, $user->uuid);
|
|
215
|
+
if ($taken) {
|
|
216
|
+
throw new ProfileIdentityConflictException(static::takenMessage(static::matchedField($taken, $changedEmail)), static::matchedField($taken, $changedEmail));
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
if (isset($input['password'])) {
|
|
220
|
+
$user->password = $input['password'];
|
|
221
|
+
unset($input['password']);
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
$user->fill($input);
|
|
225
|
+
|
|
226
|
+
return $user->save();
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
/**
|
|
230
|
+
* Release a profile's account after the profile is deleted. A managed
|
|
231
|
+
* account with no other profile is deleted, which frees its email and
|
|
232
|
+
* phone; one still used by another organization's profile only leaves this
|
|
233
|
+
* organization. Staff accounts are never touched.
|
|
234
|
+
*/
|
|
235
|
+
public static function releaseForProfile(?User $user, ?string $companyUuid = null): void
|
|
236
|
+
{
|
|
237
|
+
if (!static::isManagedAccount($user) || $user->trashed()) {
|
|
238
|
+
return;
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
$remainingProfiles = static::profileCompanies($user);
|
|
242
|
+
if ($remainingProfiles->isEmpty()) {
|
|
243
|
+
$user->delete();
|
|
244
|
+
|
|
245
|
+
return;
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
if ($companyUuid && !$remainingProfiles->contains($companyUuid)) {
|
|
249
|
+
CompanyUser::where(['company_uuid' => $companyUuid, 'user_uuid' => $user->uuid])->delete();
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
/**
|
|
254
|
+
* Generate a new password for the account and send it to the profile.
|
|
255
|
+
*
|
|
256
|
+
* @return string how the credentials were sent: `email` or `sms`
|
|
257
|
+
*/
|
|
258
|
+
public static function sendCredentials(Driver|Contact $profile, User $user, ?string $password = null): string
|
|
259
|
+
{
|
|
260
|
+
$password ??= Str::random(12);
|
|
261
|
+
$user->changePassword($password);
|
|
262
|
+
|
|
263
|
+
if ($user->status !== 'active') {
|
|
264
|
+
$user->activate();
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
return static::deliverCredentials($profile, $user, $password);
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
/**
|
|
271
|
+
* Send already set credentials to the profile by email, or by SMS when the
|
|
272
|
+
* account has no email.
|
|
273
|
+
*/
|
|
274
|
+
public static function deliverCredentials(Driver|Contact $profile, User $user, string $password): string
|
|
275
|
+
{
|
|
276
|
+
if ($user->email) {
|
|
277
|
+
Mail::to($user)->send($profile instanceof Driver ? new DriverCredentialsMail($password, $profile, $user) : new CustomerCredentialsMail($password, $profile));
|
|
278
|
+
|
|
279
|
+
return 'email';
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
if ($user->phone) {
|
|
283
|
+
$profile->loadMissing('company');
|
|
284
|
+
$companyName = $profile->company?->name ?? config('app.name');
|
|
285
|
+
$identity = $user->phone;
|
|
286
|
+
$result = app(SmsService::class)->send($user->phone, "Your {$companyName} sign-in details. Login: {$identity} Password: {$password}");
|
|
287
|
+
|
|
288
|
+
if (is_array($result) && array_key_exists('success', $result) && !$result['success']) {
|
|
289
|
+
throw new \Exception('The credentials could not be texted: ' . ($result['error'] ?? $result['message'] ?? 'the SMS provider refused it.'));
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
return 'sms';
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
throw new \Exception('This profile has no email or phone to send credentials to.');
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
/**
|
|
299
|
+
* Throw when the account can't hold the kind of profile being created.
|
|
300
|
+
*
|
|
301
|
+
* @throws ProfileIdentityConflictException
|
|
302
|
+
*/
|
|
303
|
+
public static function assertAccountCanHoldProfile(User $account, string $companyUuid, string $type, string $field = 'email'): void
|
|
304
|
+
{
|
|
305
|
+
if (static::isStaffAccount($account)) {
|
|
306
|
+
if (!static::isCompanyMember($account, $companyUuid)) {
|
|
307
|
+
throw new ProfileIdentityConflictException(static::takenMessage($field), $field);
|
|
308
|
+
}
|
|
309
|
+
} elseif ($account->type !== $type) {
|
|
310
|
+
throw new ProfileIdentityConflictException('This ' . static::fieldLabel($field) . ' is already used by a ' . $account->type . '.', $field);
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
if (static::hasProfileInCompany($account, $companyUuid, $type)) {
|
|
314
|
+
throw new ProfileIdentityConflictException('A ' . $type . ' with this ' . static::fieldLabel($field) . ' already exists.', $field);
|
|
315
|
+
}
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
public static function hasProfileInCompany(User $user, string $companyUuid, string $type): bool
|
|
319
|
+
{
|
|
320
|
+
if ($type === 'driver') {
|
|
321
|
+
return Driver::withoutGlobalScopes()->where(['user_uuid' => $user->uuid, 'company_uuid' => $companyUuid])->whereNull('deleted_at')->exists();
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
return Contact::withoutGlobalScopes()->where(['user_uuid' => $user->uuid, 'company_uuid' => $companyUuid])->whereNull('deleted_at')->exists();
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
/**
|
|
328
|
+
* The organizations the account still has a driver or contact profile in.
|
|
329
|
+
*/
|
|
330
|
+
public static function profileCompanies(User $user): \Illuminate\Support\Collection
|
|
331
|
+
{
|
|
332
|
+
$drivers = Driver::withoutGlobalScopes()->where('user_uuid', $user->uuid)->whereNull('deleted_at')->pluck('company_uuid');
|
|
333
|
+
$contacts = Contact::withoutGlobalScopes()->where('user_uuid', $user->uuid)->whereNull('deleted_at')->pluck('company_uuid');
|
|
334
|
+
|
|
335
|
+
return $drivers->merge($contacts)->filter()->unique()->values();
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
public static function isCompanyMember(User $user, ?string $companyUuid): bool
|
|
339
|
+
{
|
|
340
|
+
if (!$companyUuid) {
|
|
341
|
+
return false;
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
return $user->company_uuid === $companyUuid || CompanyUser::where(['company_uuid' => $companyUuid, 'user_uuid' => $user->uuid])->exists();
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
/**
|
|
348
|
+
* Add a managed account to the organization with its profile role, without
|
|
349
|
+
* sending an organization invite.
|
|
350
|
+
*/
|
|
351
|
+
public static function attachToCompany(User $user, string $companyUuid, string $type): ?CompanyUser
|
|
352
|
+
{
|
|
353
|
+
$company = Company::where('uuid', $companyUuid)->first();
|
|
354
|
+
if (!$company) {
|
|
355
|
+
return null;
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
$role = static::ROLES[$type] ?? static::ROLES['contact'];
|
|
359
|
+
$companyUser = CompanyUser::where(['company_uuid' => $companyUuid, 'user_uuid' => $user->uuid])->first();
|
|
360
|
+
if (!$companyUser) {
|
|
361
|
+
$companyUser = $company->addUser($user, $role);
|
|
362
|
+
} elseif (static::isManagedAccount($user)) {
|
|
363
|
+
$companyUser->assignSingleRole($role);
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
if (!$user->company_uuid) {
|
|
367
|
+
$user->forceFill(['company_uuid' => $companyUuid])->saveQuietly();
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
$user->setRelation('companyUser', $companyUser);
|
|
371
|
+
|
|
372
|
+
return $companyUser;
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
public static function normalizeEmail(?string $email): ?string
|
|
376
|
+
{
|
|
377
|
+
$email = is_string($email) ? strtolower(trim($email)) : null;
|
|
378
|
+
|
|
379
|
+
return $email ?: null;
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
public static function normalizePhone(?string $phone): ?string
|
|
383
|
+
{
|
|
384
|
+
$phone = is_string($phone) ? trim($phone) : null;
|
|
385
|
+
|
|
386
|
+
return $phone ? Utils::formatPhoneNumber($phone) : null;
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
private static function matchedField(User $account, ?string $email): string
|
|
390
|
+
{
|
|
391
|
+
$email = static::normalizeEmail($email);
|
|
392
|
+
|
|
393
|
+
return $email && strtolower((string) $account->email) === $email ? 'email' : 'phone';
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
private static function fieldLabel(string $field): string
|
|
397
|
+
{
|
|
398
|
+
return $field === 'phone' ? 'phone number' : 'email';
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
private static function takenMessage(string $field): string
|
|
402
|
+
{
|
|
403
|
+
return 'This ' . static::fieldLabel($field) . ' is already in use by another account.';
|
|
404
|
+
}
|
|
405
|
+
}
|