diff --git a/services/nginx/app/routes/subusersRoute.php b/services/nginx/app/routes/subusersRoute.php index ac0ef504..2ce4bfa6 100644 --- a/services/nginx/app/routes/subusersRoute.php +++ b/services/nginx/app/routes/subusersRoute.php @@ -1881,6 +1881,14 @@ class subusersRoute $response->error('Failed to create driver registration', 500); } + // Create (or reuse) a pending company grant and notify the dispatcher + // immediately so they can approve the driver before the driver even + // finishes the SMS setup. This is TRU-88 / DOGNVASK-OP 4. + $this->seedPendingGrantAndNotifyDispatcher( + $subuser, + $customerNumber + ); + if ($subuser->requiresSetup()) { $issuedInvite = $this->issueSetupInvite($subuser); if (($issuedInvite['delivery']['status'] ?? null) !== 'sent') { @@ -1898,6 +1906,69 @@ class subusersRoute ]); } + /** + * Ensure a pending grant exists for the (subuser, customer) pair and SMS + * the dispatcher with approve/deny links as soon as the driver is created + * via the public QR-code flow. Idempotent: reuses an existing grant and + * short-circuits the SMS send when an actionable notification is already + * outstanding (avoiding duplicate dispatcher pings for repeat scans). + */ + private function seedPendingGrantAndNotifyDispatcher( + subusers_o $subuser, + int $customerNumber + ): array { + $grant = (new subuser_grants_o())->getGrantForSubuserAndCustomer( + (int)$subuser->id, + $customerNumber, + true + ); + if ($grant === null) { + $grant = (new subuser_grants_o())->add( + $customerNumber, + (int)$subuser->id, + false, + null + ); + } + + if ($this->hasOutstandingGrantDecisionTokens((int)$grant->id, (int)$subuser->id)) { + return [ + 'channel' => 'sms', + 'status' => 'skipped_duplicate', + 'message' => 'Dispatcher already has an outstanding approval request.', + ]; + } + + return $this->notifyCustomerOfGrantRequest($grant, $subuser, $customerNumber); + } + + /** + * Returns true when there is at least one unconsumed, unexpired + * PURPOSE_GRANT_APPROVE or PURPOSE_GRANT_DENY token for the (subuser, + * grant) pair. Used to avoid re-pinging the dispatcher when a driver + * re-scans the QR code while a previous approval request is still live. + */ + private function hasOutstandingGrantDecisionTokens(int $grantId, int $subuserId): bool + { + global $db; + $statement = $db->conn->prepare( + 'SELECT id FROM subuser_action_tokens ' + . 'WHERE grant_id = ? AND subuser_id = ? ' + . "AND purpose IN ('grant_approve', 'grant_deny') " + . 'AND used_at IS NULL AND expires_at > UTC_TIMESTAMP() ' + . 'LIMIT 1' + ); + if ($statement === false) { + return false; + } + $statement->bind_param('ii', $grantId, $subuserId); + $statement->execute(); + $statement->store_result(); + $outstanding = $statement->num_rows > 0; + $statement->close(); + return $outstanding; + } + public function run(): void { subusers_schema_bootstrap::ensureTables(); @@ -2274,16 +2345,18 @@ class subusersRoute null ); } - $notification = $this->notifyCustomerOfGrantRequest( - $grant, - $subuser, - $customerNumber - ); - if (($notification['status'] ?? null) !== 'sent') { - $response->error( - 'Driver setup was saved, but the customer notification could not be sent. Please try again.', - 503 + if (!$this->hasOutstandingGrantDecisionTokens((int)$grant->id, (int)$subuser->id)) { + $notification = $this->notifyCustomerOfGrantRequest( + $grant, + $subuser, + $customerNumber ); + if (($notification['status'] ?? null) !== 'sent') { + $response->error( + 'Driver setup was saved, but the customer notification could not be sent. Please try again.', + 503 + ); + } } } // Invalidate the setup token diff --git a/services/nginx/app/tests/Unit/Subusers/PublicSubuserRegistrationContractTest.php b/services/nginx/app/tests/Unit/Subusers/PublicSubuserRegistrationContractTest.php index c840e346..094a4eef 100644 --- a/services/nginx/app/tests/Unit/Subusers/PublicSubuserRegistrationContractTest.php +++ b/services/nginx/app/tests/Unit/Subusers/PublicSubuserRegistrationContractTest.php @@ -35,7 +35,23 @@ it('requires abuse controls and serializes idempotent driver registration writes expect($method)->toContain('SELECT RELEASE_LOCK(?)'); expect(strpos($method, '$db->conn->commit();'))->toBeLessThan(strpos($method, '$this->issueSetupInvite($subuser);')); expect($method)->toContain('$this->storePublicRegistrationPending($setupToken, $customerNumber);'); - expect($method)->not->toContain('(new subuser_grants_o())->add('); + // TRU-88: dispatcher is notified at driver-creation time, so the public + // registration now seeds a pending company grant via the helper. + expect($method)->toContain('$this->seedPendingGrantAndNotifyDispatcher('); + expect($method)->toContain('$this->hasOutstandingGrantDecisionTokens('); +}); + +it('seeds the pending grant and notifies the dispatcher on QR driver creation (TRU-88)', function (): void { + $method = preg_replace('/\s+/', ' ', public_subuser_registration_method()); + + expect($method)->toContain('seedPendingGrantAndNotifyDispatcher'); + expect($method)->toContain('hasOutstandingGrantDecisionTokens'); + expect($method)->toContain('notifyCustomerOfGrantRequest'); + // The dispatcher ping must run BEFORE we store the setup token in Redis + // so a pre-approval is valid by the time the driver opens their setup link. + expect(strpos($method, '$this->seedPendingGrantAndNotifyDispatcher('))->toBeLessThan( + strpos($method, '$this->storePublicRegistrationPending(') + ); }); it('never returns setup credentials from either public driver registration alias', function (): void { @@ -55,7 +71,7 @@ it('never returns setup credentials from either public driver registration alias expect($operation)->not->toContain('setup_link:'); }); -it('creates and notifies a company grant only after the SMS setup token is completed', function (): void { +it('reuses the pre-seeded grant at setup completion and re-notifies only when no decision tokens are outstanding', function (): void { $code = preg_replace('/\s+/', ' ', (string)file_get_contents(app_path('routes/subusersRoute.php'))); $setupStart = strpos($code, "\$this->post('/subusers/setup', function () {"); $setupEnd = strpos($code, "\$this->post('/subusers/password-reset/request'", $setupStart === false ? 0 : $setupStart); @@ -65,5 +81,9 @@ it('creates and notifies a company grant only after the SMS setup token is compl expect($setup)->toContain('(new subuser_grants_o())->add('); expect($setup)->toContain('$this->notifyCustomerOfGrantRequest('); expect($setup)->toContain('$this->clearPublicRegistrationPending($token)'); + // TRU-88: the setup completion must short-circuit the dispatcher SMS + // when approval/deny tokens were already issued at driver creation time. + expect($setup)->toContain('$this->hasOutstandingGrantDecisionTokens('); + expect($setup)->toContain('$this->clearThrottleAttempt($setupThrottleKey);'); expect(strpos($setup, '$subuser->update('))->toBeLessThan(strpos($setup, '(new subuser_grants_o())->add(')); }); diff --git a/services/nginx/app/tests/Unit/Subusers/SubuserQrRegistrationDispatcherNotificationTest.php b/services/nginx/app/tests/Unit/Subusers/SubuserQrRegistrationDispatcherNotificationTest.php new file mode 100644 index 00000000..8df46868 --- /dev/null +++ b/services/nginx/app/tests/Unit/Subusers/SubuserQrRegistrationDispatcherNotificationTest.php @@ -0,0 +1,88 @@ +post('/subusers/setup', function () {"); + $end = strpos($code, "\$this->post('/subusers/password-reset/request'", $start === false ? 0 : $start); + if ($start === false || $end === false || $end <= $start) { + throw new RuntimeException('Unable to locate the SMS setup completion handler.'); + } + return substr($code, $start, $end - $start); +} + +it('seeds the pending grant and pings the dispatcher on QR driver creation', function (): void { + $code = preg_replace('/\s+/', ' ', tru_88_public_registration_block()); + + // Idempotent helper that gets-or-creates the grant and short-circuits + // duplicate dispatcher pings. + expect($code)->toContain('private function seedPendingGrantAndNotifyDispatcher('); + expect($code)->toContain('private function hasOutstandingGrantDecisionTokens('); + expect($code)->toContain('$this->seedPendingGrantAndNotifyDispatcher('); + + // The dispatcher ping must happen BEFORE the driver receives the SMS + // setup link so the customer's approve/deny links are valid the moment + // the driver taps the link. + $seedIndex = strpos($code, '$this->seedPendingGrantAndNotifyDispatcher('); + $setupIndex = strpos($code, '$this->issueSetupInvite($subuser);'); + $storeIndex = strpos($code, '$this->storePublicRegistrationPending('); + expect($seedIndex)->not->toBeFalse(); + expect($setupIndex)->not->toBeFalse(); + expect($storeIndex)->not->toBeFalse(); + expect($seedIndex)->toBeLessThan($setupIndex); + expect($seedIndex)->toBeLessThan($storeIndex); +}); + +it('does not double-ping the dispatcher on repeat QR scans', function (): void { + $code = preg_replace('/\s+/', ' ', tru_88_public_registration_block()); + + // The helper must check for outstanding grant decision tokens before + // firing a fresh SMS, so a driver who re-scans the QR code after + // their previous request is still live does not spam the dispatcher. + expect($code)->toContain('hasOutstandingGrantDecisionTokens'); + expect($code)->toContain("'skipped_duplicate'"); +}); + +it('reuses the seeded grant at SMS setup completion and skips the duplicate SMS', function (): void { + $code = preg_replace('/\s+/', ' ', tru_88_setup_completion_block()); + + // The setup completion must check for outstanding grant decision tokens + // so a dispatcher who was already pinged at QR-driver-creation time is + // not notified again once the driver finishes their setup. + expect($code)->toContain('$this->hasOutstandingGrantDecisionTokens('); + expect($code)->toContain('$this->notifyCustomerOfGrantRequest('); +}); + +it('queries subuser_action_tokens for both grant_approve and grant_deny purposes', function (): void { + $code = preg_replace('/\s+/', ' ', tru_88_public_registration_block()); + + // The duplicate-guard query must consider both approve AND deny tokens. + // Hiding only the deny tokens would re-ping the dispatcher even when + // the customer has already rejected the driver. + expect($code)->toContain("'grant_approve'"); + expect($code)->toContain("'grant_deny'"); + expect($code)->toContain('used_at IS NULL'); + expect($code)->toContain('expires_at > UTC_TIMESTAMP()'); +});