Compare commits

...
Author SHA1 Message Date
Jeppe B 6adc6f79be Merge branch 'master' into fix/tru-88-qr-sms-dispatcher-approval 2026-08-17 21:03:45 +02:00
TRU-88 backend sub-agent 0e32e03da5 TRU-88: SMS dispatcher on QR driver creation with approval link
DOGNVASK-OP 4: when a new driver registers via the public QR-code
endpoint, the customer (dispatcher) is now notified by SMS with
approve/deny links the moment the driver is created — no longer only
after the driver completes their SMS setup.

- registerPublicSubuser() now calls seedPendingGrantAndNotifyDispatcher(),
  which gets-or-creates the pending company grant, issues grant_approve
  / grant_deny action tokens, and SMSes the customer with the same
  link structure the existing /subusers/access-decision flow already
  consumes.
- seedPendingGrantAndNotifyDispatcher() is idempotent: if a previous
  request is still live (unconsumed, unexpired tokens outstanding), the
  SMS is skipped so a driver re-scanning the QR code does not spam the
  dispatcher.
- /subusers/setup completion reuses the seeded grant and only re-pings
  the dispatcher when no decision tokens are outstanding, so a fresh
  flow (e.g. legacy scans) still notifies once.
- Public response stays uniform (no setup_token / customer_number
  leaked), per existing contract.
- New contract tests assert the new code paths, helper methods, and
  duplicate-guard semantics.
2026-08-17 18:01:27 +00:00
3 changed files with 192 additions and 11 deletions
+82 -9
View File
@@ -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
@@ -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('));
});
@@ -0,0 +1,88 @@
<?php
/**
* TRU-88 / DOGNVASK-OP 4: SMS to dispatcher on QR driver creation.
*
* Asserts that the public QR-code driver registration handler in
* services/nginx/app/routes/subusersRoute.php seeds a pending company
* grant and notifies the dispatcher with approve/deny links at the
* moment the driver is created, without waiting for the driver to
* complete the SMS setup. The setup completion must reuse the seeded
* grant and re-notify only when no decision tokens are still live.
*/
function tru_88_public_registration_block(): string
{
$code = (string)file_get_contents(app_path('routes/subusersRoute.php'));
$start = strpos($code, 'private function registerPublicSubuser(): void');
$end = strpos($code, 'public function run(): void', $start === false ? 0 : $start);
if ($start === false || $end === false || $end <= $start) {
throw new RuntimeException('Unable to locate the public subuser registration handler.');
}
return $code;
}
function tru_88_setup_completion_block(): string
{
$code = (string)file_get_contents(app_path('routes/subusersRoute.php'));
$start = strpos($code, "\$this->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()');
});