diff --git a/services/nginx/app/routes/subusersRoute.php b/services/nginx/app/routes/subusersRoute.php index 31121521..9d859204 100644 --- a/services/nginx/app/routes/subusersRoute.php +++ b/services/nginx/app/routes/subusersRoute.php @@ -503,7 +503,87 @@ class subusersRoute ]; } - private function listSuperuserSubusers(): array + private function routePositiveInt(string $name): int + { + global $response; + + $raw = $this->fromRoute($name); + if (!is_string($raw) || !preg_match('/^[1-9][0-9]*$/', $raw)) { + $response->error('Invalid route parameter', 400); + } + + return (int)$raw; + } + + private function resolveSuperuserSubuserTargetUser(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); + } + + $customerName = $targetUser->display_name->value(); + if (!is_string($customerName) || trim($customerName) === '') { + $customerName = $this->resolveCustomerName($customerNumber); + } + + return [ + 'user_id' => (int)$targetUser->id, + 'customer_number' => $customerNumber, + 'customer_name' => $customerName, + ]; + } + + private function buildSubuserSummaryForCustomer(int $customerNumber): array + { + global $db; + + $statement = $db->conn->prepare(" + SELECT + COUNT(*) AS `total`, + SUM(CASE WHEN g.`enabled` = 1 AND COALESCE(s.`password`, '') <> '' THEN 1 ELSE 0 END) AS `active`, + SUM(CASE WHEN g.`enabled` = 1 AND COALESCE(s.`password`, '') = '' THEN 1 ELSE 0 END) AS `pending_setup`, + SUM(CASE WHEN g.`enabled` = 0 THEN 1 ELSE 0 END) AS `disabled` + FROM `subuser_grants` g + INNER JOIN `subusers` s ON s.`id` = g.`subuser` + WHERE g.`deleted_at` IS NULL + AND g.`billing_customer_number` = ? + "); + if ($statement === false) { + throw new Exception('Failed to prepare subuser summary query: ' . $db->conn->error); + } + + $statement->bind_param('i', $customerNumber); + $statement->execute(); + $result = $statement->get_result(); + $row = $result->fetch_assoc() ?: []; + $statement->close(); + + return [ + 'total' => (int)($row['total'] ?? 0), + 'active' => (int)($row['active'] ?? 0), + 'pending_setup' => (int)($row['pending_setup'] ?? 0), + 'disabled' => (int)($row['disabled'] ?? 0), + ]; + } + + private function addUserScopedSubuserMeta(array $targetUser): void + { + global $response; + + $response->add_meta('user_context', $targetUser); + $response->add_meta('subusers_summary', $this->buildSubuserSummaryForCustomer((int)$targetUser['customer_number'])); + } + + private function listSuperuserSubusers(?int $customerNumber = null): array { global $db, $response; @@ -521,6 +601,11 @@ class subusersRoute if (!$includeNonEnabled) { $where[] = 'g.`enabled` = 1'; } + if ($customerNumber !== null) { + $where[] = 'g.`billing_customer_number` = ?'; + $params[] = $customerNumber; + $types .= 'i'; + } if ($pagination['search'] !== null) { $where[] = "( @@ -610,6 +695,98 @@ class subusersRoute return array_map(fn(array $row): array => $this->buildSuperuserSubuserManagementPayload($row), $rows); } + private function getGrantForScopedUserOrFail(int $grantId, int $customerNumber): subuser_grants_o + { + global $response; + + $grant = (new subuser_grants_o())->select($grantId); + if (!$grant->exists()) { + $response->error('Subuser grant not found', 404); + } + $grant->getObjectProperties(); + + if ((int)$grant->billing_customer_number->value() !== $customerNumber || $grant->deleted_at->value() !== null) { + $response->error('Subuser grant not found for selected user', 404); + } + + return $grant; + } + + private function patchScopedSubuserGrant(int $grantId, int $customerNumber): void + { + global $response; + + $grant = $this->getGrantForScopedUserOrFail($grantId, $customerNumber); + $updates = []; + + if (self::isParametersSet(['enabled'])) { + $tmp = filter_var(self::getParameter('enabled'), FILTER_VALIDATE_BOOLEAN, FILTER_NULL_ON_FAILURE); + if ($tmp === null) { + $response->error('Invalid enabled value', 400); + } + $updates['enabled'] = (bool)$tmp; + } + + if (self::isParametersSet(['note'])) { + $note = $this->normalizeOptionalString(self::getParameter('note')); + if ($note !== null && strlen($note) > 65535) { + $response->error('Note must be at most 65535 characters long', 400); + } + $updates['note'] = $note; + } + + if (self::isParametersSet(['permissions'])) { + $updates['permissions'] = $this->parsePermissionsPayload(self::getParameter('permissions'), []); + } + + if ($updates === []) { + $response->error('No fields to update', 400); + } + + try { + $grant->update($updates); + } catch (Exception $exception) { + $response->error('Failed to update subuser grant', 500); + } + + $updatedGrant = (new subuser_grants_o())->select($grantId); + $updatedGrant->getObjectProperties(); + $subuser = (new subusers_o())->select((int)$updatedGrant->subuser->value()); + $subuser->getObjectProperties(); + + $response->success([ + 'subuser' => $this->buildSubuserManagementPayload($subuser, $customerNumber), + 'grant' => $updatedGrant->asArray(), + ]); + } + + private function resendInviteForScopedUser(int $subuserId, int $customerNumber): void + { + global $response; + + $subuser = (new subusers_o())->select($subuserId); + if (!$subuser->exists()) { + $response->error('Subuser not found', 404); + } + $subuser->getObjectProperties(); + + $grant = (new subuser_grants_o())->getGrantForSubuserAndCustomer($subuserId, $customerNumber, true); + if ($grant === null) { + $response->error('Subuser grant not found for selected user', 404); + } + + if (!$subuser->requiresSetup()) { + $response->error('Driver account already accepted the invitation.', 409); + } + + $invite = $this->issueSetupInvite($subuser); + $response->success([ + 'subuser' => $this->buildSubuserManagementPayload($subuser, $customerNumber), + 'grant' => $grant->asArray(), + 'invite' => $invite, + ]); + } + private function handleInviteSubuserForCustomer(int $customerNumber): void { global $response; @@ -617,6 +794,9 @@ class subusersRoute if ($customerNumber <= 0) { $response->error('Customer number is required', 400); } + if (self::isParametersSet(['customer_number']) && (int)self::getParameter('customer_number') !== $customerNumber) { + $response->error('Customer number does not match selected user', 400); + } self::requireParameters(['name', 'phone_country_code', 'phone']); @@ -1150,6 +1330,26 @@ class subusersRoute 'list_subusers' => 'List all chauffeur access grants for superusers.', ]); + $this->get('/superuser/users/{user_id}/subusers', function () { + global $response; + $this->requirePermission('list_subusers'); + $targetUser = $this->resolveSuperuserSubuserTargetUser($this->routePositiveInt('user_id')); + $this->addUserScopedSubuserMeta($targetUser); + $response->success($this->listSuperuserSubusers((int)$targetUser['customer_number'])); + }, [ + 'list_subusers' => 'List chauffeur access grants for a selected superuser customer account.', + ]); + + $this->get('/superuser/users/{user_id}/subusers/summary', function () { + global $response; + $this->requirePermission('list_subusers'); + $targetUser = $this->resolveSuperuserSubuserTargetUser($this->routePositiveInt('user_id')); + $response->add_meta('user_context', $targetUser); + $response->success($this->buildSubuserSummaryForCustomer((int)$targetUser['customer_number'])); + }, [ + 'list_subusers' => 'Summarize chauffeur access grants for a selected superuser customer account.', + ]); + $this->get('/subusers', function () { global $response; $customerNumber = $this->requireManagedCustomerScope(subusers_permission_node_key::SUBUSERS_LIST); @@ -1264,6 +1464,14 @@ class subusersRoute 'add_subusers' => 'Invite or link chauffeurs for any customer (superuser).', ]); + $this->post('/superuser/users/{user_id}/subusers/invite', function () { + $this->requirePermission('add_subusers'); + $targetUser = $this->resolveSuperuserSubuserTargetUser($this->routePositiveInt('user_id')); + $this->handleInviteSubuserForCustomer((int)$targetUser['customer_number']); + }, [ + 'add_subusers' => 'Invite or link chauffeurs for a selected superuser customer account.', + ]); + $this->post('/subusers/invite', function () { $customerNumber = $this->requireManagedCustomerScope(subusers_permission_node_key::SUBUSERS_ADD); $this->handleInviteSubuserForCustomer($customerNumber); @@ -1306,6 +1514,28 @@ class subusersRoute 'edit_subusers' => 'Resend chauffeur invites for a selected customer (superuser).', ]); + $this->post('/superuser/users/{user_id}/subusers/{subuser_id}/invite/resend', function () { + $this->requirePermission('edit_subusers'); + $targetUser = $this->resolveSuperuserSubuserTargetUser($this->routePositiveInt('user_id')); + $this->resendInviteForScopedUser( + $this->routePositiveInt('subuser_id'), + (int)$targetUser['customer_number'] + ); + }, [ + 'edit_subusers' => 'Resend chauffeur invites for a selected superuser customer account.', + ]); + + $this->patch('/superuser/users/{user_id}/subusers/grants/{grant_id}', function () { + $this->requirePermission('manage_subuser_grants'); + $targetUser = $this->resolveSuperuserSubuserTargetUser($this->routePositiveInt('user_id')); + $this->patchScopedSubuserGrant( + $this->routePositiveInt('grant_id'), + (int)$targetUser['customer_number'] + ); + }, [ + 'manage_subuser_grants' => 'Edit chauffeur grants for a selected superuser customer account.', + ]); + $this->post('/subusers/invite/resend', function () { global $response; diff --git a/services/nginx/app/tests/Api/SubusersApiTest.php b/services/nginx/app/tests/Api/SubusersApiTest.php index 289ab1e8..02bce244 100644 --- a/services/nginx/app/tests/Api/SubusersApiTest.php +++ b/services/nginx/app/tests/Api/SubusersApiTest.php @@ -280,3 +280,197 @@ it('lets superusers invite chauffeurs for a selected customer', function (): voi } } }); + +it('lists and summarizes chauffeurs through the user-scoped superuser route', function (): void { + $session = api_fixtures()->createUserSession(['list_subusers']); + $targetCustomer = api_fixtures()->createUser([ + 'display_name' => 'Scoped Customer Alpha', + 'economic_customer_name' => 'Scoped Customer Alpha', + ]); + $otherCustomer = api_fixtures()->createUser([ + 'display_name' => 'Scoped Customer Beta', + 'economic_customer_name' => 'Scoped Customer Beta', + ]); + $activeSubuser = api_fixtures()->createSubuser(['name' => 'Scoped Active Driver']); + $pendingSubuser = api_fixtures()->createSubuser([ + 'name' => 'Scoped Pending Driver', + 'password_plaintext' => null, + ]); + $disabledSubuser = api_fixtures()->createSubuser(['name' => 'Scoped Disabled Driver']); + $otherSubuser = api_fixtures()->createSubuser(['name' => 'Scoped Other Driver']); + + $activeGrantId = api_fixtures()->grantSubuser( + (int)$activeSubuser['id'], + (int)$targetCustomer['customer_number'], + ['VEHICLES_LIST'] + ); + api_fixtures()->grantSubuser( + (int)$pendingSubuser['id'], + (int)$targetCustomer['customer_number'], + ['BOOKINGS_LIST'] + ); + $disabledGrantId = api_fixtures()->grantSubuser( + (int)$disabledSubuser['id'], + (int)$targetCustomer['customer_number'], + ['ORDERS_LIST'] + ); + api_test_runtime()->db()->query('UPDATE `subuser_grants` SET `enabled` = 0 WHERE `id` = ' . $disabledGrantId); + api_fixtures()->grantSubuser( + (int)$otherSubuser['id'], + (int)$otherCustomer['customer_number'], + ['SELFSERVE_LIST'] + ); + + $response = api_client()->get( + '/superuser/users/' . $targetCustomer['id'] . '/subusers?page=1&limit=20&include_non_enabled=true', + $session['headers'] + ); + $summary = api_client()->get( + '/superuser/users/' . $targetCustomer['id'] . '/subusers/summary', + $session['headers'] + ); + + $response + ->assertStatus(200) + ->assertEnvelope() + ->assertSuccess(); + $summary + ->assertStatus(200) + ->assertEnvelope() + ->assertSuccess(); + + $rows = is_array($response->data()) ? $response->data() : []; + $grantIds = array_map(static fn (array $row): int => (int)($row['grant_id'] ?? 0), $rows); + + expect($grantIds)->toContain($activeGrantId); + expect($grantIds)->not->toContain(0); + expect($rows)->toHaveCount(3); + foreach ($rows as $row) { + expect($row['customer_number'] ?? null)->toBe((int)$targetCustomer['customer_number']); + } + + expect($response->meta()['user_context']['user_id'] ?? null)->toBe((int)$targetCustomer['id']); + expect($response->meta()['subusers_summary'] ?? null)->toMatchArray([ + 'total' => 3, + 'active' => 1, + 'pending_setup' => 1, + 'disabled' => 1, + ]); + expect($summary->data())->toMatchArray([ + 'total' => 3, + 'active' => 1, + 'pending_setup' => 1, + 'disabled' => 1, + ]); +}); + +it('edits only customer-matching chauffeur grants through the user-scoped route', function (): void { + $session = api_fixtures()->createUserSession(['manage_subuser_grants']); + $targetCustomer = api_fixtures()->createUser(['display_name' => 'Scoped Patch Customer']); + $otherCustomer = api_fixtures()->createUser(['display_name' => 'Scoped Patch Other']); + $targetSubuser = api_fixtures()->createSubuser(['name' => 'Patch Target Driver']); + $otherSubuser = api_fixtures()->createSubuser(['name' => 'Patch Other Driver']); + $targetGrantId = api_fixtures()->grantSubuser( + (int)$targetSubuser['id'], + (int)$targetCustomer['customer_number'], + ['VEHICLES_LIST'] + ); + $otherGrantId = api_fixtures()->grantSubuser( + (int)$otherSubuser['id'], + (int)$otherCustomer['customer_number'], + ['BOOKINGS_LIST'] + ); + + $update = api_client()->request( + 'PATCH', + '/superuser/users/' . $targetCustomer['id'] . '/subusers/grants/' . $targetGrantId, + [ + 'enabled' => false, + 'note' => 'Scoped note', + 'permissions' => ['ORDERS_LIST'], + ], + $session['headers'] + ); + $crossCustomer = api_client()->request( + 'PATCH', + '/superuser/users/' . $targetCustomer['id'] . '/subusers/grants/' . $otherGrantId, + ['note' => 'Should not save'], + $session['headers'] + ); + + $update + ->assertStatus(200) + ->assertEnvelope() + ->assertSuccess(); + $crossCustomer + ->assertStatus(404) + ->assertEnvelope() + ->assertSuccess(false); + + expect($update->data()['grant']['enabled'] ?? null)->toBeFalse(); + expect($update->data()['grant']['note'] ?? null)->toBe('Scoped note'); + expect($update->data()['grant']['permissions'] ?? null)->toBe(['ORDERS_LIST']); +}); + +it('rejects mismatched customer numbers on user-scoped chauffeur invites', function (): void { + $session = api_fixtures()->createUserSession(['add_subusers']); + $targetCustomer = api_fixtures()->createUser(['display_name' => 'Scoped Invite Customer']); + $otherCustomer = api_fixtures()->createUser(['display_name' => 'Scoped Invite Other']); + + $response = api_client()->post('/superuser/users/' . $targetCustomer['id'] . '/subusers/invite', [ + 'customer_number' => (int)$otherCustomer['customer_number'], + 'name' => 'Mismatched Driver', + 'phone_country_code' => 45, + 'phone' => 71999999, + ], $session['headers']); + + $response + ->assertStatus(400) + ->assertEnvelope() + ->assertSuccess(false) + ->assertMessage('Customer number does not match selected user'); +}); + +it('resends pending user-scoped chauffeur invites and blocks accepted accounts', function (): void { + $session = api_fixtures()->createUserSession(['edit_subusers']); + $targetCustomer = api_fixtures()->createUser(['display_name' => 'Scoped Resend Customer']); + $pendingSubuser = api_fixtures()->createSubuser([ + 'name' => 'Pending Resend Driver', + 'password_plaintext' => null, + ]); + $acceptedSubuser = api_fixtures()->createSubuser(['name' => 'Accepted Resend Driver']); + api_fixtures()->grantSubuser( + (int)$pendingSubuser['id'], + (int)$targetCustomer['customer_number'], + ['VEHICLES_LIST'] + ); + api_fixtures()->grantSubuser( + (int)$acceptedSubuser['id'], + (int)$targetCustomer['customer_number'], + ['VEHICLES_LIST'] + ); + + $pending = api_client()->post( + '/superuser/users/' . $targetCustomer['id'] . '/subusers/' . $pendingSubuser['id'] . '/invite/resend', + [], + $session['headers'] + ); + $accepted = api_client()->post( + '/superuser/users/' . $targetCustomer['id'] . '/subusers/' . $acceptedSubuser['id'] . '/invite/resend', + [], + $session['headers'] + ); + + $pending + ->assertStatus(200) + ->assertEnvelope() + ->assertSuccess(); + $accepted + ->assertStatus(409) + ->assertEnvelope() + ->assertSuccess(false); + + $token = $pending->data()['invite']['setup_token'] ?? null; + expect($token)->toBeString(); + (new \objects\subusers_o())->invalidateSetupToken((string)$token); +});