TenantAtlas/apps/platform/app/Services/Providers/ProviderConnectionClassifier.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

119 lines
4.7 KiB
PHP

<?php
namespace App\Services\Providers;
use App\Models\ProviderConnection;
use App\Models\ProviderCredential;
use App\Models\Tenant;
use App\Support\Providers\ProviderConnectionType;
final class ProviderConnectionClassifier
{
public function classify(
ProviderConnection $connection,
string $source = 'migration_scan',
): ProviderConnectionClassificationResult {
$connection->loadMissing(['credential', 'tenant']);
$tenant = $connection->tenant;
$credential = $connection->credential;
$legacyIdentity = $tenant instanceof Tenant ? $tenant->legacyProviderIdentity() : ['client_id' => null, 'has_secret' => false];
$tenantClientId = trim((string) ($legacyIdentity['client_id'] ?? ''));
$credentialClientId = $this->credentialClientId($credential);
$currentConnectionType = $connection->connection_type instanceof ProviderConnectionType
? $connection->connection_type->value
: (is_string($connection->connection_type) ? $connection->connection_type : null);
$hasLegacyTenantIdentity = $tenantClientId !== '' || (bool) ($legacyIdentity['has_secret'] ?? false);
$hasDedicatedCredential = $credential instanceof ProviderCredential;
$suggestedConnectionType = ProviderConnectionType::Platform;
$reviewRequired = false;
if ($hasDedicatedCredential && ! $hasLegacyTenantIdentity) {
$suggestedConnectionType = ProviderConnectionType::Dedicated;
} elseif ($hasDedicatedCredential && $hasLegacyTenantIdentity) {
$suggestedConnectionType = ProviderConnectionType::Dedicated;
$reviewRequired = $tenantClientId === '' || $credentialClientId === '' || $tenantClientId !== $credentialClientId;
} elseif (! $hasDedicatedCredential && $hasLegacyTenantIdentity) {
$suggestedConnectionType = ProviderConnectionType::Platform;
$reviewRequired = true;
}
return new ProviderConnectionClassificationResult(
providerConnectionId: (int) $connection->getKey(),
suggestedConnectionType: $suggestedConnectionType,
reviewRequired: $reviewRequired,
signals: [
'has_dedicated_credential' => $hasDedicatedCredential,
'credential_client_id' => $credentialClientId !== '' ? $credentialClientId : null,
'has_legacy_tenant_identity' => $hasLegacyTenantIdentity,
'tenant_client_id' => $tenantClientId !== '' ? $tenantClientId : null,
'tenant_has_secret' => (bool) ($legacyIdentity['has_secret'] ?? false),
'current_connection_type' => $currentConnectionType,
'is_enabled' => (bool) $connection->is_enabled,
'consent_status' => $this->enumValue($connection->consent_status),
'verification_status' => $this->enumValue($connection->verification_status),
'last_error_reason_code' => is_string($connection->last_error_reason_code) ? $connection->last_error_reason_code : null,
],
effectiveApp: $this->effectiveAppMetadata(
suggestedConnectionType: $suggestedConnectionType,
reviewRequired: $reviewRequired,
credentialClientId: $credentialClientId,
),
source: $source,
);
}
private function credentialClientId(?ProviderCredential $credential): string
{
if (! $credential instanceof ProviderCredential || ! is_array($credential->payload)) {
return '';
}
return trim((string) ($credential->payload['client_id'] ?? ''));
}
/**
* @return array{app_id: ?string, source: string}
*/
private function effectiveAppMetadata(
ProviderConnectionType $suggestedConnectionType,
bool $reviewRequired,
string $credentialClientId,
): array {
if ($reviewRequired) {
return [
'app_id' => null,
'source' => 'review_required',
];
}
if ($suggestedConnectionType === ProviderConnectionType::Dedicated) {
return [
'app_id' => $credentialClientId !== '' ? $credentialClientId : null,
'source' => 'dedicated_credential',
];
}
$platformClientId = trim((string) config('graph.client_id'));
return [
'app_id' => $platformClientId !== '' ? $platformClientId : null,
'source' => 'platform_config',
];
}
private function enumValue(mixed $value): ?string
{
if ($value instanceof \BackedEnum) {
return $value->value;
}
if (is_string($value) && trim($value) !== '') {
return trim($value);
}
return null;
}
}