fromRoute($name); if (!is_string($raw) || !preg_match('/^[1-9][0-9]*$/', $raw)) { $response->error('Invalid route parameter', 400); } return (int)$raw; } private function resolveSuperuserVehicleTargetUser(int $userId): array { global $response; $targetUser = (new users_o())->select($userId); if (!$targetUser->exists()) { $response->error('User not found', 404); } $targetUser->getObjectProperties(); $customerNumber = (int)$targetUser->customer_number->value(); if ($customerNumber <= 0) { $response->error('Selected user does not have a customer number', 400); } return [ 'user_id' => (int)$targetUser->id, 'customer_number' => $customerNumber, 'customer_name' => (string)$targetUser->getCustomerName($customerNumber), ]; } private function addUserScopedVehicleMeta(array $targetUser): void { global $response; $response->add_meta('user_context', $targetUser); $response->add_meta('vehicles_summary', $this->buildVehicleSummaryForCustomer((int)$targetUser['customer_number'])); } private function buildVehiclePayload(array $vehicle): array { return [...(new customer_vehicles_o())->select((int)$vehicle['id'])->asArray()]; } private function listVehiclesForCustomer(int $customerNumber): array { $vehicles = new customer_vehicles_o(); return $vehicles->listObjectsWithPaginationIfSet( fn ($vehicle) => $this->buildVehiclePayload($vehicle), $vehicles->forceRestrictFilters([ 'customer_id' => [$customerNumber], ]) ); } private function buildVehicleSummaryForCustomer(int $customerNumber): array { $vehicles = new customer_vehicles_o(); $filters = [ 'customer_id' => $customerNumber, ]; if ($vehicles->columnsExist(['deleted_at'])) { $filters['deleted_at'] = null; } $rows = $vehicles->getFieldsWhere($filters, [ 'id', 'wash_subscription', ]); $summary = [ 'total' => 0, 'wash_subscription' => 0, 'self_service' => 0, ]; foreach ($rows as $row) { $summary['total']++; if ((int)($row['wash_subscription'] ?? 0) === 1) { $summary['wash_subscription']++; } try { $vehicle = (new customer_vehicles_o())->select((int)$row['id']); if ($vehicle->exists() && $vehicle->hasXLVask()) { $summary['self_service']++; } } catch (\Throwable) { // XLVask availability should not prevent the customer vehicle summary from loading. } } return $summary; } private function requireScopedVehicle(int $vehicleId, int $customerNumber): customer_vehicles_o { global $response; $vehicle = (new customer_vehicles_o())->select($vehicleId); if (!$vehicle->exists()) { $response->error('Vehicle not found', 404); } $vehicle->getObjectProperties(); if ((int)$vehicle->customer_id->value() !== $customerNumber) { $response->error('Vehicle does not belong to selected user', 404); } return $vehicle; } private function validateOptionalCustomerIdMatches(int $customerNumber): void { global $response; if (!self::isParametersSet(['customer_id'])) { return; } $requestedCustomerNumber = (int)self::getParameter('customer_id'); self::requireType($requestedCustomerNumber, self::type_int()); if ($requestedCustomerNumber !== $customerNumber) { $response->error('Customer number does not match selected user', 400); } } private function createVehicleForCustomer(int $customerNumber): array { global $response; self::requireParameters([ 'type', 'reg', ]); $this->validateOptionalCustomerIdMatches($customerNumber); $reference = null; if (self::isParametersSet(['reference']) && !empty(self::getParameter('reference'))) { $reference = (string)self::getParameter('reference'); self::requireType($reference, self::type_string()); self::requireMinLength('reference', 1); self::requireMaxLength('reference', 255); } self::requireType(self::getParameter('reg'), self::type_string()); self::requireType(self::getParameter('type'), self::type_int()); $subscription = false; if (self::isParametersSet(['wash_subscription'])) { self::requireType(self::getParameter('wash_subscription'), self::type_bool()); $subscription = (bool)self::getParameter('wash_subscription'); } $reg = trim((string)self::getParameter('reg')); self::requireMinLength('reg', 2); self::requireMaxLength('reg', 12); $type = (int)self::getParameter('type'); $vehicle = new customer_vehicles_o(); $vehicle->add($customerNumber, $type, $reg, $subscription, $reference); try { (new economic_v2_versioning_service())->recordVehicleSubscriptionVersion( [ 'vehicle_id' => (int)$vehicle->id, 'customer_number' => $customerNumber, 'reg' => $reg, 'vehicle_type' => $type, 'wash_subscription' => $subscription, ], date('Y-m-d H:i:s'), 'live.vehicle.route', 1.0, false, [ 'route' => '/superuser/users/{user_id}/vehicles', 'method' => 'POST', 'actor_user_id' => (int)((new authentication())->get_user()->id ?? 0), ] ); } catch (\Throwable $exception) { (new logs_o())->add('vehicles', 'global', 0, (int)((new authentication())->get_user()->id ?? 0), 'ADD_VEHICLE_VERSIONING_FAILED', $exception->getMessage()); } $this->observeVehicleCreated($customerNumber, (int)$vehicle->id, '/superuser/users/{user_id}/vehicles'); return $vehicle->asArray(); } private function observeVehicleCreated(int $customerNumber, int $vehicleId, string $route): void { try { (new security_policy_service())->observeVehicleCreated($customerNumber, [ 'vehicle_id' => $vehicleId, 'route' => $route, ]); } catch (\Throwable) { // Security observation must not change vehicle creation responses. } } private function updateScopedVehicle(customer_vehicles_o $vehicle, int $customerNumber): array { global $response; $this->validateOptionalCustomerIdMatches($customerNumber); $beforeState = [ 'vehicle_id' => (int)$vehicle->id, 'customer_number' => (int)$vehicle->customer_id->value(), 'reg' => (string)$vehicle->reg->value(), 'vehicle_type' => (int)$vehicle->type->value(), 'wash_subscription' => (bool)$vehicle->wash_subscription->value(), ]; if (self::isParametersSet(['type'])) { $type = (int)self::getParameter('type'); self::requireType($type, self::type_int()); self::requireMinValue($type, 0); if ($type === 0) { $vehicle->type->set(0); $vehicle->wash_subscription->set(0); } else { $product = new products_o(); $product->select($type); if (!$product->exists() || !$product->subscription_allowed->value()) { $response->error('Invalid type', 400); } $vehicle->type->set($type); } } if (self::isParametersSet(['reg'])) { $reg = (string)self::getParameter('reg'); self::requireType($reg, self::type_string()); self::requireMinLength('reg', 2); self::requireMaxLength('reg', 12); $vehicle->reg->set(preg_replace('/\s+/', '', $reg)); } if (self::isParametersSet(['wash_subscription'])) { $subscription = (bool)self::getParameter('wash_subscription'); self::requireType($subscription, self::type_bool()); if ((int)$vehicle->type->value() === 0 && $subscription) { $response->error('Unable to set subscription, type is not set', 400); } $vehicle->wash_subscription->set($subscription ? 1 : 0); } if (self::isParametersSet(['reference'])) { if (empty(self::getParameter('reference'))) { $vehicle->reference->nullify(); } else { $reference = (string)self::getParameter('reference'); self::requireType($reference, self::type_string()); self::requireMinLength('reference', 1); self::requireMaxLength('reference', 255); $vehicle->reference->set($reference); } } $vehicle->objectChanged(); $afterState = [ 'vehicle_id' => (int)$vehicle->id, 'customer_number' => (int)$vehicle->customer_id->value(), 'reg' => (string)$vehicle->reg->value(), 'vehicle_type' => (int)$vehicle->type->value(), 'wash_subscription' => (bool)$vehicle->wash_subscription->value(), ]; $versionRelevantChange = ( (string)$beforeState['reg'] !== (string)$afterState['reg'] || (int)$beforeState['vehicle_type'] !== (int)$afterState['vehicle_type'] || (bool)$beforeState['wash_subscription'] !== (bool)$afterState['wash_subscription'] ); if ($versionRelevantChange) { try { $versioning = new economic_v2_versioning_service(); $effectiveAt = date('Y-m-d H:i:s'); if ((string)$beforeState['reg'] !== (string)$afterState['reg']) { $versioning->closeActiveVehicleSubscriptionVersion( (int)$beforeState['customer_number'], (string)$beforeState['reg'], $effectiveAt, 'live.vehicle.route', 1.0, false, [ 'route' => '/superuser/users/{user_id}/vehicles', 'method' => 'PUT', 'actor_user_id' => (int)((new authentication())->get_user()->id ?? 0), 'reason' => 'identity_change', ] ); } $versioning->recordVehicleSubscriptionVersion( $afterState, $effectiveAt, 'live.vehicle.route', 1.0, false, [ 'route' => '/superuser/users/{user_id}/vehicles', 'method' => 'PUT', 'actor_user_id' => (int)((new authentication())->get_user()->id ?? 0), ] ); } catch (\Throwable $exception) { (new logs_o())->add('vehicles', 'global', 0, (int)((new authentication())->get_user()->id ?? 0), 'EDIT_VEHICLE_VERSIONING_FAILED', $exception->getMessage()); } } return $vehicle->asArray(); } public function run(): void { $this->get('/vehicles', function () { ScopeMiddleware::requireScope(Scope::CUSTOMER_READ, '/vehicles'); global $response; $auth = new authentication(); $user = $auth->get_user(); // Define permissions with subuser node linkage $permission_own = self::definePermission('list_own_vehicles', subusers_permission_node_key::VEHICLES_LIST); $permission_other = self::definePermission('list_vehicles_other'); $has_permission_other = $this->hasPermission($permission_other); // If a specific ID is requested, validate access against that vehicle's customer context if ($this->isParametersSet(['id'])) { $id = (int)$this->getParameter('id'); $this->requireType($id, self::type_int()); $this->requireMinValue($id, 1); $this->requireMaxValue($id, 9999999999); $vehicle = (new customer_vehicles_o())->select($id); if (!$vehicle->exists()) { (new logs_o())->add('vehicles', 'global', 1, (int)($user->id ?? 0), 'LIST_OWN_VEHICLES', 'Vehicle not found'); $response->error('Vehicle not found', 404); } $targetCustomer = (int)$vehicle->customer_id->value(); self::allowOwnOrDepartmentAccess( $permission_own, $permission_other, $targetCustomer, null, null, 'You do not have permission to view this vehicle.' ); $response->success([...$vehicle->asArray()]); } // Listing: restrict to effective customer when lacking the broader permission $vehicles_o = new customer_vehicles_o(); $effectiveCustomer = self::resolveEffectiveCustomerNumber(); $permitted = self::allowOwnOrDepartmentAccess( $permission_own, $permission_other, $effectiveCustomer, null, null, 'You do not have permission to view vehicles.' ); if (!$permitted) { $response->error('You do not have permission to view vehicles.'); } $response->success( $vehicles_o->listObjectsWithPaginationIfSet( function ($vehicle) { return [...(new customer_vehicles_o())->select($vehicle['id'])->asArray()]; }, $vehicles_o->forceRestrictFilters([ ...(!$has_permission_other && $effectiveCustomer !== null ? [ 'customer_id' => [(int)$effectiveCustomer] ] : []) ]) ) ); }, [ 'list_own_vehicles' => 'List own vehicles', 'list_vehicles_other' => 'List other users vehicles', ] ); $this->get('/department/vehicles/unknown-customer', function () { ScopeMiddleware::requireScope(Scope::CUSTOMER_READ, '/department/vehicles/unknown-customer'); // Require the user to be logged in global $response; $this->requirePermission('list_unknown_customer_vehicles'); // Get the user object $user = (new authentication())->get_user(); // Check if the request was successful if ($user) { // Log the incident (new logs_o())->add('vehicles', 'global', 1, $user->id, 'LIST_UNKNOWN_CUSTOMER_VEHICLES', 'Successfully listed unknown customer vehicles'); // Return the list of the user's vehicles $vehicles_o = new orders_o(); $vehicles_o->setView('unique_order_reg_1'); $response->success($vehicles_o ->setSearchableFields([ 'reg_1' ]) ->listObjectsWithPaginationIfSet() ); } else { // Log the incident (new logs_o())->add('vehicles', 'global', 1, 0, 'LIST_UNKNOWN_CUSTOMER_VEHICLES', 'No user found, or invalid session'); // Return an error $response->error('Invalid session', 400); } }, [ 'list_unknown_customer_vehicles' => 'List unknown customer vehicles', ] ); $this->get('/superuser/users/{user_id}/vehicles', function () { ScopeMiddleware::requireScope(Scope::CUSTOMER_READ, '/superuser/users/{user_id}/vehicles'); global $response; $this->requirePermission('list_vehicles_other'); $targetUser = $this->resolveSuperuserVehicleTargetUser($this->routePositiveInt('user_id')); $this->addUserScopedVehicleMeta($targetUser); $response->success($this->listVehiclesForCustomer((int)$targetUser['customer_number'])); }, [ 'list_vehicles_other' => 'List vehicles for a selected superuser customer account.', ]); $this->get('/superuser/users/{user_id}/vehicles/summary', function () { ScopeMiddleware::requireScope(Scope::CUSTOMER_READ, '/superuser/users/{user_id}/vehicles/summary'); global $response; $this->requirePermission('list_vehicles_other'); $targetUser = $this->resolveSuperuserVehicleTargetUser($this->routePositiveInt('user_id')); $response->add_meta('user_context', $targetUser); $response->success($this->buildVehicleSummaryForCustomer((int)$targetUser['customer_number'])); }, [ 'list_vehicles_other' => 'Summarize vehicles for a selected superuser customer account.', ]); $this->post('/superuser/users/{user_id}/vehicles', function () { ScopeMiddleware::requireScope(Scope::CUSTOMER_WRITE, '/superuser/users/{user_id}/vehicles'); global $response; $this->requirePermission('add_vehicle_other'); $targetUser = $this->resolveSuperuserVehicleTargetUser($this->routePositiveInt('user_id')); $response->add_meta('user_context', $targetUser); $response->success($this->createVehicleForCustomer((int)$targetUser['customer_number'])); }, [ 'add_vehicle_other' => 'Add a vehicle for a selected superuser customer account.', ]); $this->put('/superuser/users/{user_id}/vehicles', function () { ScopeMiddleware::requireScope(Scope::CUSTOMER_WRITE, '/superuser/users/{user_id}/vehicles'); global $response; $this->requirePermission('edit_vehicle_other'); self::requireParameters(['id']); $targetUser = $this->resolveSuperuserVehicleTargetUser($this->routePositiveInt('user_id')); $vehicleId = (int)self::getParameter('id'); self::requireType($vehicleId, self::type_int()); self::requireMinValue($vehicleId, 1); $vehicle = $this->requireScopedVehicle($vehicleId, (int)$targetUser['customer_number']); $response->add_meta('user_context', $targetUser); $response->success($this->updateScopedVehicle($vehicle, (int)$targetUser['customer_number'])); }, [ 'edit_vehicle_other' => 'Edit a vehicle for a selected superuser customer account.', ]); $this->delete('/superuser/users/{user_id}/vehicles', function () { ScopeMiddleware::requireScope(Scope::CUSTOMER_WRITE, '/superuser/users/{user_id}/vehicles'); global $response; $this->requirePermission('delete_vehicle_other'); self::requireParameters(['id']); $targetUser = $this->resolveSuperuserVehicleTargetUser($this->routePositiveInt('user_id')); $vehicleId = (int)self::getParameter('id'); self::requireType($vehicleId, self::type_int()); self::requireMinValue($vehicleId, 1); $vehicle = $this->requireScopedVehicle($vehicleId, (int)$targetUser['customer_number']); $beforeState = [ 'customer_number' => (int)$vehicle->customer_id->value(), 'reg' => (string)$vehicle->reg->value(), ]; $vehicle->delete(); try { (new economic_v2_versioning_service())->closeActiveVehicleSubscriptionVersion( (int)$beforeState['customer_number'], (string)$beforeState['reg'], date('Y-m-d H:i:s'), 'live.vehicle.route', 1.0, false, [ 'route' => '/superuser/users/{user_id}/vehicles', 'method' => 'DELETE', 'actor_user_id' => (int)((new authentication())->get_user()->id ?? 0), ] ); } catch (\Throwable $exception) { (new logs_o())->add('vehicles', 'global', 0, (int)((new authentication())->get_user()->id ?? 0), 'DELETE_VEHICLE_VERSIONING_FAILED', $exception->getMessage()); } $response->add_meta('user_context', $targetUser); $response->success([ 'success' => true, 'message' => 'Vehicle deleted successfully', ]); }, [ 'delete_vehicle_other' => 'Delete a vehicle for a selected superuser customer account.', ]); $this->post('/vehicles', function () { ScopeMiddleware::requireScope(Scope::CUSTOMER_WRITE, '/vehicles'); global $response; $auth = new authentication(); $user = $auth->get_user(); // Define permissions with subuser node linkage $permission_own = self::definePermission('add_vehicle', subusers_permission_node_key::VEHICLES_ADD); $permission_other = self::definePermission('add_vehicle_other'); // Require the parameters self::requireParameters([ 'type', 'reg', ]); // Determine target customer $targetCustomer = self::isParametersSet(['customer_id']) ? (int)self::getParameter('customer_id') : (int)(self::resolveEffectiveCustomerNumber() ?? 0); self::requireType($targetCustomer, self::type_int()); self::requireMinValue($targetCustomer, 1); self::requireMaxValue($targetCustomer, 9999999999); // Enforce access (own vs broader) self::allowOwnOrDepartmentAccess( $permission_own, $permission_other, $targetCustomer, null, null, 'You are not allowed to add vehicles to this customer' ); $reference = null; if (self::isParametersSet(['reference'])) { if (!empty(self::getParameter('reference'))) { $reference = (string)self::getParameter('reference'); self::requireType($reference, self::type_string()); self::requireMinLength('reference', 1); self::requireMaxLength('reference', 255); } } // Validate the parameters self::requireType(self::getParameter('reg'), self::type_string()); self::requireType(self::getParameter('type'), self::type_int()); $subscription = false; if (self::isParametersSet(['wash_subscription'])) { self::requireType(self::getParameter('wash_subscription'), self::type_bool()); $subscription = (bool)self::getParameter('wash_subscription'); } // Get the parameters $reg = (string)self::getParameter('reg'); $type = (int)self::getParameter('type'); $reg = trim($reg); // Create a new vehicle $vehicle = new customer_vehicles_o(); $vehicle->add( $targetCustomer, $type, $reg, $subscription ? 1 : 0, $reference ); try { (new economic_v2_versioning_service())->recordVehicleSubscriptionVersion( [ 'vehicle_id' => (int)$vehicle->id, 'customer_number' => (int)$targetCustomer, 'reg' => (string)$reg, 'vehicle_type' => (int)$type, 'wash_subscription' => (bool)$subscription, ], date('Y-m-d H:i:s'), 'live.vehicle.route', 1.0, false, [ 'route' => '/vehicles', 'method' => 'POST', 'actor_user_id' => (int)($user->id ?? 0), ] ); } catch (\Throwable $e) { (new logs_o())->add('vehicles', 'global', 0, (int)($user->id ?? 0), 'ADD_VEHICLE_VERSIONING_FAILED', $e->getMessage()); } (new logs_o())->add('vehicles', 'global', 1, (int)($user->id ?? 0), 'ADD_VEHICLE', 'Successfully added vehicle'); $this->observeVehicleCreated((int)$targetCustomer, (int)$vehicle->id, '/vehicles'); $response->success($vehicle->asArray()); }, [ 'add_vehicle' => 'Add a vehicle to own vehicles', 'add_vehicle_other' => 'Add a vehicle to another users vehicles', ] ); $this->put('/vehicles', function () { ScopeMiddleware::requireScope(Scope::CUSTOMER_WRITE, '/vehicles'); global $response; $auth = new authentication(); $user = $auth->get_user(); $permission_own = self::definePermission('edit_vehicle', subusers_permission_node_key::VEHICLES_EDIT); $permission_other = self::definePermission('edit_vehicle_other'); // Get the request data self::requireParameters(['id']); $id = (int)self::getParameter('id'); $vehicle = (new customer_vehicles_o())->select($id); if (!$vehicle->exists()) { (new logs_o())->add('vehicles', 'global', 1, (int)($user->id ?? 0), 'EDIT_VEHICLE', 'Vehicle not found'); $response->error('Vehicle not found', 404); } $before_state = [ 'vehicle_id' => (int)$vehicle->id, 'customer_number' => (int)$vehicle->customer_id->value(), 'reg' => (string)$vehicle->reg->value(), 'vehicle_type' => (int)$vehicle->type->value(), 'wash_subscription' => (bool)$vehicle->wash_subscription->value(), ]; // Enforce access (own vs broader) self::allowOwnOrDepartmentAccess( $permission_own, $permission_other, (int)$vehicle->customer_id->value(), null, null, 'You are not allowed to edit vehicles from other users' ); if (self::isParametersSet(['customer_id'])) { $new_customer_number = (int)self::getParameter('customer_id'); self::requireType($new_customer_number, self::type_int()); self::requireMinValue($new_customer_number, 1); self::requireMaxValue($new_customer_number, 9999999999); // Require access for the destination customer context as well. self::allowOwnOrDepartmentAccess( $permission_own, $permission_other, $new_customer_number, null, null, 'You are not allowed to move vehicles to this customer' ); $vehicle->customer_id->set($new_customer_number); } // Check all the fields, and if they are set, validate and set them if (self::isParametersSet(['type'])) { $type = (int)self::getParameter('type'); // Make sure the type is an integer self::requireType($type, self::type_int()); self::requireMinValue($type, 0); // Make sure the type is a valid type (If it isn't 0) if ($type === 0) { // Set the type to null $vehicle->type->set(0); // Turn off the subscription $vehicle->wash_subscription->set(0); } else { $products_o = new products_o(); $products_o->select((int)$type); if (!$products_o->exists() || !$products_o->subscription_allowed->value()) { // Log the incident (new logs_o())->add('vehicles', 'global', 1, $user->id, 'EDIT_VEHICLE', 'Invalid type'); // Return an error $response->error('Invalid type', 400); } // Set the type $vehicle->type->set( (int)$type ); // TODO: Make the potential vehicleTypeId reflect the correct type. // It's currently possible to set the vehicleTypeId to a type that is not allowed for the vehicle. } } if (self::isParametersSet(['reg'])) { $reg = (string)self::getParameter('reg'); self::requireType($reg, self::type_string()); self::requireMinLength('reg', 2); self::requireMaxLength('reg', 12); // Strip the registration number of whitespace $reg = preg_replace('/\s+/', '', $reg); // Set the registration number $vehicle->reg->set($reg); } if (self::isParametersSet(['wash_subscription'])) { $subscription = (bool)self::getParameter('wash_subscription'); self::requireType($subscription, self::type_bool()); // Check if the type is a valid type (If it isn't 0) if ((int)$vehicle->type->value() === 0 && $subscription) { // Set the subscription to null $response->error('Unable to set subscription, type is not set', 400); } // Set the wash subscription $vehicle->wash_subscription->set($subscription ? 1 : 0); } if (self::isParametersSet(['reference'])) { // Check if the reference is set to null/empty if (empty(self::getParameter('reference'))) { // Set the reference to null $vehicle->reference->nullify(); } else { // Check if the reference is valid $reference = (string)self::getParameter('reference'); self::requireType($reference, self::type_string()); self::requireMinLength('reference', 1); self::requireMaxLength('reference', 255); // Set the reference $vehicle->reference->set($reference); } } $vehicle->objectChanged(); $after_state = [ 'vehicle_id' => (int)$vehicle->id, 'customer_number' => (int)$vehicle->customer_id->value(), 'reg' => (string)$vehicle->reg->value(), 'vehicle_type' => (int)$vehicle->type->value(), 'wash_subscription' => (bool)$vehicle->wash_subscription->value(), ]; $version_relevant_change = ( (int)$before_state['customer_number'] !== (int)$after_state['customer_number'] || (string)$before_state['reg'] !== (string)$after_state['reg'] || (int)$before_state['vehicle_type'] !== (int)$after_state['vehicle_type'] || (bool)$before_state['wash_subscription'] !== (bool)$after_state['wash_subscription'] ); if ($version_relevant_change) { try { $versioning = new economic_v2_versioning_service(); $effective_at = date('Y-m-d H:i:s'); $identity_changed = ( (int)$before_state['customer_number'] !== (int)$after_state['customer_number'] || (string)$before_state['reg'] !== (string)$after_state['reg'] ); if ($identity_changed) { $versioning->closeActiveVehicleSubscriptionVersion( (int)$before_state['customer_number'], (string)$before_state['reg'], $effective_at, 'live.vehicle.route', 1.0, false, [ 'route' => '/vehicles', 'method' => 'PUT', 'actor_user_id' => (int)($user->id ?? 0), 'reason' => 'identity_change', ] ); } $versioning->recordVehicleSubscriptionVersion( $after_state, $effective_at, 'live.vehicle.route', 1.0, false, [ 'route' => '/vehicles', 'method' => 'PUT', 'actor_user_id' => (int)($user->id ?? 0), ] ); } catch (\Throwable $e) { (new logs_o())->add('vehicles', 'global', 0, (int)($user->id ?? 0), 'EDIT_VEHICLE_VERSIONING_FAILED', $e->getMessage()); } } // Return the vehicle $response->success($vehicle->asArray()); }, [ 'edit_vehicle' => 'Edit a vehicle', 'edit_vehicle_other' => 'Edit a vehicle from another user' ] ); $this->delete('/vehicles', function () { ScopeMiddleware::requireScope(Scope::CUSTOMER_WRITE, '/vehicles'); global $response; $auth = new authentication(); $user = $auth->get_user(); $permission_own = self::definePermission('delete_vehicle', subusers_permission_node_key::VEHICLES_DELETE); $permission_other = self::definePermission('delete_vehicle_other'); // Get the request data self::requireParameters(['id']); $id = (int)self::getParameter('id'); $vehicle = (new customer_vehicles_o())->select($id); if (!$vehicle->exists()) { (new logs_o())->add('vehicles', 'global', 1, (int)($user->id ?? 0), 'DELETE_VEHICLE', 'Vehicle not found'); $response->error('Vehicle not found', 404); } $before_state = [ 'customer_number' => (int)$vehicle->customer_id->value(), 'reg' => (string)$vehicle->reg->value(), ]; // Enforce access (own vs broader) self::allowOwnOrDepartmentAccess( $permission_own, $permission_other, (int)$vehicle->customer_id->value(), null, null, 'You are not allowed to delete vehicles from other users' ); // Delete the vehicle $vehicle->delete(); try { (new economic_v2_versioning_service())->closeActiveVehicleSubscriptionVersion( (int)$before_state['customer_number'], (string)$before_state['reg'], date('Y-m-d H:i:s'), 'live.vehicle.route', 1.0, false, [ 'route' => '/vehicles', 'method' => 'DELETE', 'actor_user_id' => (int)($user->id ?? 0), ] ); } catch (\Throwable $e) { (new logs_o())->add('vehicles', 'global', 0, (int)($user->id ?? 0), 'DELETE_VEHICLE_VERSIONING_FAILED', $e->getMessage()); } $response->success([ 'success' => true, 'message' => 'Vehicle deleted successfully' ]); }, [ 'delete_vehicle' => 'Delete a vehicle', 'delete_vehicle_other' => 'Remove (delete) a vehicle from another user' ] ); $this->get('/department/vehicle/customer-suggestions', function () { ScopeMiddleware::requireScope(Scope::CUSTOMER_READ, '/department/vehicle/customer-suggestions'); // Require the user to be logged in global $response; $this->requirePermission('list_vehicle_customer_suggestions'); // Get the user object $user = (new authentication())->get_user(); // Check if the request was successful if ($user) { self::requireParameters(['reg_1']); self::requireType((string)self::getParameter('reg_1'), self::type_string()); $plate = (string)self::getParameter('reg_1'); self::requireMinLength('reg_1', 1); self::requireMaxLength('reg_1', 12); // Return the list of the user's vehicles $orders_o = new orders_o(); // Check if the user is allowed to list other user's vehicles // Get all the customer_ids that has orders with the same plate $customer_ids = $orders_o->getFieldsWhere([ 'reg_1' => $plate, 'deleted_at' => null, ], [ 'customer_id', ]); // Get all the customer_ids that has orders with the same plate $customer_ids = array_map(function ($customer_id) { return (int)$customer_id['customer_id']; }, $customer_ids); // Remove duplicates $customer_ids = array_unique($customer_ids); // Get all the customers that has orders with the same plate $customers = []; foreach ( $customer_ids as $customer_id ) { $customers[] = (new users_o())->getUserByCustomerNumber($customer_id); } // Log the incident (new logs_o())->add('vehicles', 'global', 1, $user->id, 'LIST_VEHICLE_CUSTOMER_SUGGESTIONS', 'Successfully listed customer suggestions for a vehicle'); // Format the response $response->success( array_map(function ($customer) { /** @var users_o $customer */ $customer_number = (int)$customer->customer_number->value(); return [ 'id' => (int)$customer->id, 'customer_name' => (string)$customer->getCustomerName((int)$customer_number), 'customer_number' => (int)$customer_number, 'barred' => (new economic_customer_mo())->getCustomerByCustomerNumber((int)$customer_number)->asArray()['barred'], ]; }, $customers) ); } else { // Log the incident (new logs_o())->add('vehicles', 'global', 1, 0, 'LIST_VEHICLE_CUSTOMER_SUGGESTIONS', 'No user found, or invalid session'); // Return an error $response->error('Invalid session', 400); } }, [ 'list_vehicle_customer_suggestions' => 'List customer suggestions for a vehicle', ] ); $this->post('/vehicles/set-auto-start-on-lpr', function () { ScopeMiddleware::requireScope(Scope::CUSTOMER_WRITE, '/vehicles/set-auto-start-on-lpr'); global $response; $auth = new authentication(); $user = $auth->get_user(); $permission_own = self::definePermission('set_auto_start_on_lpr', subusers_permission_node_key::VEHICLES_EDIT); $permission_other = self::definePermission('set_auto_start_on_lpr_other'); self::requireParameters(['id', 'active']); $id = (int)self::getParameter('id'); self::requireType($id, self::type_int()); self::requireMinValue($id, 1); self::requireMaxValue($id, 9999999999); $autoStartOnLpr = (bool)self::getParameter('active'); self::requireType($autoStartOnLpr, self::type_bool()); $vehicle = (new customer_vehicles_o())->select($id); if (!$vehicle->exists()) { (new logs_o())->add('vehicles', 'global', 1, (int)($user->id ?? 0), 'SET_AUTO_START_ON_LPR', 'Vehicle not found'); $response->error('Vehicle not found', 404); } self::allowOwnOrDepartmentAccess( $permission_own, $permission_other, (int)$vehicle->customer_id->value(), null, null, 'You are not allowed to edit vehicles from other users' ); $vehicle->setAutoStartOnLpr($autoStartOnLpr); $response->success([...$vehicle->asArray()]); }, [ 'set_auto_start_on_lpr' => 'Set auto start on LPR', 'set_auto_start_on_lpr_other' => 'Set auto start on LPR for another user\'s vehicle', ] ); $this->post('/vehicles/set-vehicle-type-id', function () { ScopeMiddleware::requireScope(Scope::CUSTOMER_WRITE, '/vehicles/set-vehicle-type-id'); global $response; $auth = new authentication(); $user = $auth->get_user(); $permission_own = self::definePermission('set_vehicle_type_id', subusers_permission_node_key::VEHICLES_EDIT); $permission_other = self::definePermission('set_vehicle_type_id_other'); self::requireParameters(['id', 'vehicleTypeId']); $id = (int)self::getParameter('id'); self::requireType($id, self::type_int()); self::requireMinValue($id, 1); self::requireMaxValue($id, 9999999999); $vehicleTypeId = (string)self::getParameter('vehicleTypeId'); self::requireType($vehicleTypeId, self::type_string()); self::requireMinLength('vehicleTypeId', 1); self::requireMaxLength('vehicleTypeId', 50); $vehicle = (new customer_vehicles_o())->select($id); if (!$vehicle->exists()) { (new logs_o())->add('vehicles', 'global', 1, (int)($user->id ?? 0), 'SET_VEHICLE_TYPE_ID', 'Vehicle not found'); $response->error('Vehicle not found', 404); } self::allowOwnOrDepartmentAccess( $permission_own, $permission_other, (int)$vehicle->customer_id->value(), null, null, 'You are not allowed to edit vehicles from other users' ); $customer = (new users_o())->getUserByCustomerNumber((int)$vehicle->customer_id->value()); if (!$vehicle->hasXLVask()) { if (!$customer->hasXLVaskCustomerAccount()) { (new logs_o())->add('vehicles', 'global', 1, (int)($user->id ?? 0), 'SET_VEHICLE_TYPE_ID', 'Attempt to set vehicle type ID without XL Vask registration and no customer account'); $response->error('Vehicle is not registered in the XL Vask system', 400); } else { $vehicle->createXLVaskVehicle($vehicleTypeId); } } else { $vehicle->setVehicleTypeId($vehicleTypeId); } $response->success([...$vehicle->asArray()]); }, [ 'set_vehicle_type_id' => 'Set vehicle type ID', 'set_vehicle_type_id_other' => 'Set vehicle type ID for another user\'s vehicle', ] ); $this->get('/superuser/users-with-vehicle-subscriptions', function () { ScopeMiddleware::requireScope(Scope::CUSTOMER_READ, '/superuser/users-with-vehicle-subscriptions'); // Require the user to be logged in global $response; $this->requirePermission('list_users_with_vehicle_subscriptions'); // Get the user object $user = (new authentication())->get_user(); // Check if the request was successful if ($user) { // Log the incident (new logs_o())->add('vehicles', 'global', 1, $user->id, 'LIST_USERS_WITH_VEHICLE_SUBSCRIPTIONS', 'Successfully listed users with vehicle subscriptions'); // Return the list of users with vehicle subscriptions $vehicles_o = new customer_vehicles_o(); $customer_ids = $vehicles_o->getFieldsWhere( [ 'wash_subscription' => 1, ], ['customer_id'] ); // Get all the customer_ids that has vehicle subscriptions $customer_ids = array_map(function ($customer_id) { return (int)$customer_id['customer_id']; }, $customer_ids); // Remove duplicates $customer_ids = array_unique($customer_ids); $response->success( $customer_ids ? array_map(function ($customer_id) { $tmp_user = new users_o(); $tmp_user->getUserByCustomerNumber((int)$customer_id); return [...$tmp_user->asArray()]; }, $customer_ids) : [] ); } else { // Log the incident (new logs_o())->add('vehicles', 'global', 1, 0, 'LIST_USERS_WITH_VEHICLE_SUBSCRIPTIONS', 'No user found, or invalid session'); // Return an error $response->error('Invalid session', 400); } }, [ 'list_users_with_vehicle_subscriptions' => 'List users with vehicle subscriptions', ] ); $this->get('/vehicles/status', function () { ScopeMiddleware::requireScope(Scope::CUSTOMER_READ, '/vehicles/status'); // Require the user to be logged in global $response; $this->requirePermission('view_vehicle_status'); /** Define parameters */ // "reg" - The registration number to check the status of self::requireParameters([ 'reg', ]); self::requireType((string)self::getParameter('reg'), self::type_string()); $reg = (string)self::getParameter('reg'); self::requireMinLength('reg', 1); self::requireMaxLength('reg', 12); // "department" - The department to check the status of (optional) if (self::isParametersSet(['department'])) { self::requireType((int)self::getParameter('department'), self::type_int()); $department = (int)self::getParameter('department'); self::requireMinValue($department, 1); self::requireMaxValue($department, 999999); // Check if the department exists $department_o = new departments_o(); $department_o->select($department); if (!$department_o->exists()) { $response->error('Department not found', 404); } } else { $department = null; } // Validate the registration number $this->validateRegistrationNumber($reg, $response); $customer_vehicles_o = new customer_vehicles_o(); /** Determine the statuses of the vehicle */ $isVerified = $customer_vehicles_o->countRowsWhere( [ 'reg' => $reg, ] ) > 0; $isKnown = (new orders_o())->countRowsWhere( [ 'reg_1' => $reg, ] ) > 1; // We need to go above one, as the current order will also be counted $isBooked = (new bookings_o())->countRowsWhere( [ 'regNrTraekker' => $reg, 'status' => 'pending', ...($department ? ['department' => $department] : []), // Filter by department if set ] ) > 0; // Check if the customer is barred (only if the vehicle is verified) $customer_number = $isVerified ? ($customer_vehicles_o->getFieldsWhere( [ 'reg' => $reg, ], ['customer_id'] )) : null; $customer_number = $customer_number ? (int)$customer_number[0]['customer_id'] : null; $cardPaymentRequired = ($customer_number && (new users_o())->isCustomerBarred((int)$customer_number)); $last_order = $customer_vehicles_o->getLastOrderByPlate($reg); $response->success([ 'department' => $department, 'reg' => $reg, 'verified' => $isVerified, 'known' => $isKnown, 'booked' => $isBooked, 'card' => $cardPaymentRequired, 'unknown' => !$customer_number && !$isKnown && !$isBooked, 'last_order_id' => $last_order?->id ?? null, ]); }, [ 'view_vehicle_status' => 'View vehicle status (Verified, Known, Unknown, Booked, Card payment required, etc.)', ] ); $this->get('/vehicles/search', function () { ScopeMiddleware::requireScope(Scope::CUSTOMER_READ, '/vehicles/search'); global $response; $this->requirePermission('search_vehicles'); /** * This endpoint allows searching for vehicles by registration number. * This includes: * - verified vehicles (in customer_vehicles) * - known vehicles (in orders). * - booked vehicles (in bookings). * - unknown vehicles (not in any of the above). */ self::requireParameters(['search']); self::requireType((string)self::getParameter('search'), self::type_string()); $search = (string)self::getParameter('search'); self::requireMinLength('search', 1); self::requireMaxLength('search', 12); $department = null; // If department is set, validate it if (self::isParametersSet(['department'])) { $department = (int)self::getParameter('department'); self::requireType($department, self::type_int()); self::requireMinValue($department, 1); self::requireMaxValue($department, 999999); // Check if the department exists $department_o = new departments_o(); $department_o->select($department); if (!$department_o->exists()) { $response->error('Department not found', 404); } self::requireDepartmentAccess($department); } // Strip the search term of whitespace $search = preg_replace('/\s+/', '', $search); /** Get the different lists of registration numbers */ $options = ['limit' => 10]; $booked_filters = [...($department ? ['department' => $department] : [])]; $booking_lookup_filters = ['order_id' => null, 'deleted_at' => null, ...$booked_filters]; $verified_regs = self::getVerifiedRegs($search, $options); $known_regs = self::getKnownRegs($search, $options); $booked_regs = self::getBookedRegs($search, $options, $booked_filters); $unknown_regs = array_filter(array_unique(array_merge([...self::getUnknownRegs($search, $options), $search]))); // Add the search term as the first item, to ensure it is included in the results /** * Prevent overlaps between the lists * - Booked vehicles should not be in known, unknown or verified * - Verified vehicles should not be in known or booked * - Known vehicles should not be in unknown * - Unknown vehicles should not be in any other list */ $known_regs = array_filter($known_regs, function ($reg) use ($verified_regs, $booked_regs) { return !in_array($reg, $verified_regs) && !in_array($reg, $booked_regs); }); $verified_regs = array_filter($verified_regs, function ($reg) use ($booked_regs) { return !in_array($reg, $booked_regs); }); $unknown_regs = array_filter($unknown_regs, function ($reg) use ($verified_regs, $known_regs, $booked_regs) { return !in_array($reg, $verified_regs) && !in_array($reg, $known_regs) && !in_array($reg, $booked_regs); }); // Sort the lists alphabetically sort($verified_regs); sort($known_regs); sort($booked_regs); sort($unknown_regs); // Define the custom relevance sorting function $relevant = [ ...array_values(array_unique($booked_regs)), ...array_values(array_unique($verified_regs)), ...array_values(array_unique($known_regs)), ...array_values(array_unique($unknown_regs)), ]; // Limit the results to 10 items total, while keeping the relevance order $vehicles = array_slice($relevant, 0, 10); /** Format the relevance results */ $vehicles = array_map(function ($reg) use ($verified_regs, $known_regs, $booked_regs, $unknown_regs, $booking_lookup_filters) { // Determine the status of the vehicle $status = null; $isVerified = in_array($reg, $verified_regs); $isKnown = in_array($reg, $known_regs); $isBooked = in_array($reg, $booked_regs); $isUnknown = in_array($reg, $unknown_regs); // Initialize other variables $customer_number = null; // Will be populated for verified / booked vehicles $customer_name = null; // Will be populated for verified / booked vehicles $last_order_id = (new customer_vehicles_o())->getLastOrderByPlate($reg)?->id ?? null; // These variables are only for verified vehicles $type = null; $id = null; $reference = null; // Booking related variables $booking_id = null; $notes = null; // Booking notes $booking_datetime = null; // Set the status based on priority: booked > verified > known > unknown if ($isBooked) { $status = 'booked'; $booking = self::getPendingOrderBookingSummaryByPlate($reg, $booking_lookup_filters); // Populate other variables $booking_id = $booking ? (int)$booking['id'] : null; $customer_number = $booking ? (int)$booking['customer_number'] : null; $reference = $booking ? (string)$booking['reference'] : null; $notes = $booking ? (string)$booking['note'] : null; $booking_datetime = $booking ? (string)($booking['datetime'] ?? '') : null; // If the booking has a customer number, check if the customer is barred if ($customer_number && (new users_o())->isCustomerBarred((int)$customer_number)) { $status = 'card'; } } elseif ($isVerified) { $status = 'verified'; // Get the vehicle $customer_vehicles_o = new customer_vehicles_o(); $vehicle = $customer_vehicles_o->getFieldsWhere(['reg' => $reg], ['customer_id', 'type', 'id', 'reference']); // Populate other variables $customer_number = $vehicle ? (int)$vehicle[0]['customer_id'] : null; $type = $vehicle ? (int)$vehicle[0]['type'] : null; $id = $vehicle ? (int)$vehicle[0]['id'] : null; $reference = $vehicle ? (string)$vehicle[0]['reference'] : null; // Check if the customer is barred (only if verified) if ($customer_number && (new users_o())->isCustomerBarred((int)$customer_number)) { $status = 'card'; } } elseif ($isKnown) { $status = 'known'; // Check if the vehicle previously only has been set to a specific customer, and if so, get the customer name $orders_o = new orders_o(); $order = $orders_o->getFieldsWhere([ 'reg_1' => $reg, 'deleted_at' => null, // Only consider non-deleted orders ], ['customer_id']); $customer_ids = array_unique(array_map(function ($row) { return (int)$row['customer_id']; }, $order)); if (count($customer_ids) === 1) { $customer_number = $customer_ids[0]; // Check if the customer is barred (only if known) if ($customer_number && (new users_o())->isCustomerBarred((int)$customer_number)) { $status = 'unknown'; // If the customer is barred, we set the status to unknown (as we don't need to know about barred known vehicles) $customer_number = null; // Clear the customer number as well } } else { // Set the customer name to multiple known customers $customer_name = 'Multiple known customers'; } } elseif ($isUnknown) { $status = 'unknown'; } // Get the customer name if we have a customer number if ($customer_number) { $customer = (new users_o())->getUserByCustomerNumber((int)$customer_number); if ($customer->exists()) { $customer_name = (string)$customer->getCustomerName((int)$customer_number); } } // Return the formatted vehicle return [ 'reg' => $reg, 'status' => $status, 'type' => $type, 'customer_id' => $customer_number, 'id' => $id, 'last_order_id' => $last_order_id, 'reference' => $reference, 'booking_id' => $booking_id, 'booking_datetime' => $booking_datetime, 'notes' => $notes, 'customer_name' => $customer_name, 'barred' => $status === 'card', // If the status is 'card', the customer is barred ]; }, $vehicles); // Return the results $response->success($vehicles); }, [ 'search_vehicles' => 'Search vehicles by registration number', ] ); } private static function getVerifiedRegs(string $search, array $options = ['limit' => null]): array { $customer_vehicles_o = new customer_vehicles_o(); $results = $customer_vehicles_o->getFieldsWhereContaining( [ 'reg' => $search ], ['reg'], $options ); return array_map(function ($row) { return (string)$row['reg']; }, $results); } private static function getKnownRegs(string $search, array $options = ['limit' => null]): array { $orders_o = new orders_o(); $results = $orders_o->getFieldsWhereContaining( [ 'reg_1' => $search ], ['reg_1'], $options ); return array_map(function ($row) { return (string)$row['reg_1']; }, $results); } private static function getBookedRegs(string $search, array $options = ['limit' => null], ?array $filters = []): array { $bookings_o = new order_bookings_o(); /** * $bookings_o = new bookings_o(); * * $results1 = $bookings_o->getFieldsWhereContaining( * [ * 'regNrTraekker' => $search, * 'status' => 'pending', * ], * ['regNrTraekker'], * $options * ); * $results2 = $bookings_o->getFieldsWhereContaining( * [ * 'regNrTrailer' => $search, * 'status' => 'pending', * ], * ['regNrTrailer'], * $options * ); */ $results1 = $bookings_o->getFieldsWhereContaining( [ 'reg_1' => $search, 'order_id' => null, 'deleted_at' => null, ...$filters ], ['reg_1'], $options ); $results2 = $bookings_o->getFieldsWhereContaining( [ 'reg_2' => $search, 'order_id' => null, 'deleted_at' => null, ...$filters ], ['reg_2'], $options ); return array_unique(array_merge( array_map(function ($row) { return (string)$row['reg_1']; }, $results1), array_map(function ($row) { return (string)$row['reg_2']; }, $results2) )); } private static function getPendingOrderBookingSummaryByPlate(string $reg, array $filters = []): ?array { $bookings_o = new order_bookings_o(); $fields = ['id', 'customer_number', 'reference', 'note', 'datetime']; $results1 = $bookings_o->getFieldsWhere([ 'reg_1' => $reg, ...$filters ], $fields) ?: []; $results2 = $bookings_o->getFieldsWhere([ 'reg_2' => $reg, ...$filters ], $fields) ?: []; $matches_by_key = []; foreach (array_merge($results1, $results2) as $booking) { if (!is_array($booking)) { continue; } $booking_id = isset($booking['id']) ? (int)$booking['id'] : 0; $dedupe_key = $booking_id > 0 ? 'booking:' . $booking_id : implode('|', [ (string)($booking['reference'] ?? ''), (string)($booking['note'] ?? ''), (string)($booking['datetime'] ?? ''), ]); $matches_by_key[$dedupe_key] = $booking; } if (empty($matches_by_key)) { return null; } $matches = array_values($matches_by_key); usort($matches, function (array $left, array $right): int { $left_has_datetime = self::hasPendingOrderBookingSummaryDatetime($left); $right_has_datetime = self::hasPendingOrderBookingSummaryDatetime($right); if ($left_has_datetime !== $right_has_datetime) { return $right_has_datetime <=> $left_has_datetime; } $timestamp_difference = self::getPendingOrderBookingSummaryTimestamp($left) <=> self::getPendingOrderBookingSummaryTimestamp($right); if ($timestamp_difference !== 0) { return $timestamp_difference; } return ((int)($left['id'] ?? 0)) <=> ((int)($right['id'] ?? 0)); }); return $matches[0] ?? null; } private static function hasPendingOrderBookingSummaryDatetime(array $booking): bool { return trim((string)($booking['datetime'] ?? '')) !== ''; } private static function getPendingOrderBookingSummaryTimestamp(array $booking): int { $raw_value = (string)($booking['datetime'] ?? ''); if ($raw_value !== '') { $parsed_value = strtotime($raw_value); if ($parsed_value !== false) { return $parsed_value; } } $fallback_id = (int)($booking['id'] ?? PHP_INT_MAX); return $fallback_id > 0 ? $fallback_id : PHP_INT_MAX; } private static function getUnknownRegs(string $search, array $options = ['limit' => null]): array { // Unknown vehicles are vehicles that has been scanned by the LPR system, but are not in any of the other lists $plate_scans_o = new plate_scans_o(); $results = $plate_scans_o->getFieldsWhereContaining( [ 'plate' => $search, ], ['plate'], $options ); return array_map(function ($row) { return (string)$row['plate']; }, $results); } private function getData(mixed $data, $response) { if (!isset($data['type'])) { $response->error('Type is required', 400); } if (!isset($data['reg'])) { $response->error('Registration number is required', 400); } if (!isset($data['notes'])) { $response->error('Notes is required', 400); } return $data; } private function validateRegistrationNumber(mixed $reg, $response): void { if (!preg_match('/^[A-Z0-9]{4,10}$/', $reg)) { $response->error('Invalid registration number, it must be 4-10 characters long, and only contain uppercase letters and numbers', 400); } } private function validateType(mixed $type, $response): void { // Make sure the type is more than 2 characters if (strlen($type) < 2) { $response->error('Type is too short, it must be at least 2 characters', 400); } // Make sure the type is less than 50 characters if (strlen($type) > 50) { $response->error('Type is too long, it must be less than 50 characters', 400); } } private function validateNotes(mixed $notes, $response): void { // If the notes are set, make sure they are less than 250 characters if (isset($notes) && strlen($notes) > 250) { $response->error('Notes are too long, they must be less than 250 characters', 400); } } }