TenantAtlas/app/Services/Tenants/TenantActionPolicySurface.php
ahmido 440e63edff feat: implement tenant action taxonomy lifecycle visibility (#174)
## Summary

Implements Spec 145 for tenant action taxonomy and lifecycle-safe visibility.

This PR:
- adds a central tenant action policy surface and supporting value objects
- aligns tenant list, detail, edit, onboarding, and widget surfaces around lifecycle-safe actions
- standardizes operator-facing lifecycle wording around View, Resume onboarding, Archive, Restore, and Complete onboarding
- tightens onboarding and tenant lifecycle authorization semantics, including honest 404 vs 403 behavior
- updates related regression coverage and spec artifacts for Spec 145
- fixes follow-on full-suite regressions uncovered during validation, including onboarding browser flows, provider consent fixtures, workspace redirect DI expectations, and critical table/action/UI expectation drift

## Validation

Executed and passed:
- vendor/bin/sail bin pint --dirty --format agent
- vendor/bin/sail artisan test --compact

Result:
- 2581 passed
- 8 skipped
- 13534 assertions

## Notes

- Base branch: dev
- Feature branch commit: a33a41b
- Filament v5 / Livewire v4 compliance preserved
- No panel provider registration changes; Laravel 12 provider registration remains in bootstrap/providers.php
- No new globally searchable resource behavior added in this slice
- Destructive lifecycle actions remain confirmation-gated and authorization-protected

Co-authored-by: Ahmed Darrazi <ahmed.darrazi@live.de>
Reviewed-on: #174
2026-03-16 00:57:17 +00:00

274 lines
10 KiB
PHP

<?php
declare(strict_types=1);
namespace App\Services\Tenants;
use App\Models\Tenant;
use App\Models\TenantOnboardingSession;
use App\Models\User;
use App\Services\Onboarding\OnboardingLifecycleService;
use App\Support\Audit\AuditActionId;
use App\Support\Tenants\TenantActionContext;
use App\Support\Tenants\TenantActionDescriptor;
use App\Support\Tenants\TenantActionFamily;
use App\Support\Tenants\TenantActionSurface;
use Illuminate\Support\Facades\Gate;
class TenantActionPolicySurface
{
public function __construct(
private readonly TenantOperabilityService $tenantOperabilityService,
private readonly OnboardingLifecycleService $onboardingLifecycleService,
) {}
public function buildContext(Tenant $tenant, TenantActionSurface $surface, ?User $user = null): TenantActionContext
{
$user ??= auth()->user();
$draft = $user instanceof User ? $this->relatedOnboardingDraft($tenant, $user) : null;
$lifecycle = $this->tenantOperabilityService->lifecycleFor($tenant);
return new TenantActionContext(
tenant: $tenant,
lifecycle: $lifecycle,
surface: $surface,
relatedOnboardingDraft: $draft,
relatedOnboardingIsResumable: $draft instanceof TenantOnboardingSession
&& $this->onboardingLifecycleService->canResumeDraft($draft),
hasRelatedOnboardingDraft: $draft instanceof TenantOnboardingSession,
isArchived: $tenant->trashed() || $this->tenantOperabilityService->canRestore($tenant),
);
}
/**
* @return list<TenantActionDescriptor>
*/
public function catalogForTenant(Tenant $tenant, TenantActionSurface $surface, ?User $user = null): array
{
$context = $this->buildContext($tenant, $surface, $user);
$actions = [
$this->viewAction(),
];
$primaryAction = $this->primaryActionForContext($context);
if ($primaryAction instanceof TenantActionDescriptor) {
$actions[] = $primaryAction;
}
$secondaryActions = [
$this->secondaryRelatedOnboardingActionForContext($context, $primaryAction),
$this->secondaryLifecycleActionForContext($context, $primaryAction),
];
foreach ($secondaryActions as $secondaryAction) {
if ($secondaryAction instanceof TenantActionDescriptor) {
$actions[] = $secondaryAction;
}
}
return array_values(array_filter($actions, static fn (mixed $action): bool => $action instanceof TenantActionDescriptor && $action->visible));
}
public function lifecycleActionForTenant(Tenant $tenant): ?TenantActionDescriptor
{
return $this->lifecycleActionForContext($this->buildContext($tenant, TenantActionSurface::ContextMenu));
}
public function relatedOnboardingActionForTenant(Tenant $tenant, TenantActionSurface $surface, ?User $user = null): ?TenantActionDescriptor
{
return $this->relatedOnboardingActionForContext($this->buildContext($tenant, $surface, $user));
}
public function onboardingEntryDescriptor(int $resumableDraftCount): TenantActionDescriptor
{
return match (true) {
$resumableDraftCount === 1 => new TenantActionDescriptor(
key: 'resume_onboarding',
family: TenantActionFamily::OnboardingWorkflow,
label: 'Resume onboarding',
icon: 'heroicon-m-arrow-path',
group: 'primary',
),
$resumableDraftCount > 1 => new TenantActionDescriptor(
key: 'choose_onboarding_draft',
family: TenantActionFamily::OnboardingWorkflow,
label: 'Choose onboarding draft',
icon: 'heroicon-m-queue-list',
group: 'primary',
),
default => new TenantActionDescriptor(
key: 'add_tenant',
family: TenantActionFamily::OnboardingWorkflow,
label: 'Add tenant',
icon: 'heroicon-m-plus',
group: 'primary',
),
};
}
public function relatedOnboardingDraft(Tenant $tenant, ?User $user = null): ?TenantOnboardingSession
{
$user ??= auth()->user();
if (! $user instanceof User) {
return null;
}
return TenantOnboardingSession::query()
->where('workspace_id', (int) $tenant->workspace_id)
->where('tenant_id', (int) $tenant->getKey())
->orderByDesc('updated_at')
->get()
->first(fn (TenantOnboardingSession $draft): bool => Gate::forUser($user)->allows('view', $draft));
}
private function viewAction(): TenantActionDescriptor
{
return new TenantActionDescriptor(
key: 'view',
family: TenantActionFamily::Neutral,
label: 'View',
icon: 'heroicon-o-eye',
group: 'primary',
);
}
private function primaryActionForContext(TenantActionContext $context): ?TenantActionDescriptor
{
if ($context->relatedOnboardingIsResumable && $context->lifecycle->canResumeOnboarding()) {
return $this->relatedOnboardingActionForContext($context, 'primary');
}
return $this->lifecycleActionForContext($context, 'primary');
}
private function secondaryLifecycleActionForContext(
TenantActionContext $context,
?TenantActionDescriptor $primaryAction,
): ?TenantActionDescriptor {
$action = $this->lifecycleActionForContext($context);
if (! $action instanceof TenantActionDescriptor) {
return null;
}
if ($primaryAction instanceof TenantActionDescriptor && $primaryAction->key === $action->key) {
return null;
}
return $action;
}
private function secondaryRelatedOnboardingActionForContext(
TenantActionContext $context,
?TenantActionDescriptor $primaryAction,
): ?TenantActionDescriptor {
$action = $this->relatedOnboardingActionForContext($context);
if (! $action instanceof TenantActionDescriptor) {
return null;
}
if ($primaryAction instanceof TenantActionDescriptor && $primaryAction->key === $action->key) {
return null;
}
return $action;
}
private function lifecycleActionForContext(
TenantActionContext $context,
string $group = 'overflow',
): ?TenantActionDescriptor {
if (! $context->isGenericTenantManagementSurface()) {
return null;
}
if ($this->tenantOperabilityService->canRestore($context->tenant)) {
return new TenantActionDescriptor(
key: 'restore',
family: TenantActionFamily::LifecycleManagement,
label: 'Restore',
icon: 'heroicon-o-arrow-uturn-left',
destructive: true,
requiresConfirmation: true,
auditActionId: AuditActionId::TenantRestored,
successNotificationTitle: 'Tenant restored',
successNotificationBody: 'The tenant is available again in normal tenant management flows and can be selected as active context.',
modalHeading: 'Restore tenant',
modalDescription: 'Restore this archived tenant so it can be selected again in normal tenant management flows.',
group: $group,
);
}
if ($this->tenantOperabilityService->canArchive($context->tenant)) {
return new TenantActionDescriptor(
key: 'archive',
family: TenantActionFamily::LifecycleManagement,
label: 'Archive',
icon: 'heroicon-o-archive-box-x-mark',
destructive: true,
requiresConfirmation: true,
auditActionId: AuditActionId::TenantArchived,
successNotificationTitle: 'Tenant archived',
successNotificationBody: 'The tenant remains available for inspection and audit history, but it is no longer selectable as active context.',
modalHeading: 'Archive tenant',
modalDescription: 'Archive this tenant to retain it for inspection and audit history while removing it from active management flows.',
group: $group,
);
}
return null;
}
private function relatedOnboardingActionForContext(
TenantActionContext $context,
string $group = 'overflow',
): ?TenantActionDescriptor {
$draft = $context->relatedOnboardingDraft;
if (! $draft instanceof TenantOnboardingSession) {
return null;
}
if ($context->relatedOnboardingIsResumable && $context->lifecycle->canResumeOnboarding()) {
return new TenantActionDescriptor(
key: 'related_onboarding',
family: TenantActionFamily::OnboardingWorkflow,
label: 'Resume onboarding',
icon: 'heroicon-o-arrow-path',
group: $group,
);
}
if ($draft->isCancelled()) {
return new TenantActionDescriptor(
key: 'related_onboarding',
family: TenantActionFamily::OnboardingWorkflow,
label: 'View cancelled onboarding',
icon: 'heroicon-o-eye',
group: $group,
);
}
if ($draft->isCompleted()) {
return new TenantActionDescriptor(
key: 'related_onboarding',
family: TenantActionFamily::OnboardingWorkflow,
label: 'View completed onboarding',
icon: 'heroicon-o-eye',
group: $group,
);
}
return new TenantActionDescriptor(
key: 'related_onboarding',
family: TenantActionFamily::OnboardingWorkflow,
label: 'View related onboarding',
icon: 'heroicon-o-eye',
group: $group,
);
}
}