TenantAtlas/app/Support/Providers/ProviderReasonTranslator.php
ahmido 92f39d9749 feat: add shared reason translation contract (#187)
## Summary
- introduce a shared reason-translation contract with envelopes, presenter helpers, fallback handling, and provider translation support
- adopt translated operator-facing reason presentation across operation runs, notifications, provider guidance, tenant operability, and RBAC-related surfaces
- add Spec 157 design artifacts and targeted regression coverage for translation quality, diagnostics retention, and authorization-safe guidance

## Validation
- `vendor/bin/sail bin pint --dirty --format agent`
- `vendor/bin/sail artisan test --compact tests/Architecture/ReasonTranslationPrimarySurfaceGuardTest.php tests/Unit/Support/ReasonTranslation/ReasonResolutionEnvelopeTest.php tests/Unit/Support/ReasonTranslation/ExecutionDenialReasonTranslationTest.php tests/Unit/Support/ReasonTranslation/TenantOperabilityReasonTranslationTest.php tests/Unit/Support/ReasonTranslation/RbacReasonTranslationTest.php tests/Unit/Support/ReasonTranslation/ProviderReasonTranslationTest.php tests/Feature/Notifications/OperationRunNotificationTest.php tests/Feature/Operations/OperationRunBlockedExecutionPresentationTest.php tests/Feature/Operations/TenantlessOperationRunViewerTest.php tests/Feature/ReasonTranslation/GovernanceReasonPresentationTest.php tests/Feature/Authorization/ReasonTranslationScopeSafetyTest.php tests/Feature/Monitoring/OperationRunBlockedSpec081Test.php tests/Feature/ProviderConnections/ProviderOperationBlockedGuidanceSpec081Test.php tests/Feature/ProviderConnections/ProviderGatewayRuntimeSmokeSpec081Test.php`

## Notes
- Livewire v4.0+ compliance remains unchanged within the existing Filament v5 stack.
- No new panel was added; provider registration remains in `bootstrap/providers.php`.
- No new globally searchable resource was introduced.
- No new destructive action family was introduced.
- No new assets were added; the existing `filament:assets` deployment behavior remains unchanged.

Co-authored-by: Ahmed Darrazi <ahmed.darrazi@live.de>
Reviewed-on: #187
2026-03-22 20:19:43 +00:00

365 lines
18 KiB
PHP

<?php
declare(strict_types=1);
namespace App\Support\Providers;
use App\Filament\Resources\ProviderConnectionResource;
use App\Models\ProviderConnection;
use App\Models\Tenant;
use App\Support\Links\RequiredPermissionsLinks;
use App\Support\ReasonTranslation\Contracts\TranslatesReasonCode;
use App\Support\ReasonTranslation\NextStepOption;
use App\Support\ReasonTranslation\ReasonResolutionEnvelope;
final class ProviderReasonTranslator implements TranslatesReasonCode
{
public const string ARTIFACT_KEY = 'provider_reason_codes';
public function artifactKey(): string
{
return self::ARTIFACT_KEY;
}
public function canTranslate(string $reasonCode): bool
{
return ProviderReasonCodes::isKnown(trim($reasonCode));
}
/**
* @param array<string, mixed> $context
*/
public function translate(string $reasonCode, string $surface = 'detail', array $context = []): ?ReasonResolutionEnvelope
{
$reasonCode = trim($reasonCode);
if ($reasonCode === '') {
return null;
}
$normalizedCode = ProviderReasonCodes::isKnown($reasonCode)
? $reasonCode
: ProviderReasonCodes::UnknownError;
$tenant = $context['tenant'] ?? null;
$connection = $context['connection'] ?? null;
if (! $tenant instanceof Tenant) {
$nextSteps = $this->fallbackNextSteps($normalizedCode);
} else {
$nextSteps = $this->nextStepsFor($tenant, $normalizedCode, $connection instanceof ProviderConnection ? $connection : null);
}
return match ($normalizedCode) {
ProviderReasonCodes::ProviderConnectionMissing => $this->envelope(
reasonCode: $normalizedCode,
operatorLabel: 'Provider connection required',
shortExplanation: 'This tenant does not have a usable provider connection for Microsoft operations.',
actionability: 'prerequisite_missing',
nextSteps: $nextSteps,
),
ProviderReasonCodes::ProviderConnectionInvalid => $this->envelope(
reasonCode: $normalizedCode,
operatorLabel: 'Provider connection needs review',
shortExplanation: 'The selected provider connection is incomplete or no longer valid for this workflow.',
actionability: 'prerequisite_missing',
nextSteps: $nextSteps,
),
ProviderReasonCodes::ProviderCredentialMissing => $this->envelope(
reasonCode: $normalizedCode,
operatorLabel: 'Credentials missing',
shortExplanation: 'The provider connection is missing the credentials required to authenticate.',
actionability: 'prerequisite_missing',
nextSteps: $nextSteps,
),
ProviderReasonCodes::ProviderCredentialInvalid => $this->envelope(
reasonCode: $normalizedCode,
operatorLabel: 'Credentials need review',
shortExplanation: 'Stored provider credentials are no longer valid for the selected provider connection.',
actionability: 'prerequisite_missing',
nextSteps: $nextSteps,
),
ProviderReasonCodes::ProviderConnectionTypeInvalid => $this->envelope(
reasonCode: $normalizedCode,
operatorLabel: 'Connection type unsupported',
shortExplanation: 'The selected provider connection type cannot be used for this workflow.',
actionability: 'permanent_configuration',
nextSteps: $nextSteps,
),
ProviderReasonCodes::PlatformIdentityMissing => $this->envelope(
reasonCode: $normalizedCode,
operatorLabel: 'Platform identity missing',
shortExplanation: 'The platform provider connection is missing the app identity details required to continue.',
actionability: 'prerequisite_missing',
nextSteps: $nextSteps,
),
ProviderReasonCodes::PlatformIdentityIncomplete => $this->envelope(
reasonCode: $normalizedCode,
operatorLabel: 'Platform identity incomplete',
shortExplanation: 'The platform provider connection needs more app identity details before it can continue.',
actionability: 'prerequisite_missing',
nextSteps: $nextSteps,
),
ProviderReasonCodes::DedicatedCredentialMissing => $this->envelope(
reasonCode: $normalizedCode,
operatorLabel: 'Dedicated credentials required',
shortExplanation: 'This dedicated provider connection cannot continue until dedicated credentials are configured.',
actionability: 'prerequisite_missing',
nextSteps: $nextSteps,
),
ProviderReasonCodes::DedicatedCredentialInvalid => $this->envelope(
reasonCode: $normalizedCode,
operatorLabel: 'Dedicated credentials need review',
shortExplanation: 'The dedicated credentials are no longer valid for this provider connection.',
actionability: 'prerequisite_missing',
nextSteps: $nextSteps,
),
ProviderReasonCodes::ProviderConsentMissing => $this->envelope(
reasonCode: $normalizedCode,
operatorLabel: 'Admin consent required',
shortExplanation: 'The provider connection cannot continue until admin consent is granted.',
actionability: 'prerequisite_missing',
nextSteps: $nextSteps,
),
ProviderReasonCodes::ProviderConsentFailed => $this->envelope(
reasonCode: $normalizedCode,
operatorLabel: 'Admin consent check failed',
shortExplanation: 'TenantPilot could not confirm admin consent for this provider connection.',
actionability: 'prerequisite_missing',
nextSteps: $nextSteps,
),
ProviderReasonCodes::ProviderConsentRevoked => $this->envelope(
reasonCode: $normalizedCode,
operatorLabel: 'Admin consent revoked',
shortExplanation: 'Previously granted admin consent is no longer valid for this provider connection.',
actionability: 'prerequisite_missing',
nextSteps: $nextSteps,
),
ProviderReasonCodes::ProviderConnectionReviewRequired => $this->envelope(
reasonCode: $normalizedCode,
operatorLabel: 'Connection classification needs review',
shortExplanation: 'TenantPilot needs you to confirm how this provider connection should be used.',
actionability: 'prerequisite_missing',
nextSteps: $nextSteps,
),
ProviderReasonCodes::ProviderAuthFailed => $this->envelope(
reasonCode: $normalizedCode,
operatorLabel: 'Provider authentication failed',
shortExplanation: 'The provider connection could not authenticate with the stored credentials.',
actionability: 'prerequisite_missing',
nextSteps: $nextSteps,
),
ProviderReasonCodes::ProviderPermissionMissing => $this->envelope(
reasonCode: $normalizedCode,
operatorLabel: 'Permissions missing',
shortExplanation: 'The provider app is missing required Microsoft Graph permissions.',
actionability: 'prerequisite_missing',
nextSteps: $nextSteps,
),
ProviderReasonCodes::ProviderPermissionDenied => $this->envelope(
reasonCode: $normalizedCode,
operatorLabel: 'Permission denied',
shortExplanation: 'Microsoft Graph denied the requested permission for this provider connection.',
actionability: 'permanent_configuration',
nextSteps: $nextSteps,
),
ProviderReasonCodes::ProviderPermissionRefreshFailed => $this->envelope(
reasonCode: $normalizedCode,
operatorLabel: 'Permission refresh failed',
shortExplanation: 'TenantPilot could not refresh the provider permission snapshot.',
actionability: 'retryable_transient',
nextSteps: $nextSteps,
),
ProviderReasonCodes::IntuneRbacPermissionMissing => $this->envelope(
reasonCode: $normalizedCode,
operatorLabel: 'Intune RBAC permission missing',
shortExplanation: 'The provider app lacks the Intune RBAC permission needed for this workflow.',
actionability: 'prerequisite_missing',
nextSteps: $nextSteps,
),
ProviderReasonCodes::TenantTargetMismatch => $this->envelope(
reasonCode: $normalizedCode,
operatorLabel: 'Connection targets a different tenant',
shortExplanation: 'The selected provider connection points to a different Microsoft tenant than the current scope.',
actionability: 'permanent_configuration',
nextSteps: $nextSteps,
),
ProviderReasonCodes::NetworkUnreachable => $this->envelope(
reasonCode: $normalizedCode,
operatorLabel: 'Microsoft Graph temporarily unreachable',
shortExplanation: 'TenantPilot could not reach Microsoft Graph or the provider dependency.',
actionability: 'retryable_transient',
nextSteps: $nextSteps,
),
ProviderReasonCodes::RateLimited => $this->envelope(
reasonCode: $normalizedCode,
operatorLabel: 'Request rate limited',
shortExplanation: 'Microsoft Graph asked TenantPilot to slow down before retrying.',
actionability: 'retryable_transient',
nextSteps: $nextSteps,
),
ProviderReasonCodes::IntuneRbacNotConfigured => $this->envelope(
reasonCode: $normalizedCode,
operatorLabel: 'Intune RBAC not configured',
shortExplanation: 'Intune RBAC has not been configured for this tenant yet.',
actionability: 'prerequisite_missing',
nextSteps: $nextSteps,
),
ProviderReasonCodes::IntuneRbacUnhealthy => $this->envelope(
reasonCode: $normalizedCode,
operatorLabel: 'Intune RBAC health degraded',
shortExplanation: 'The latest Intune RBAC health check found a blocking issue.',
actionability: 'prerequisite_missing',
nextSteps: $nextSteps,
),
ProviderReasonCodes::IntuneRbacStale => $this->envelope(
reasonCode: $normalizedCode,
operatorLabel: 'Intune RBAC check is stale',
shortExplanation: 'The latest Intune RBAC health check is too old to trust for write operations.',
actionability: 'prerequisite_missing',
nextSteps: $nextSteps,
),
default => $this->envelope(
reasonCode: $normalizedCode,
operatorLabel: str_starts_with($normalizedCode, 'ext.')
? 'Provider configuration needs review'
: 'Provider check needs review',
shortExplanation: 'TenantPilot recorded a provider error that does not yet have a domain-specific translation.',
actionability: 'permanent_configuration',
nextSteps: $nextSteps,
),
};
}
/**
* @param array<int, NextStepOption> $nextSteps
*/
private function envelope(
string $reasonCode,
string $operatorLabel,
string $shortExplanation,
string $actionability,
array $nextSteps,
): ReasonResolutionEnvelope {
return new ReasonResolutionEnvelope(
internalCode: $reasonCode,
operatorLabel: $operatorLabel,
shortExplanation: $shortExplanation,
actionability: $actionability,
nextSteps: $nextSteps,
showNoActionNeeded: false,
diagnosticCodeLabel: $reasonCode,
);
}
/**
* @return array<int, NextStepOption>
*/
private function fallbackNextSteps(string $reasonCode): array
{
return match ($reasonCode) {
ProviderReasonCodes::NetworkUnreachable, ProviderReasonCodes::RateLimited => [
NextStepOption::instruction('Retry after the provider dependency recovers.'),
],
ProviderReasonCodes::UnknownError => [
NextStepOption::instruction('Review the provider connection and retry once the cause is understood.'),
],
default => [
NextStepOption::instruction('Review the provider connection before retrying.'),
],
};
}
/**
* @return array<int, NextStepOption>
*/
private function nextStepsFor(
Tenant $tenant,
string $reasonCode,
?ProviderConnection $connection = null,
): array {
return match ($reasonCode) {
ProviderReasonCodes::ProviderConnectionMissing,
ProviderReasonCodes::ProviderConnectionInvalid,
ProviderReasonCodes::TenantTargetMismatch,
ProviderReasonCodes::PlatformIdentityMissing,
ProviderReasonCodes::PlatformIdentityIncomplete,
ProviderReasonCodes::ProviderConnectionReviewRequired => [
NextStepOption::link(
label: $connection instanceof ProviderConnection ? 'Review migration classification' : 'Manage Provider Connections',
destination: $connection instanceof ProviderConnection
? ProviderConnectionResource::getUrl('view', ['tenant' => $tenant->external_id, 'record' => (int) $connection->getKey()], panel: 'admin')
: ProviderConnectionResource::getUrl('index', ['tenant' => $tenant->external_id], panel: 'admin'),
),
NextStepOption::link(
label: 'Review effective app details',
destination: $connection instanceof ProviderConnection
? ProviderConnectionResource::getUrl('edit', ['tenant' => $tenant->external_id, 'record' => (int) $connection->getKey()], panel: 'admin')
: ProviderConnectionResource::getUrl('index', ['tenant' => $tenant->external_id], panel: 'admin'),
),
],
ProviderReasonCodes::DedicatedCredentialMissing,
ProviderReasonCodes::DedicatedCredentialInvalid => [
NextStepOption::link(
label: $connection instanceof ProviderConnection ? 'Manage dedicated connection' : 'Manage Provider Connections',
destination: $connection instanceof ProviderConnection
? ProviderConnectionResource::getUrl('edit', ['tenant' => $tenant->external_id, 'record' => (int) $connection->getKey()], panel: 'admin')
: ProviderConnectionResource::getUrl('index', ['tenant' => $tenant->external_id], panel: 'admin'),
),
],
ProviderReasonCodes::ProviderCredentialMissing,
ProviderReasonCodes::ProviderCredentialInvalid,
ProviderReasonCodes::ProviderConsentFailed,
ProviderReasonCodes::ProviderConsentRevoked,
ProviderReasonCodes::ProviderAuthFailed,
ProviderReasonCodes::ProviderConsentMissing => [
NextStepOption::link(
label: 'Grant admin consent',
destination: RequiredPermissionsLinks::adminConsentPrimaryUrl($tenant),
),
NextStepOption::link(
label: $connection instanceof ProviderConnection
? ($connection->connection_type === ProviderConnectionType::Dedicated ? 'Manage dedicated connection' : 'Review platform connection')
: 'Manage Provider Connections',
destination: $connection instanceof ProviderConnection
? ProviderConnectionResource::getUrl('edit', ['tenant' => $tenant->external_id, 'record' => (int) $connection->getKey()], panel: 'admin')
: ProviderConnectionResource::getUrl('index', ['tenant' => $tenant->external_id], panel: 'admin'),
),
],
ProviderReasonCodes::ProviderPermissionMissing,
ProviderReasonCodes::ProviderPermissionDenied,
ProviderReasonCodes::ProviderPermissionRefreshFailed,
ProviderReasonCodes::IntuneRbacPermissionMissing => [
NextStepOption::link(
label: 'Open Required Permissions',
destination: RequiredPermissionsLinks::requiredPermissions($tenant),
),
],
ProviderReasonCodes::IntuneRbacNotConfigured,
ProviderReasonCodes::IntuneRbacUnhealthy,
ProviderReasonCodes::IntuneRbacStale => [
NextStepOption::link(
label: 'Review provider connections',
destination: ProviderConnectionResource::getUrl('index', ['tenant' => $tenant->external_id], panel: 'admin'),
),
NextStepOption::instruction('Refresh the tenant RBAC health check before retrying.', scope: 'tenant'),
],
ProviderReasonCodes::NetworkUnreachable,
ProviderReasonCodes::RateLimited => [
NextStepOption::instruction('Retry after the provider dependency recovers.', scope: 'tenant'),
NextStepOption::link(
label: 'Review provider connection',
destination: $connection instanceof ProviderConnection
? ProviderConnectionResource::getUrl('edit', ['tenant' => $tenant->external_id, 'record' => (int) $connection->getKey()], panel: 'admin')
: ProviderConnectionResource::getUrl('index', ['tenant' => $tenant->external_id], panel: 'admin'),
),
],
default => [
NextStepOption::link(
label: 'Manage Provider Connections',
destination: ProviderConnectionResource::getUrl('index', ['tenant' => $tenant->external_id], panel: 'admin'),
),
],
};
}
}