@fleetbase/fleetops-engine 0.6.49 → 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.
- package/addon/components/customer/create-order-form.hbs +2 -2
- package/addon/components/customer/form.hbs +2 -2
- package/addon/components/customer/order-form.hbs +2 -2
- package/addon/components/customer/orders.hbs +2 -2
- package/addon/components/display-place.hbs +2 -2
- package/addon/components/map/google-live-map.js +6 -2
- package/addon/components/map/leaflet-live-map.hbs +48 -14
- package/addon/components/map/leaflet-live-map.js +184 -20
- package/addon/components/modals/order-change-destination.hbs +46 -0
- package/addon/components/modals/order-change-destination.js +106 -0
- package/addon/components/modals/stop-activity.hbs +28 -0
- package/addon/components/modals/stop-activity.js +52 -0
- package/addon/components/modals/update-order-activity.hbs +45 -3
- package/addon/components/modals/update-order-activity.js +143 -5
- package/addon/components/orchestrator/resource-panel.hbs +0 -10
- package/addon/components/orchestrator/resource-panel.js +2 -4
- package/addon/components/order/activity-list.hbs +2 -2
- package/addon/components/order/activity-timeline.hbs +31 -29
- package/addon/components/order/details/activity.hbs +3 -3
- package/addon/components/order/details/activity.js +11 -0
- package/addon/components/order/details/documents.js +2 -0
- package/addon/components/order/details/purchase-rate.hbs +2 -2
- package/addon/components/order/details/route.hbs +11 -3
- package/addon/components/order/details/route.js +91 -14
- package/addon/components/order/details/tracking.hbs +101 -72
- package/addon/components/order/details/tracking.js +149 -2
- package/addon/components/order/form/details.hbs +3 -3
- package/addon/components/order/form/documents.js +11 -4
- package/addon/components/order/form/route.hbs +108 -104
- package/addon/components/order/form/route.js +37 -1
- package/addon/components/order/form/service-rate.hbs +2 -2
- package/addon/components/order-config-manager/activity-flow.hbs +4 -1
- package/addon/components/order-config-manager/activity-flow.js +178 -12
- package/addon/components/place/address.js +1 -1
- package/addon/components/route-list.hbs +17 -17
- package/addon/components/route-list.js +42 -10
- package/addon/components/service-rate/details.hbs +41 -0
- package/addon/components/service-rate/form.hbs +118 -0
- package/addon/components/service-rate/form.js +55 -0
- package/addon/components/vehicle/form.js +1 -1
- package/addon/components/vehicle/pill.hbs +1 -1
- package/addon/controllers/management/vehicles/index/new.js +1 -1
- package/addon/controllers/operations/orders/index/details.js +4 -4
- package/addon/controllers/operations/orders/index.js +14 -0
- package/addon/helpers/place-address-html.js +2 -2
- package/addon/routes/operations/service-rates/index.js +1 -1
- package/addon/services/geofence.js +163 -19
- package/addon/services/leaflet-contextmenu-manager.js +27 -0
- package/addon/services/vehicle-actions.js +1 -1
- package/addon/styles/fleetops-engine.css +91 -1
- package/addon/utils/fleet-ops-options.js +2 -1
- package/addon/utils/normalize-order-config-flow.js +41 -0
- package/addon/utils/place-address-html.js +3 -2
- package/addon/utils/route-visualization.js +54 -7
- package/app/components/modals/order-change-destination.js +1 -0
- package/app/components/modals/stop-activity.js +1 -0
- package/composer.json +1 -1
- package/extension.json +1 -1
- package/package.json +3 -3
- package/server/migrations/2026_05_21_000001_add_geographic_rules_to_service_rate_fees_table.php +37 -0
- package/server/migrations/2026_05_25_000001_add_endpoint_tracking_refs_to_payloads_table.php +50 -0
- package/server/migrations/2026_05_27_000001_default_driver_vehicle_statuses_to_available.php +48 -0
- package/server/resources/views/labels/default.php +57 -49
- package/server/resources/views/labels/waypoint-label.php +27 -20
- package/server/seeders/Testing/ConnectivitySeeder.php +1 -0
- package/server/seeders/Testing/FleetSeeder.php +22 -22
- package/server/seeders/Testing/OrdersSeeder.php +6 -22
- package/server/seeders/Testing/VroomOrchestrationSeeder.php +21 -21
- package/server/src/Console/Commands/AuditCustomerUserConflicts.php +75 -0
- package/server/src/Exceptions/CustomerUserConflictException.php +13 -0
- package/server/src/Http/Controllers/Api/v1/ContactController.php +17 -2
- package/server/src/Http/Controllers/Api/v1/CustomerController.php +876 -0
- package/server/src/Http/Controllers/Api/v1/DriverController.php +1 -1
- package/server/src/Http/Controllers/Api/v1/OrderConfigController.php +54 -0
- package/server/src/Http/Controllers/Api/v1/OrderController.php +98 -70
- package/server/src/Http/Controllers/Api/v1/ServiceAreaController.php +2 -2
- package/server/src/Http/Controllers/Api/v1/VehicleController.php +20 -3
- package/server/src/Http/Controllers/Internal/v1/ContactController.php +19 -0
- package/server/src/Http/Controllers/Internal/v1/LiveController.php +101 -66
- package/server/src/Http/Controllers/Internal/v1/OrderController.php +429 -26
- package/server/src/Http/Controllers/Internal/v1/ServiceQuoteController.php +1 -1
- package/server/src/Http/Controllers/Internal/v1/SettingController.php +2 -2
- package/server/src/Http/Controllers/Internal/v1/VehicleController.php +54 -1
- package/server/src/Http/Middleware/AuthenticateCustomerToken.php +34 -0
- package/server/src/Http/Requests/CreateCustomerOrderRequest.php +63 -0
- package/server/src/Http/Requests/CreateCustomerRequest.php +48 -0
- package/server/src/Http/Requests/CreateDriverRequest.php +1 -1
- package/server/src/Http/Requests/CreateVehicleRequest.php +25 -1
- package/server/src/Http/Requests/Internal/CreateDriverRequest.php +1 -1
- package/server/src/Http/Requests/VerifyCreateCustomerRequest.php +29 -0
- package/server/src/Http/Resources/Internal/v1/OrderConfig.php +41 -0
- package/server/src/Http/Resources/v1/Customer.php +79 -0
- package/server/src/Http/Resources/v1/OrderConfig.php +74 -0
- package/server/src/Http/Resources/v1/Payload.php +23 -20
- package/server/src/Http/Resources/v1/ServiceArea.php +33 -19
- package/server/src/Http/Resources/v1/ServiceRateFee.php +7 -0
- package/server/src/Listeners/HandleOrderDispatched.php +2 -2
- package/server/src/Models/Contact.php +92 -15
- package/server/src/Models/Customer.php +64 -0
- package/server/src/Models/Driver.php +12 -3
- package/server/src/Models/Entity.php +2 -2
- package/server/src/Models/Order.php +29 -4
- package/server/src/Models/Payload.php +17 -1
- package/server/src/Models/ServiceRate.php +273 -1
- package/server/src/Models/ServiceRateFee.php +39 -9
- package/server/src/Models/TrackingNumber.php +8 -2
- package/server/src/Models/Vehicle.php +18 -2
- package/server/src/Models/Waypoint.php +2 -2
- package/server/src/Notifications/LateDeparture.php +2 -2
- package/server/src/Notifications/ProlongedStoppage.php +2 -2
- package/server/src/Notifications/RouteDeviation.php +2 -2
- package/server/src/Observers/ContactObserver.php +2 -0
- package/server/src/Observers/ServiceRateObserver.php +6 -6
- package/server/src/Orchestration/Engines/VroomOrchestrationEngine.php +1 -1
- package/server/src/Providers/FleetOpsServiceProvider.php +1 -0
- package/server/src/Support/Analytics/AbstractAnalytics.php +6 -6
- package/server/src/Support/Analytics/FuelEfficiency.php +2 -2
- package/server/src/Support/Analytics/OrdersByStatus.php +1 -1
- package/server/src/Support/Analytics/RevenueTrend.php +0 -1
- package/server/src/Support/Analytics/TopDrivers.php +1 -1
- package/server/src/Support/CustomerAuth.php +98 -0
- package/server/src/Support/LabelPdf.php +52 -0
- package/server/src/Support/Metrics/AbstractMetric.php +4 -4
- package/server/src/Support/ResolvesOrderServiceStops.php +512 -0
- package/server/src/Tracking/TrackingContextBuilder.php +41 -38
- package/server/src/Tracking/TrackingIntelligenceService.php +109 -9
- package/server/src/Tracking/TrackingStop.php +13 -11
- package/server/src/routes.php +43 -9
- package/server/tests/CapacityAllocationEngineTest.php +10 -10
- package/server/tests/CustomerContactIdentitySafetyTest.php +54 -0
- package/server/tests/CustomerEndpointTest.php +199 -0
- package/server/tests/DriverVehicleStatusDefaultTest.php +39 -0
- package/server/tests/LabelPdfRenderingTest.php +23 -0
- package/server/tests/LiveControllerViewportTest.php +60 -0
- package/server/tests/MetricsRegistryTest.php +4 -8
- package/server/tests/OrchestrationPayloadBuilderTest.php +2 -2
- package/server/tests/OrderConfigEndpointTest.php +86 -0
- package/server/tests/OrderFileAttachmentTest.php +20 -0
- package/server/tests/ServiceAreaZoneApiShapeTest.php +34 -0
- package/server/tests/VehicleDriverAssignmentTest.php +18 -0
- package/translations/en-us.yaml +1 -0
- 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
|
+
}
|