TenantAtlas/apps/platform/app/Services/Providers/ProviderConnectionMutationService.php
ahmido 1655cc481e Spec 188: canonical provider connection state cleanup (#219)
## Summary
- migrate provider connections to the canonical three-dimension state model: lifecycle via `is_enabled`, consent via `consent_status`, and verification via `verification_status`
- remove legacy provider status and health badge paths, update admin and system directory surfaces, and align onboarding, consent callback, verification, resolver, and mutation flows with the new model
- add the Spec 188 artifact set, schema migrations, guard coverage, and expanded provider-state tests across admin, system, onboarding, verification, and rendering paths

## Verification
- `cd apps/platform && ./vendor/bin/sail bin pint --dirty --format agent`
- `cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/Auth/SystemPanelAuthTest.php tests/Feature/Filament/TenantGlobalSearchLifecycleScopeTest.php tests/Feature/ProviderConnections/ProviderConnectionEnableDisableTest.php tests/Feature/ProviderConnections/ProviderConnectionTruthCleanupSpec179Test.php`
- integrated browser smoke: validated admin provider list/detail/edit, tenant provider summary, system directory tenant detail, provider-connection search exclusion, and cleaned up the temporary smoke record afterward

## Filament / implementation notes
- Livewire v4.0+ compliance: preserved; this change targets Filament v5 on Livewire v4 and does not introduce older APIs
- Provider registration location: unchanged; Laravel 11+ panel providers remain registered in `bootstrap/providers.php`
- Globally searchable resources: `ProviderConnectionResource` remains intentionally excluded from global search; tenant global search remains enabled and continues to resolve to view pages
- Destructive actions: no new destructive action surface was introduced without confirmation or authorization; existing capability checks continue to gate provider mutations
- Asset strategy: unchanged; no new Filament assets were added, so deploy behavior for `php artisan filament:assets` remains unchanged
- Testing plan covered: system auth, tenant global search, provider lifecycle enable/disable behavior, and provider truth cleanup cutover behavior

Co-authored-by: Ahmed Darrazi <ahmed.darrazi@live.de>
Reviewed-on: #219
2026-04-10 11:22:56 +00:00

142 lines
5.6 KiB
PHP

<?php
declare(strict_types=1);
namespace App\Services\Providers;
use App\Models\ProviderConnection;
use App\Models\ProviderCredential;
use App\Support\Providers\ProviderConnectionType;
use App\Support\Providers\ProviderConsentStatus;
use App\Support\Providers\ProviderReasonCodes;
use App\Support\Providers\ProviderVerificationStatus;
use Illuminate\Support\Facades\DB;
use InvalidArgumentException;
final class ProviderConnectionMutationService
{
public function __construct(private readonly CredentialManager $credentials) {}
public function enableDedicatedOverride(
ProviderConnection $connection,
string $clientId,
string $clientSecret,
): ProviderConnection {
$clientId = trim($clientId);
$clientSecret = trim($clientSecret);
if ($clientId === '' || $clientSecret === '') {
throw new InvalidArgumentException('Dedicated client_id and client_secret are required.');
}
return DB::transaction(function () use ($connection, $clientId, $clientSecret): ProviderConnection {
$connection->loadMissing('credential');
$credential = $connection->credential;
$payload = $credential instanceof ProviderCredential && is_array($credential->payload)
? $credential->payload
: [];
$previousClientId = trim((string) ($payload['client_id'] ?? ''));
$needsConsentReset = $connection->connection_type !== ProviderConnectionType::Dedicated
|| $previousClientId === ''
|| $previousClientId !== $clientId;
$consentStatus = $needsConsentReset
? ProviderConsentStatus::Required
: $this->normalizeConsentStatus($connection->consent_status);
$verificationStatus = ProviderVerificationStatus::Unknown;
$connection->forceFill([
'connection_type' => ProviderConnectionType::Dedicated->value,
'consent_status' => $consentStatus->value,
'verification_status' => $verificationStatus->value,
'last_health_check_at' => null,
'last_error_reason_code' => $needsConsentReset
? ProviderReasonCodes::ProviderConsentMissing
: null,
'last_error_message' => null,
'scopes_granted' => $needsConsentReset ? [] : ($connection->scopes_granted ?? []),
'consent_granted_at' => $needsConsentReset ? null : $connection->consent_granted_at,
'consent_last_checked_at' => $needsConsentReset ? null : $connection->consent_last_checked_at,
'consent_error_code' => null,
'consent_error_message' => null,
])->save();
$this->credentials->upsertClientSecretCredential(
connection: $connection->fresh(),
clientId: $clientId,
clientSecret: $clientSecret,
);
return $connection->fresh(['credential']);
});
}
public function revertToPlatform(ProviderConnection $connection): ProviderConnection
{
return DB::transaction(function () use ($connection): ProviderConnection {
$connection->loadMissing('credential');
if ($connection->credential instanceof ProviderCredential) {
$connection->credential->delete();
}
$connection->forceFill([
'connection_type' => ProviderConnectionType::Platform->value,
'consent_status' => ProviderConsentStatus::Required->value,
'consent_granted_at' => null,
'consent_last_checked_at' => null,
'consent_error_code' => null,
'consent_error_message' => null,
'verification_status' => ProviderVerificationStatus::Unknown->value,
'last_health_check_at' => null,
'last_error_reason_code' => ProviderReasonCodes::ProviderConsentMissing,
'last_error_message' => null,
'scopes_granted' => [],
])->save();
return $connection->fresh(['credential']);
});
}
public function deleteDedicatedCredential(ProviderConnection $connection): ProviderConnection
{
return DB::transaction(function () use ($connection): ProviderConnection {
$connection->loadMissing('credential');
if ($connection->credential instanceof ProviderCredential) {
$connection->credential->delete();
}
$consentStatus = $this->normalizeConsentStatus($connection->consent_status);
$verificationStatus = ProviderVerificationStatus::Blocked;
$connection->forceFill([
'connection_type' => ProviderConnectionType::Dedicated->value,
'consent_status' => $consentStatus->value,
'verification_status' => $verificationStatus->value,
'last_health_check_at' => null,
'last_error_reason_code' => ProviderReasonCodes::DedicatedCredentialMissing,
'last_error_message' => 'Dedicated credential is missing.',
])->save();
return $connection->fresh(['credential']);
});
}
private function normalizeConsentStatus(
ProviderConsentStatus|string|null $consentStatus,
): ProviderConsentStatus {
if ($consentStatus instanceof ProviderConsentStatus) {
return $consentStatus;
}
if (is_string($consentStatus)) {
return ProviderConsentStatus::tryFrom(trim($consentStatus)) ?? ProviderConsentStatus::Required;
}
return ProviderConsentStatus::Required;
}
}