Compare commits
4 Commits
dev
...
246-suppor
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3ca722a125 | ||
|
|
65ec1d5904 | ||
|
|
f05857c276 | ||
|
|
9f5d3293c5 |
8
.github/agents/copilot-instructions.md
vendored
8
.github/agents/copilot-instructions.md
vendored
@ -260,10 +260,6 @@ ## Active Technologies
|
|||||||
- PostgreSQL via existing `managed_tenant_onboarding_sessions`, `provider_connections`, `operation_runs`, and stored permission-posture data; no new persistence planned (240-tenant-onboarding-readiness)
|
- PostgreSQL via existing `managed_tenant_onboarding_sessions`, `provider_connections`, `operation_runs`, and stored permission-posture data; no new persistence planned (240-tenant-onboarding-readiness)
|
||||||
- PHP 8.4 (Laravel 12) + Laravel 12 + Filament v5 + Livewire v4 + Pest; existing `OperationRunLinks`, `GovernanceRunDiagnosticSummaryBuilder`, `ProviderReasonTranslator`, `RelatedNavigationResolver`, `RedactionIntegrity`, `WorkspaceAuditLogger` (241-support-diagnostic-pack)
|
- PHP 8.4 (Laravel 12) + Laravel 12 + Filament v5 + Livewire v4 + Pest; existing `OperationRunLinks`, `GovernanceRunDiagnosticSummaryBuilder`, `ProviderReasonTranslator`, `RelatedNavigationResolver`, `RedactionIntegrity`, `WorkspaceAuditLogger` (241-support-diagnostic-pack)
|
||||||
- PostgreSQL via existing `operation_runs`, `provider_connections`, `findings`, `stored_reports`, `tenant_reviews`, `review_packs`, and `audit_logs`; no new persistence planned (241-support-diagnostic-pack)
|
- PostgreSQL via existing `operation_runs`, `provider_connections`, `findings`, `stored_reports`, `tenant_reviews`, `review_packs`, and `audit_logs`; no new persistence planned (241-support-diagnostic-pack)
|
||||||
- PHP 8.4, Laravel 12 + Filament v5, Livewire v4, Pest v4, existing review/evidence/review-pack/audit/RBAC support services (249-customer-review-workspace)
|
|
||||||
- PostgreSQL via existing `tenant_reviews`, `review_packs`, `evidence_snapshots`, findings / finding-exception truth, workspace memberships, and `audit_logs`; no new persistence planned (249-customer-review-workspace)
|
|
||||||
- PHP 8.4 (Laravel 12) + Filament v5 + Livewire v4, existing workspace settings stack (`SettingsRegistry`, `SettingsResolver`, `SettingsWriter`), `WorkspaceEntitlementResolver`, `ReviewPackService`, system directory detail page (251-commercial-entitlements-billing-state)
|
|
||||||
- PostgreSQL via existing `workspace_settings` rows plus existing audit log records; no new table or billing/account model (251-commercial-entitlements-billing-state)
|
|
||||||
|
|
||||||
- PHP 8.4.15 (feat/005-bulk-operations)
|
- PHP 8.4.15 (feat/005-bulk-operations)
|
||||||
|
|
||||||
@ -298,9 +294,9 @@ ## Code Style
|
|||||||
PHP 8.4.15: Follow standard conventions
|
PHP 8.4.15: Follow standard conventions
|
||||||
|
|
||||||
## Recent Changes
|
## Recent Changes
|
||||||
- 251-commercial-entitlements-billing-state: Added PHP 8.4 (Laravel 12) + Filament v5 + Livewire v4, existing workspace settings stack (`SettingsRegistry`, `SettingsResolver`, `SettingsWriter`), `WorkspaceEntitlementResolver`, `ReviewPackService`, system directory detail page
|
|
||||||
- 249-customer-review-workspace: Added PHP 8.4, Laravel 12 + Filament v5, Livewire v4, Pest v4, existing review/evidence/review-pack/audit/RBAC support services
|
|
||||||
- 241-support-diagnostic-pack: Added PHP 8.4 (Laravel 12) + Laravel 12 + Filament v5 + Livewire v4 + Pest; existing `OperationRunLinks`, `GovernanceRunDiagnosticSummaryBuilder`, `ProviderReasonTranslator`, `RelatedNavigationResolver`, `RedactionIntegrity`, `WorkspaceAuditLogger`
|
- 241-support-diagnostic-pack: Added PHP 8.4 (Laravel 12) + Laravel 12 + Filament v5 + Livewire v4 + Pest; existing `OperationRunLinks`, `GovernanceRunDiagnosticSummaryBuilder`, `ProviderReasonTranslator`, `RelatedNavigationResolver`, `RedactionIntegrity`, `WorkspaceAuditLogger`
|
||||||
|
- 240-tenant-onboarding-readiness: Added PHP 8.4 (Laravel 12) + Laravel 12 + Filament v5 + Livewire v4 + Pest; existing onboarding services (`OnboardingLifecycleService`, `OnboardingDraftStageResolver`), provider connection summary, verification assist, and Ops-UX helpers
|
||||||
|
- 239-canonical-operation-type-source-of-truth: Added PHP 8.4.15, Laravel 12, Filament v5, Livewire v4 + existing `App\Support\OperationCatalog`, `App\Support\OperationRunType`, `App\Services\OperationRunService`, `App\Services\Providers\ProviderOperationRegistry`, `App\Services\Providers\ProviderOperationStartGate`, `App\Filament\Resources\OperationRunResource`, `App\Filament\Pages\Workspaces\ManagedTenantOnboardingWizard`, `App\Support\Filament\FilterOptionCatalog`, `App\Support\OpsUx\OperationUxPresenter`, `App\Support\References\Resolvers\OperationRunReferenceResolver`, `App\Services\Audit\AuditEventBuilder`, Pest v4
|
||||||
<!-- MANUAL ADDITIONS START -->
|
<!-- MANUAL ADDITIONS START -->
|
||||||
|
|
||||||
### Pre-production compatibility check
|
### Pre-production compatibility check
|
||||||
|
|||||||
@ -1,24 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
namespace App\Exceptions\Entitlements;
|
|
||||||
|
|
||||||
final class WorkspaceEntitlementBlockedException extends \RuntimeException
|
|
||||||
{
|
|
||||||
/**
|
|
||||||
* @param array<string, mixed> $decision
|
|
||||||
*/
|
|
||||||
public function __construct(private readonly array $decision)
|
|
||||||
{
|
|
||||||
parent::__construct((string) ($decision['block_reason'] ?? 'Workspace entitlement currently blocks this action.'));
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @return array<string, mixed>
|
|
||||||
*/
|
|
||||||
public function decision(): array
|
|
||||||
{
|
|
||||||
return $this->decision;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,494 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
namespace App\Filament\Pages\Governance;
|
|
||||||
|
|
||||||
use App\Filament\Pages\Findings\FindingsIntakeQueue;
|
|
||||||
use App\Filament\Pages\Findings\MyFindingsInbox;
|
|
||||||
use App\Models\Tenant;
|
|
||||||
use App\Models\User;
|
|
||||||
use App\Models\Workspace;
|
|
||||||
use App\Services\Auth\CapabilityResolver;
|
|
||||||
use App\Services\Auth\WorkspaceCapabilityResolver;
|
|
||||||
use App\Services\TenantReviews\TenantReviewRegisterService;
|
|
||||||
use App\Support\Auth\Capabilities;
|
|
||||||
use App\Support\GovernanceInbox\GovernanceInboxSectionBuilder;
|
|
||||||
use App\Support\Navigation\CanonicalNavigationContext;
|
|
||||||
use App\Support\Ui\ActionSurface\ActionSurfaceDeclaration;
|
|
||||||
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceInspectAffordance;
|
|
||||||
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceProfile;
|
|
||||||
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceSlot;
|
|
||||||
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceType;
|
|
||||||
use App\Support\Workspaces\WorkspaceContext;
|
|
||||||
use BackedEnum;
|
|
||||||
use Filament\Facades\Filament;
|
|
||||||
use Filament\Pages\Page;
|
|
||||||
use Illuminate\Support\Str;
|
|
||||||
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
|
|
||||||
use UnitEnum;
|
|
||||||
|
|
||||||
class GovernanceInbox extends Page
|
|
||||||
{
|
|
||||||
protected static bool $isDiscovered = false;
|
|
||||||
|
|
||||||
protected static string|BackedEnum|null $navigationIcon = 'heroicon-o-inbox-stack';
|
|
||||||
|
|
||||||
protected static string|UnitEnum|null $navigationGroup = 'Governance';
|
|
||||||
|
|
||||||
protected static ?string $navigationLabel = 'Governance inbox';
|
|
||||||
|
|
||||||
protected static ?int $navigationSort = 5;
|
|
||||||
|
|
||||||
protected static ?string $title = 'Governance inbox';
|
|
||||||
|
|
||||||
protected static ?string $slug = 'governance/inbox';
|
|
||||||
|
|
||||||
protected string $view = 'filament.pages.governance.governance-inbox';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @var array<int, Tenant>|null
|
|
||||||
*/
|
|
||||||
private ?array $authorizedTenants = null;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @var array<int, Tenant>|null
|
|
||||||
*/
|
|
||||||
private ?array $visibleFindingTenants = null;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @var array<int, Tenant>|null
|
|
||||||
*/
|
|
||||||
private ?array $reviewTenants = null;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @var array<string, mixed>|null
|
|
||||||
*/
|
|
||||||
private ?array $inboxPayload = null;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @var array<string, mixed>|null
|
|
||||||
*/
|
|
||||||
private ?array $unfilteredInboxPayload = null;
|
|
||||||
|
|
||||||
private ?Workspace $workspace = null;
|
|
||||||
|
|
||||||
private ?bool $visibleAlertsFamily = null;
|
|
||||||
|
|
||||||
public ?int $tenantId = null;
|
|
||||||
|
|
||||||
public ?string $family = null;
|
|
||||||
|
|
||||||
public static function actionSurfaceDeclaration(): ActionSurfaceDeclaration
|
|
||||||
{
|
|
||||||
return ActionSurfaceDeclaration::forPage(ActionSurfaceProfile::ListOnlyReadOnly, ActionSurfaceType::ReadOnlyRegistryReport)
|
|
||||||
->satisfy(ActionSurfaceSlot::ListHeader, 'Header actions keep the workspace decision surface calm and read-only.')
|
|
||||||
->satisfy(ActionSurfaceSlot::InspectAffordance, ActionSurfaceInspectAffordance::ClickableRow->value)
|
|
||||||
->exempt(ActionSurfaceSlot::ListRowMoreMenu, 'The governance inbox routes into existing source surfaces instead of exposing row-level secondary actions.')
|
|
||||||
->exempt(ActionSurfaceSlot::ListBulkMoreGroup, 'The governance inbox does not expose bulk actions.')
|
|
||||||
->satisfy(ActionSurfaceSlot::ListEmptyState, 'Empty states stay calm and capability-safe when no visible attention exists.')
|
|
||||||
->exempt(ActionSurfaceSlot::DetailHeader, 'The governance inbox owns no local detail surface in v1.');
|
|
||||||
}
|
|
||||||
|
|
||||||
public function mount(): void
|
|
||||||
{
|
|
||||||
$this->authorizeWorkspaceMembership();
|
|
||||||
$this->applyRequestedTenantPrefilter();
|
|
||||||
$this->family = $this->resolveRequestedFamily();
|
|
||||||
$this->ensureAtLeastOneVisibleFamily();
|
|
||||||
$this->ensureRequestedFamilyIsVisible();
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @return array<string, mixed>
|
|
||||||
*/
|
|
||||||
public function appliedScope(): array
|
|
||||||
{
|
|
||||||
$selectedTenant = $this->selectedTenant();
|
|
||||||
$availableFamilies = collect($this->availableFamilies())
|
|
||||||
->keyBy('key');
|
|
||||||
|
|
||||||
return [
|
|
||||||
'workspace_label' => $this->workspace()?->name,
|
|
||||||
'tenant_label' => $selectedTenant?->name,
|
|
||||||
'tenant_prefilter_source' => $selectedTenant instanceof Tenant ? 'explicit_filter' : 'none',
|
|
||||||
'family_key' => $this->family,
|
|
||||||
'family_label' => $this->family !== null
|
|
||||||
? ($availableFamilies->get($this->family)['label'] ?? Str::headline($this->family))
|
|
||||||
: 'All attention',
|
|
||||||
'total_count' => (int) ($this->inboxPayload()['total_count'] ?? 0),
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @return list<array{key: string, label: string, count: int}>
|
|
||||||
*/
|
|
||||||
public function availableFamilies(): array
|
|
||||||
{
|
|
||||||
return $this->inboxPayload()['available_families'] ?? [];
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @return list<array<string, mixed>>
|
|
||||||
*/
|
|
||||||
public function sections(): array
|
|
||||||
{
|
|
||||||
return $this->inboxPayload()['sections'] ?? [];
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @return array<string, mixed>
|
|
||||||
*/
|
|
||||||
public function calmEmptyState(): array
|
|
||||||
{
|
|
||||||
if ($this->tenantFilterAloneExcludesRows()) {
|
|
||||||
return [
|
|
||||||
'title' => 'This tenant filter is hiding other visible attention',
|
|
||||||
'body' => 'The current tenant scope is calm, but other visible tenants in this workspace still have governance attention.',
|
|
||||||
'action_label' => 'Clear tenant filter',
|
|
||||||
'action_url' => $this->pageUrl(['tenant' => null]),
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
return [
|
|
||||||
'title' => 'No visible governance attention right now',
|
|
||||||
'body' => 'The current workspace scope is calm across the visible governance families.',
|
|
||||||
'action_label' => null,
|
|
||||||
'action_url' => null,
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
public function hasTenantPrefilter(): bool
|
|
||||||
{
|
|
||||||
return $this->selectedTenant() instanceof Tenant;
|
|
||||||
}
|
|
||||||
|
|
||||||
public function isActiveFamily(?string $familyKey): bool
|
|
||||||
{
|
|
||||||
return $this->family === $familyKey;
|
|
||||||
}
|
|
||||||
|
|
||||||
public function pageUrl(array $overrides = []): string
|
|
||||||
{
|
|
||||||
$selectedTenant = $this->selectedTenant();
|
|
||||||
$resolvedTenant = array_key_exists('tenant', $overrides)
|
|
||||||
? $overrides['tenant']
|
|
||||||
: ($selectedTenant?->getKey() !== null ? (string) $selectedTenant->getKey() : null);
|
|
||||||
$resolvedFamily = array_key_exists('family', $overrides)
|
|
||||||
? $overrides['family']
|
|
||||||
: $this->family;
|
|
||||||
|
|
||||||
return static::getUrl(
|
|
||||||
panel: 'admin',
|
|
||||||
parameters: array_filter([
|
|
||||||
'tenant_id' => is_string($resolvedTenant) && $resolvedTenant !== '' ? $resolvedTenant : null,
|
|
||||||
'family' => is_string($resolvedFamily) && $resolvedFamily !== '' ? $resolvedFamily : null,
|
|
||||||
], static fn (mixed $value): bool => $value !== null && $value !== ''),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function navigationContext(): CanonicalNavigationContext
|
|
||||||
{
|
|
||||||
return new CanonicalNavigationContext(
|
|
||||||
sourceSurface: 'governance.inbox',
|
|
||||||
canonicalRouteName: static::getRouteName(Filament::getPanel('admin')),
|
|
||||||
tenantId: $this->tenantId,
|
|
||||||
backLinkLabel: 'Back to governance inbox',
|
|
||||||
backLinkUrl: $this->pageUrl(),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
private function authorizeWorkspaceMembership(): void
|
|
||||||
{
|
|
||||||
$user = auth()->user();
|
|
||||||
$workspace = $this->workspace();
|
|
||||||
|
|
||||||
if (! $user instanceof User) {
|
|
||||||
abort(403);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (! $workspace instanceof Workspace) {
|
|
||||||
throw new NotFoundHttpException;
|
|
||||||
}
|
|
||||||
|
|
||||||
$resolver = app(WorkspaceCapabilityResolver::class);
|
|
||||||
|
|
||||||
if (! $resolver->isMember($user, $workspace)) {
|
|
||||||
throw new NotFoundHttpException;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private function ensureAtLeastOneVisibleFamily(): void
|
|
||||||
{
|
|
||||||
if (
|
|
||||||
$this->hasVisibleOperationsFamily()
|
|
||||||
|| $this->visibleFindingTenants() !== []
|
|
||||||
|| $this->reviewTenants() !== []
|
|
||||||
|| $this->hasVisibleAlertsFamily()
|
|
||||||
) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
abort(403);
|
|
||||||
}
|
|
||||||
|
|
||||||
private function ensureRequestedFamilyIsVisible(): void
|
|
||||||
{
|
|
||||||
if ($this->family === null) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (in_array($this->family, collect($this->availableFamilies())->pluck('key')->all(), true)) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
throw new NotFoundHttpException;
|
|
||||||
}
|
|
||||||
|
|
||||||
private function hasVisibleOperationsFamily(): bool
|
|
||||||
{
|
|
||||||
return $this->authorizedTenants() !== [];
|
|
||||||
}
|
|
||||||
|
|
||||||
private function hasVisibleAlertsFamily(): bool
|
|
||||||
{
|
|
||||||
if (is_bool($this->visibleAlertsFamily)) {
|
|
||||||
return $this->visibleAlertsFamily;
|
|
||||||
}
|
|
||||||
|
|
||||||
$user = auth()->user();
|
|
||||||
$workspace = $this->workspace();
|
|
||||||
|
|
||||||
if (! $user instanceof User || ! $workspace instanceof Workspace) {
|
|
||||||
return $this->visibleAlertsFamily = false;
|
|
||||||
}
|
|
||||||
|
|
||||||
return $this->visibleAlertsFamily = app(WorkspaceCapabilityResolver::class)->can($user, $workspace, Capabilities::ALERTS_VIEW);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @return array<int, Tenant>
|
|
||||||
*/
|
|
||||||
private function visibleFindingTenants(): array
|
|
||||||
{
|
|
||||||
if ($this->visibleFindingTenants !== null) {
|
|
||||||
return $this->visibleFindingTenants;
|
|
||||||
}
|
|
||||||
|
|
||||||
$user = auth()->user();
|
|
||||||
$tenants = $this->authorizedTenants();
|
|
||||||
|
|
||||||
if (! $user instanceof User || $tenants === []) {
|
|
||||||
return $this->visibleFindingTenants = [];
|
|
||||||
}
|
|
||||||
|
|
||||||
$resolver = app(CapabilityResolver::class);
|
|
||||||
$resolver->primeMemberships(
|
|
||||||
$user,
|
|
||||||
array_map(static fn (Tenant $tenant): int => (int) $tenant->getKey(), $tenants),
|
|
||||||
);
|
|
||||||
|
|
||||||
return $this->visibleFindingTenants = array_values(array_filter(
|
|
||||||
$tenants,
|
|
||||||
fn (Tenant $tenant): bool => $resolver->can($user, $tenant, Capabilities::TENANT_FINDINGS_VIEW),
|
|
||||||
));
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @return array<int, Tenant>
|
|
||||||
*/
|
|
||||||
private function reviewTenants(): array
|
|
||||||
{
|
|
||||||
if ($this->reviewTenants !== null) {
|
|
||||||
return $this->reviewTenants;
|
|
||||||
}
|
|
||||||
|
|
||||||
$user = auth()->user();
|
|
||||||
$workspace = $this->workspace();
|
|
||||||
|
|
||||||
if (! $user instanceof User || ! $workspace instanceof Workspace) {
|
|
||||||
return $this->reviewTenants = [];
|
|
||||||
}
|
|
||||||
|
|
||||||
$service = app(TenantReviewRegisterService::class);
|
|
||||||
|
|
||||||
if (! $service->canAccessWorkspace($user, $workspace)) {
|
|
||||||
return $this->reviewTenants = [];
|
|
||||||
}
|
|
||||||
|
|
||||||
return $this->reviewTenants = $service->authorizedTenants($user, $workspace);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @return array<int, Tenant>
|
|
||||||
*/
|
|
||||||
private function authorizedTenants(): array
|
|
||||||
{
|
|
||||||
if ($this->authorizedTenants !== null) {
|
|
||||||
return $this->authorizedTenants;
|
|
||||||
}
|
|
||||||
|
|
||||||
$user = auth()->user();
|
|
||||||
$workspace = $this->workspace();
|
|
||||||
|
|
||||||
if (! $user instanceof User || ! $workspace instanceof Workspace) {
|
|
||||||
return $this->authorizedTenants = [];
|
|
||||||
}
|
|
||||||
|
|
||||||
return $this->authorizedTenants = $user->tenants()
|
|
||||||
->where('tenants.workspace_id', (int) $workspace->getKey())
|
|
||||||
->where('tenants.status', 'active')
|
|
||||||
->orderBy('tenants.name')
|
|
||||||
->get(['tenants.id', 'tenants.name', 'tenants.external_id', 'tenants.workspace_id'])
|
|
||||||
->all();
|
|
||||||
}
|
|
||||||
|
|
||||||
private function applyRequestedTenantPrefilter(): void
|
|
||||||
{
|
|
||||||
$requestedTenant = request()->query('tenant_id', request()->query('tenant'));
|
|
||||||
|
|
||||||
if (! is_string($requestedTenant) && ! is_numeric($requestedTenant)) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
foreach ($this->authorizedTenants() as $tenant) {
|
|
||||||
if ((string) $tenant->getKey() !== (string) $requestedTenant && (string) $tenant->external_id !== (string) $requestedTenant) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
$this->tenantId = (int) $tenant->getKey();
|
|
||||||
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
throw new NotFoundHttpException;
|
|
||||||
}
|
|
||||||
|
|
||||||
private function resolveRequestedFamily(): ?string
|
|
||||||
{
|
|
||||||
$family = request()->query('family');
|
|
||||||
|
|
||||||
if (! is_string($family)) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return in_array($family, [
|
|
||||||
'assigned_findings',
|
|
||||||
'intake_findings',
|
|
||||||
'stale_operations',
|
|
||||||
'alert_delivery_failures',
|
|
||||||
'review_follow_up',
|
|
||||||
], true) ? $family : null;
|
|
||||||
}
|
|
||||||
|
|
||||||
private function workspace(): ?Workspace
|
|
||||||
{
|
|
||||||
if ($this->workspace instanceof Workspace) {
|
|
||||||
return $this->workspace;
|
|
||||||
}
|
|
||||||
|
|
||||||
$workspaceId = app(WorkspaceContext::class)->currentWorkspaceId(request());
|
|
||||||
|
|
||||||
if (! is_int($workspaceId)) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return $this->workspace = Workspace::query()->whereKey($workspaceId)->first();
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @return array<string, mixed>
|
|
||||||
*/
|
|
||||||
private function inboxPayload(): array
|
|
||||||
{
|
|
||||||
if (is_array($this->inboxPayload)) {
|
|
||||||
return $this->inboxPayload;
|
|
||||||
}
|
|
||||||
|
|
||||||
$user = auth()->user();
|
|
||||||
$workspace = $this->workspace();
|
|
||||||
|
|
||||||
if (! $user instanceof User || ! $workspace instanceof Workspace) {
|
|
||||||
return $this->inboxPayload = [
|
|
||||||
'sections' => [],
|
|
||||||
'available_families' => [],
|
|
||||||
'family_counts' => [],
|
|
||||||
'total_count' => 0,
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
return $this->inboxPayload = app(GovernanceInboxSectionBuilder::class)->build(
|
|
||||||
user: $user,
|
|
||||||
workspace: $workspace,
|
|
||||||
authorizedTenants: $this->authorizedTenants(),
|
|
||||||
visibleFindingTenants: $this->visibleFindingTenants(),
|
|
||||||
reviewTenants: $this->reviewTenants(),
|
|
||||||
canViewAlerts: $this->hasVisibleAlertsFamily(),
|
|
||||||
selectedTenant: $this->selectedTenant(),
|
|
||||||
selectedFamily: $this->family,
|
|
||||||
navigationContext: $this->navigationContext(),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @return array<string, mixed>
|
|
||||||
*/
|
|
||||||
private function unfilteredInboxPayload(): array
|
|
||||||
{
|
|
||||||
if (is_array($this->unfilteredInboxPayload)) {
|
|
||||||
return $this->unfilteredInboxPayload;
|
|
||||||
}
|
|
||||||
|
|
||||||
$user = auth()->user();
|
|
||||||
$workspace = $this->workspace();
|
|
||||||
|
|
||||||
if (! $user instanceof User || ! $workspace instanceof Workspace) {
|
|
||||||
return $this->unfilteredInboxPayload = [
|
|
||||||
'sections' => [],
|
|
||||||
'available_families' => [],
|
|
||||||
'family_counts' => [],
|
|
||||||
'total_count' => 0,
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
return $this->unfilteredInboxPayload = app(GovernanceInboxSectionBuilder::class)->build(
|
|
||||||
user: $user,
|
|
||||||
workspace: $workspace,
|
|
||||||
authorizedTenants: $this->authorizedTenants(),
|
|
||||||
visibleFindingTenants: $this->visibleFindingTenants(),
|
|
||||||
reviewTenants: $this->reviewTenants(),
|
|
||||||
canViewAlerts: $this->hasVisibleAlertsFamily(),
|
|
||||||
selectedTenant: null,
|
|
||||||
selectedFamily: null,
|
|
||||||
navigationContext: $this->navigationContext(),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
private function selectedTenant(): ?Tenant
|
|
||||||
{
|
|
||||||
if (! is_int($this->tenantId)) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
foreach ($this->authorizedTenants() as $tenant) {
|
|
||||||
if ((int) $tenant->getKey() === $this->tenantId) {
|
|
||||||
return $tenant;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
private function tenantFilterAloneExcludesRows(): bool
|
|
||||||
{
|
|
||||||
if (! is_int($this->tenantId) || $this->family !== null) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
if ($this->sections() !== []) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
return (int) ($this->unfilteredInboxPayload()['total_count'] ?? 0) > 0;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,497 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
namespace App\Filament\Pages\Reviews;
|
|
||||||
|
|
||||||
use App\Filament\Resources\TenantReviewResource;
|
|
||||||
use App\Models\Tenant;
|
|
||||||
use App\Models\ReviewPack;
|
|
||||||
use App\Models\TenantReview;
|
|
||||||
use App\Models\User;
|
|
||||||
use App\Models\Workspace;
|
|
||||||
use App\Services\ReviewPackService;
|
|
||||||
use App\Services\TenantReviews\TenantReviewRegisterService;
|
|
||||||
use App\Support\Auth\Capabilities;
|
|
||||||
use App\Support\Findings\FindingOutcomeSemantics;
|
|
||||||
use App\Support\Filament\TablePaginationProfiles;
|
|
||||||
use App\Support\ReviewPackStatus;
|
|
||||||
use App\Support\Ui\GovernanceArtifactTruth\ArtifactTruthEnvelope;
|
|
||||||
use App\Support\Ui\GovernanceArtifactTruth\ArtifactTruthPresenter;
|
|
||||||
use App\Support\Ui\GovernanceArtifactTruth\CompressedGovernanceOutcome;
|
|
||||||
use App\Support\Ui\GovernanceArtifactTruth\SurfaceCompressionContext;
|
|
||||||
use App\Support\Workspaces\WorkspaceContext;
|
|
||||||
use BackedEnum;
|
|
||||||
use Filament\Actions\Action;
|
|
||||||
use Filament\Pages\Page;
|
|
||||||
use Filament\Tables\Columns\TextColumn;
|
|
||||||
use Filament\Tables\Concerns\InteractsWithTable;
|
|
||||||
use Filament\Tables\Contracts\HasTable;
|
|
||||||
use Filament\Tables\Filters\SelectFilter;
|
|
||||||
use Filament\Tables\Table;
|
|
||||||
use Illuminate\Database\Eloquent\Builder;
|
|
||||||
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
|
|
||||||
use UnitEnum;
|
|
||||||
|
|
||||||
class CustomerReviewWorkspace extends Page implements HasTable
|
|
||||||
{
|
|
||||||
use InteractsWithTable;
|
|
||||||
|
|
||||||
public const string DETAIL_CONTEXT_QUERY_KEY = 'customer_workspace';
|
|
||||||
|
|
||||||
private const string SOURCE_SURFACE = 'customer_review_workspace';
|
|
||||||
|
|
||||||
protected static bool $isDiscovered = false;
|
|
||||||
|
|
||||||
protected static string|BackedEnum|null $navigationIcon = 'heroicon-o-document-text';
|
|
||||||
|
|
||||||
protected static string|UnitEnum|null $navigationGroup = 'Reporting';
|
|
||||||
|
|
||||||
protected static ?string $navigationLabel = 'Customer reviews';
|
|
||||||
|
|
||||||
protected static ?int $navigationSort = 44;
|
|
||||||
|
|
||||||
protected static ?string $title = 'Customer Review Workspace';
|
|
||||||
|
|
||||||
protected static ?string $slug = 'reviews/workspace';
|
|
||||||
|
|
||||||
protected string $view = 'filament.pages.reviews.customer-review-workspace';
|
|
||||||
|
|
||||||
public static function tenantPrefilterUrl(Tenant $tenant): string
|
|
||||||
{
|
|
||||||
$tenantIdentifier = filled($tenant->external_id)
|
|
||||||
? (string) $tenant->external_id
|
|
||||||
: (string) $tenant->getKey();
|
|
||||||
|
|
||||||
return static::getUrl(panel: 'admin').'?'.http_build_query([
|
|
||||||
'tenant' => $tenantIdentifier,
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @var array<int, Tenant>|null
|
|
||||||
*/
|
|
||||||
private ?array $authorizedTenants = null;
|
|
||||||
|
|
||||||
public function mount(): void
|
|
||||||
{
|
|
||||||
$this->authorizePageAccess();
|
|
||||||
$this->applyRequestedTenantPrefilter();
|
|
||||||
$this->mountInteractsWithTable();
|
|
||||||
}
|
|
||||||
|
|
||||||
protected function getHeaderActions(): array
|
|
||||||
{
|
|
||||||
return [
|
|
||||||
Action::make('clear_filters')
|
|
||||||
->label('Clear filters')
|
|
||||||
->icon('heroicon-o-x-mark')
|
|
||||||
->color('gray')
|
|
||||||
->visible(fn (): bool => $this->hasActiveFilters())
|
|
||||||
->action(function (): void {
|
|
||||||
$this->clearWorkspaceFilters();
|
|
||||||
}),
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
public function table(Table $table): Table
|
|
||||||
{
|
|
||||||
return $table
|
|
||||||
->query(fn (): Builder => $this->workspaceQuery())
|
|
||||||
->defaultSort('name')
|
|
||||||
->paginated(TablePaginationProfiles::customPage())
|
|
||||||
->persistFiltersInSession()
|
|
||||||
->persistSearchInSession()
|
|
||||||
->persistSortInSession()
|
|
||||||
->recordUrl(fn (Tenant $record): ?string => $this->latestReviewUrl($record))
|
|
||||||
->columns([
|
|
||||||
TextColumn::make('name')->label('Tenant')->searchable()->sortable(),
|
|
||||||
TextColumn::make('latest_review')
|
|
||||||
->label('Latest review')
|
|
||||||
->badge()
|
|
||||||
->getStateUsing(fn (Tenant $record): string => $this->latestReviewStateLabel($record))
|
|
||||||
->color(fn (Tenant $record): string => $this->latestReviewStateColor($record))
|
|
||||||
->icon(fn (Tenant $record): ?string => $this->latestReviewStateIcon($record))
|
|
||||||
->iconColor(fn (Tenant $record): ?string => $this->latestReviewStateIconColor($record))
|
|
||||||
->description(fn (Tenant $record): ?string => $this->reviewOutcomeDescription($record))
|
|
||||||
->wrap(),
|
|
||||||
TextColumn::make('finding_summary')
|
|
||||||
->label('Key findings')
|
|
||||||
->getStateUsing(fn (Tenant $record): string => $this->findingSummary($record))
|
|
||||||
->wrap(),
|
|
||||||
TextColumn::make('accepted_risk_summary')
|
|
||||||
->label('Accepted risks')
|
|
||||||
->getStateUsing(fn (Tenant $record): string => $this->acceptedRiskSummary($record))
|
|
||||||
->wrap(),
|
|
||||||
TextColumn::make('published_at')
|
|
||||||
->label('Published')
|
|
||||||
->getStateUsing(fn (Tenant $record): ?string => $this->latestPublishedAt($record)?->toDateTimeString())
|
|
||||||
->dateTime()
|
|
||||||
->placeholder('—'),
|
|
||||||
TextColumn::make('review_pack_state')
|
|
||||||
->label('Review pack')
|
|
||||||
->getStateUsing(fn (Tenant $record): string => $this->reviewPackAvailability($record)),
|
|
||||||
])
|
|
||||||
->filters([
|
|
||||||
SelectFilter::make('tenant_id')
|
|
||||||
->label('Tenant')
|
|
||||||
->options(fn (): array => $this->tenantFilterOptions())
|
|
||||||
->default(fn (): ?string => $this->defaultTenantFilter())
|
|
||||||
->query(function (Builder $query, array $data): Builder {
|
|
||||||
$tenantId = $data['value'] ?? null;
|
|
||||||
|
|
||||||
return is_numeric($tenantId)
|
|
||||||
? $query->whereKey((int) $tenantId)
|
|
||||||
: $query;
|
|
||||||
})
|
|
||||||
->searchable(),
|
|
||||||
])
|
|
||||||
->actions([
|
|
||||||
Action::make('open_latest_review')
|
|
||||||
->label('Open latest review')
|
|
||||||
->icon('heroicon-o-arrow-top-right-on-square')
|
|
||||||
->url(fn (Tenant $record): ?string => $this->latestReviewUrl($record))
|
|
||||||
->visible(fn (Tenant $record): bool => $this->latestPublishedReview($record) instanceof TenantReview),
|
|
||||||
Action::make('download_review_pack')
|
|
||||||
->label('Download review pack')
|
|
||||||
->icon('heroicon-o-arrow-down-tray')
|
|
||||||
->url(fn (Tenant $record): ?string => $this->latestReviewPackDownloadUrl($record))
|
|
||||||
->openUrlInNewTab()
|
|
||||||
->visible(fn (Tenant $record): bool => is_string($this->latestReviewPackDownloadUrl($record))),
|
|
||||||
])
|
|
||||||
->bulkActions([])
|
|
||||||
->emptyStateHeading('No entitled tenants match this view')
|
|
||||||
->emptyStateDescription(fn (): string => $this->hasActiveFilters()
|
|
||||||
? 'Clear the current filters to return to the full customer review workspace for your entitled tenants.'
|
|
||||||
: 'Adjust filters to return to the full customer review workspace for your entitled tenants.')
|
|
||||||
->emptyStateActions([
|
|
||||||
Action::make('clear_filters_empty')
|
|
||||||
->label('Clear filters')
|
|
||||||
->icon('heroicon-o-x-mark')
|
|
||||||
->color('gray')
|
|
||||||
->visible(fn (): bool => $this->hasActiveFilters())
|
|
||||||
->action(fn (): mixed => $this->clearWorkspaceFilters()),
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @return array<int, Tenant>
|
|
||||||
*/
|
|
||||||
public function authorizedTenants(): array
|
|
||||||
{
|
|
||||||
if ($this->authorizedTenants !== null) {
|
|
||||||
return $this->authorizedTenants;
|
|
||||||
}
|
|
||||||
|
|
||||||
$user = auth()->user();
|
|
||||||
$workspace = $this->workspace();
|
|
||||||
|
|
||||||
if (! $user instanceof User || ! $workspace instanceof Workspace) {
|
|
||||||
return $this->authorizedTenants = [];
|
|
||||||
}
|
|
||||||
|
|
||||||
return $this->authorizedTenants = app(TenantReviewRegisterService::class)->authorizedTenants($user, $workspace);
|
|
||||||
}
|
|
||||||
|
|
||||||
private function authorizePageAccess(): void
|
|
||||||
{
|
|
||||||
$user = auth()->user();
|
|
||||||
$workspace = $this->workspace();
|
|
||||||
|
|
||||||
if (! $user instanceof User) {
|
|
||||||
abort(403);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (! $workspace instanceof Workspace) {
|
|
||||||
throw new NotFoundHttpException;
|
|
||||||
}
|
|
||||||
|
|
||||||
$service = app(TenantReviewRegisterService::class);
|
|
||||||
|
|
||||||
if (! $service->canAccessWorkspace($user, $workspace)) {
|
|
||||||
throw new NotFoundHttpException;
|
|
||||||
}
|
|
||||||
|
|
||||||
if ($this->authorizedTenants() === []) {
|
|
||||||
throw new NotFoundHttpException;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private function workspaceQuery(): Builder
|
|
||||||
{
|
|
||||||
$user = auth()->user();
|
|
||||||
$workspace = $this->workspace();
|
|
||||||
|
|
||||||
if (! $user instanceof User || ! $workspace instanceof Workspace) {
|
|
||||||
return Tenant::query()->whereRaw('1 = 0');
|
|
||||||
}
|
|
||||||
|
|
||||||
return app(TenantReviewRegisterService::class)->customerWorkspaceTenantQuery($user, $workspace);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @return array<string, string>
|
|
||||||
*/
|
|
||||||
private function tenantFilterOptions(): array
|
|
||||||
{
|
|
||||||
return collect($this->authorizedTenants())
|
|
||||||
->mapWithKeys(static fn (Tenant $tenant): array => [
|
|
||||||
(string) $tenant->getKey() => $tenant->name,
|
|
||||||
])
|
|
||||||
->all();
|
|
||||||
}
|
|
||||||
|
|
||||||
private function defaultTenantFilter(): ?string
|
|
||||||
{
|
|
||||||
$tenantId = app(WorkspaceContext::class)->lastTenantId(request());
|
|
||||||
|
|
||||||
return is_int($tenantId) && array_key_exists($tenantId, $this->authorizedTenants())
|
|
||||||
? (string) $tenantId
|
|
||||||
: null;
|
|
||||||
}
|
|
||||||
|
|
||||||
private function applyRequestedTenantPrefilter(): void
|
|
||||||
{
|
|
||||||
$requestedTenant = request()->query('tenant', request()->query('tenant_id'));
|
|
||||||
|
|
||||||
if (! is_string($requestedTenant) && ! is_numeric($requestedTenant)) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
foreach ($this->authorizedTenants() as $tenant) {
|
|
||||||
if ((string) $tenant->getKey() !== (string) $requestedTenant && (string) $tenant->external_id !== (string) $requestedTenant) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
$this->tableFilters['tenant_id']['value'] = (string) $tenant->getKey();
|
|
||||||
$this->tableDeferredFilters['tenant_id']['value'] = (string) $tenant->getKey();
|
|
||||||
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
throw new NotFoundHttpException;
|
|
||||||
}
|
|
||||||
|
|
||||||
private function hasActiveFilters(): bool
|
|
||||||
{
|
|
||||||
return $this->currentTenantFilterId() !== null;
|
|
||||||
}
|
|
||||||
|
|
||||||
private function clearWorkspaceFilters(): void
|
|
||||||
{
|
|
||||||
app(WorkspaceContext::class)->clearLastTenantId(request());
|
|
||||||
$this->removeTableFilters();
|
|
||||||
}
|
|
||||||
|
|
||||||
private function currentTenantFilterId(): ?int
|
|
||||||
{
|
|
||||||
$tenantFilter = data_get($this->tableFilters, 'tenant_id.value');
|
|
||||||
|
|
||||||
if (! is_numeric($tenantFilter)) {
|
|
||||||
$tenantFilter = data_get(session()->get($this->getTableFiltersSessionKey(), []), 'tenant_id.value');
|
|
||||||
}
|
|
||||||
|
|
||||||
return is_numeric($tenantFilter) ? (int) $tenantFilter : null;
|
|
||||||
}
|
|
||||||
|
|
||||||
private function workspace(): ?Workspace
|
|
||||||
{
|
|
||||||
$workspaceId = app(WorkspaceContext::class)->currentWorkspaceId(request());
|
|
||||||
|
|
||||||
return is_numeric($workspaceId)
|
|
||||||
? Workspace::query()->whereKey((int) $workspaceId)->first()
|
|
||||||
: null;
|
|
||||||
}
|
|
||||||
|
|
||||||
private function latestPublishedReview(Tenant $tenant): ?TenantReview
|
|
||||||
{
|
|
||||||
$review = $tenant->tenantReviews->first();
|
|
||||||
|
|
||||||
return $review instanceof TenantReview ? $review : null;
|
|
||||||
}
|
|
||||||
|
|
||||||
private function latestReviewUrl(Tenant $tenant): ?string
|
|
||||||
{
|
|
||||||
$review = $this->latestPublishedReview($tenant);
|
|
||||||
|
|
||||||
if (! $review instanceof TenantReview) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return TenantReviewResource::tenantScopedUrl('view', ['record' => $review], $tenant, 'tenant').'?'.http_build_query([
|
|
||||||
self::DETAIL_CONTEXT_QUERY_KEY => 1,
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
|
|
||||||
private function latestReviewPack(Tenant $tenant): ?ReviewPack
|
|
||||||
{
|
|
||||||
$review = $this->latestPublishedReview($tenant);
|
|
||||||
$pack = $review?->currentExportReviewPack;
|
|
||||||
|
|
||||||
return $pack instanceof ReviewPack ? $pack : null;
|
|
||||||
}
|
|
||||||
|
|
||||||
private function latestReviewPackDownloadUrl(Tenant $tenant): ?string
|
|
||||||
{
|
|
||||||
$user = auth()->user();
|
|
||||||
$pack = $this->latestReviewPack($tenant);
|
|
||||||
|
|
||||||
if (! $user instanceof User || ! $pack instanceof ReviewPack) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (! $user->can(Capabilities::REVIEW_PACK_VIEW, $tenant)) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
if ($pack->status !== ReviewPackStatus::Ready->value) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
if ($pack->expires_at !== null && $pack->expires_at->isPast()) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return app(ReviewPackService::class)->generateDownloadUrl($pack, [
|
|
||||||
'source_surface' => self::SOURCE_SURFACE,
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
|
|
||||||
private function latestPublishedAt(Tenant $tenant): ?\Illuminate\Support\Carbon
|
|
||||||
{
|
|
||||||
return $this->latestPublishedReview($tenant)?->published_at;
|
|
||||||
}
|
|
||||||
|
|
||||||
private function reviewTruth(Tenant $tenant): ?ArtifactTruthEnvelope
|
|
||||||
{
|
|
||||||
$review = $this->latestPublishedReview($tenant);
|
|
||||||
|
|
||||||
return $review instanceof TenantReview
|
|
||||||
? app(ArtifactTruthPresenter::class)->forTenantReview($review)
|
|
||||||
: null;
|
|
||||||
}
|
|
||||||
|
|
||||||
private function reviewOutcome(Tenant $tenant): ?CompressedGovernanceOutcome
|
|
||||||
{
|
|
||||||
$presenter = app(ArtifactTruthPresenter::class);
|
|
||||||
$review = $this->latestPublishedReview($tenant);
|
|
||||||
$truth = $this->reviewTruth($tenant);
|
|
||||||
|
|
||||||
if (! $review instanceof TenantReview || ! $truth instanceof ArtifactTruthEnvelope) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return $presenter->compressedOutcomeFor($review, SurfaceCompressionContext::reviewRegister())
|
|
||||||
?? $presenter->compressedOutcomeFromEnvelope($truth, SurfaceCompressionContext::reviewRegister());
|
|
||||||
}
|
|
||||||
|
|
||||||
private function latestReviewStateLabel(Tenant $tenant): string
|
|
||||||
{
|
|
||||||
return $this->reviewOutcome($tenant)?->primaryLabel ?? 'No published review';
|
|
||||||
}
|
|
||||||
|
|
||||||
private function latestReviewStateColor(Tenant $tenant): string
|
|
||||||
{
|
|
||||||
return $this->reviewOutcome($tenant)?->primaryBadge->color ?? 'gray';
|
|
||||||
}
|
|
||||||
|
|
||||||
private function latestReviewStateIcon(Tenant $tenant): ?string
|
|
||||||
{
|
|
||||||
return $this->reviewOutcome($tenant)?->primaryBadge->icon;
|
|
||||||
}
|
|
||||||
|
|
||||||
private function latestReviewStateIconColor(Tenant $tenant): ?string
|
|
||||||
{
|
|
||||||
return $this->reviewOutcome($tenant)?->primaryBadge->iconColor;
|
|
||||||
}
|
|
||||||
|
|
||||||
private function reviewOutcomeDescription(Tenant $tenant): ?string
|
|
||||||
{
|
|
||||||
$review = $this->latestPublishedReview($tenant);
|
|
||||||
|
|
||||||
if (! $review instanceof TenantReview) {
|
|
||||||
return 'No published review available yet';
|
|
||||||
}
|
|
||||||
|
|
||||||
$primaryReason = $this->reviewOutcome($tenant)?->primaryReason;
|
|
||||||
$summary = is_array($review->summary) ? $review->summary : [];
|
|
||||||
$findingOutcomes = $summary['finding_outcomes'] ?? null;
|
|
||||||
|
|
||||||
if (! is_array($findingOutcomes)) {
|
|
||||||
return $primaryReason;
|
|
||||||
}
|
|
||||||
|
|
||||||
$findingOutcomeSummary = app(FindingOutcomeSemantics::class)->compactOutcomeSummary($findingOutcomes);
|
|
||||||
|
|
||||||
if ($findingOutcomeSummary === null) {
|
|
||||||
return $primaryReason;
|
|
||||||
}
|
|
||||||
|
|
||||||
return trim($primaryReason.' Terminal outcomes: '.$findingOutcomeSummary.'.');
|
|
||||||
}
|
|
||||||
|
|
||||||
private function findingSummary(Tenant $tenant): string
|
|
||||||
{
|
|
||||||
$review = $this->latestPublishedReview($tenant);
|
|
||||||
|
|
||||||
if (! $review instanceof TenantReview) {
|
|
||||||
return 'No published review available yet';
|
|
||||||
}
|
|
||||||
|
|
||||||
$summary = is_array($review->summary) ? $review->summary : [];
|
|
||||||
$findingCount = (int) ($summary['finding_count'] ?? 0);
|
|
||||||
$findingOutcomes = is_array($summary['finding_outcomes'] ?? null) ? $summary['finding_outcomes'] : [];
|
|
||||||
$terminalOutcomes = app(FindingOutcomeSemantics::class)->compactOutcomeSummary($findingOutcomes);
|
|
||||||
|
|
||||||
if ($findingCount === 0) {
|
|
||||||
return 'No findings recorded in the published review.';
|
|
||||||
}
|
|
||||||
|
|
||||||
if ($terminalOutcomes === null) {
|
|
||||||
return sprintf('%d findings summarized in the published review.', $findingCount);
|
|
||||||
}
|
|
||||||
|
|
||||||
return sprintf('%d findings. Terminal outcomes: %s.', $findingCount, $terminalOutcomes);
|
|
||||||
}
|
|
||||||
|
|
||||||
private function acceptedRiskSummary(Tenant $tenant): string
|
|
||||||
{
|
|
||||||
$review = $this->latestPublishedReview($tenant);
|
|
||||||
|
|
||||||
if (! $review instanceof TenantReview) {
|
|
||||||
return 'No published review available yet';
|
|
||||||
}
|
|
||||||
|
|
||||||
$summary = is_array($review->summary) ? $review->summary : [];
|
|
||||||
$riskAcceptance = is_array($summary['risk_acceptance'] ?? null) ? $summary['risk_acceptance'] : [];
|
|
||||||
$statusMarkedCount = (int) ($riskAcceptance['status_marked_count'] ?? 0);
|
|
||||||
$validGovernedCount = (int) ($riskAcceptance['valid_governed_count'] ?? 0);
|
|
||||||
$warningCount = (int) ($riskAcceptance['warning_count'] ?? 0);
|
|
||||||
|
|
||||||
return match (true) {
|
|
||||||
$statusMarkedCount === 0 => 'No accepted risks recorded.',
|
|
||||||
$warningCount > 0 => sprintf('%d accepted risks need governance follow-up (%d total).', $warningCount, $statusMarkedCount),
|
|
||||||
$validGovernedCount > 0 => sprintf('%d accepted risks are governed.', $validGovernedCount),
|
|
||||||
default => sprintf('%d accepted risks are on record.', $statusMarkedCount),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
private function reviewPackAvailability(Tenant $tenant): string
|
|
||||||
{
|
|
||||||
$pack = $this->latestReviewPack($tenant);
|
|
||||||
|
|
||||||
if (! $pack instanceof ReviewPack) {
|
|
||||||
return 'Unavailable';
|
|
||||||
}
|
|
||||||
|
|
||||||
if ($pack->status !== ReviewPackStatus::Ready->value) {
|
|
||||||
return 'Unavailable';
|
|
||||||
}
|
|
||||||
|
|
||||||
if ($pack->expires_at !== null && $pack->expires_at->isPast()) {
|
|
||||||
return 'Unavailable';
|
|
||||||
}
|
|
||||||
|
|
||||||
return 'Available';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -9,7 +9,6 @@
|
|||||||
use App\Models\TenantReview;
|
use App\Models\TenantReview;
|
||||||
use App\Models\User;
|
use App\Models\User;
|
||||||
use App\Models\Workspace;
|
use App\Models\Workspace;
|
||||||
use App\Services\ReviewPackService;
|
|
||||||
use App\Services\TenantReviews\TenantReviewRegisterService;
|
use App\Services\TenantReviews\TenantReviewRegisterService;
|
||||||
use App\Support\Auth\Capabilities;
|
use App\Support\Auth\Capabilities;
|
||||||
use App\Support\Badges\BadgeCatalog;
|
use App\Support\Badges\BadgeCatalog;
|
||||||
@ -177,24 +176,6 @@ public function table(Table $table): Table
|
|||||||
->visible(fn (TenantReview $record): bool => auth()->user() instanceof User
|
->visible(fn (TenantReview $record): bool => auth()->user() instanceof User
|
||||||
&& auth()->user()->can(Capabilities::TENANT_REVIEW_MANAGE, $record->tenant)
|
&& auth()->user()->can(Capabilities::TENANT_REVIEW_MANAGE, $record->tenant)
|
||||||
&& in_array($record->status, ['ready', 'published'], true))
|
&& in_array($record->status, ['ready', 'published'], true))
|
||||||
->disabled(fn (TenantReview $record): bool => (bool) (app(ReviewPackService::class)->reviewPackGenerationDecisionForTenant($record->tenant)['is_blocked'] ?? false))
|
|
||||||
->tooltip(function (TenantReview $record): ?string {
|
|
||||||
$decision = app(ReviewPackService::class)->reviewPackGenerationDecisionForTenant($record->tenant);
|
|
||||||
|
|
||||||
if ((bool) ($decision['is_blocked'] ?? false)) {
|
|
||||||
$reason = $decision['block_reason'] ?? null;
|
|
||||||
|
|
||||||
return is_string($reason) && $reason !== '' ? $reason : null;
|
|
||||||
}
|
|
||||||
|
|
||||||
if ((bool) ($decision['is_warning'] ?? false)) {
|
|
||||||
$reason = $decision['warning_reason'] ?? null;
|
|
||||||
|
|
||||||
return is_string($reason) && $reason !== '' ? $reason : null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return null;
|
|
||||||
})
|
|
||||||
->action(fn (TenantReview $record): mixed => TenantReviewResource::executeExport($record)),
|
->action(fn (TenantReview $record): mixed => TenantReviewResource::executeExport($record)),
|
||||||
])
|
])
|
||||||
->bulkActions([])
|
->bulkActions([])
|
||||||
|
|||||||
@ -7,11 +7,7 @@
|
|||||||
use App\Models\User;
|
use App\Models\User;
|
||||||
use App\Models\Workspace;
|
use App\Models\Workspace;
|
||||||
use App\Models\WorkspaceSetting;
|
use App\Models\WorkspaceSetting;
|
||||||
use App\Support\Ai\AiPolicyMode;
|
|
||||||
use App\Support\Ai\AiUseCaseCatalog;
|
|
||||||
use App\Services\Auth\WorkspaceCapabilityResolver;
|
use App\Services\Auth\WorkspaceCapabilityResolver;
|
||||||
use App\Services\Entitlements\WorkspaceEntitlementResolver;
|
|
||||||
use App\Services\Entitlements\WorkspacePlanProfileCatalog;
|
|
||||||
use App\Services\Settings\SettingsResolver;
|
use App\Services\Settings\SettingsResolver;
|
||||||
use App\Services\Settings\SettingsWriter;
|
use App\Services\Settings\SettingsWriter;
|
||||||
use App\Support\Auth\Capabilities;
|
use App\Support\Auth\Capabilities;
|
||||||
@ -24,9 +20,7 @@
|
|||||||
use BackedEnum;
|
use BackedEnum;
|
||||||
use Filament\Actions\Action;
|
use Filament\Actions\Action;
|
||||||
use Filament\Forms\Components\KeyValue;
|
use Filament\Forms\Components\KeyValue;
|
||||||
use Filament\Forms\Components\Placeholder;
|
|
||||||
use Filament\Forms\Components\Select;
|
use Filament\Forms\Components\Select;
|
||||||
use Filament\Forms\Components\Textarea;
|
|
||||||
use Filament\Forms\Components\TextInput;
|
use Filament\Forms\Components\TextInput;
|
||||||
use Filament\Notifications\Notification;
|
use Filament\Notifications\Notification;
|
||||||
use Filament\Pages\Page;
|
use Filament\Pages\Page;
|
||||||
@ -57,7 +51,6 @@ class WorkspaceSettings extends Page
|
|||||||
* @var array<string, array{domain: string, key: string, type: 'int'|'json'|'string'|'bool'}>
|
* @var array<string, array{domain: string, key: string, type: 'int'|'json'|'string'|'bool'}>
|
||||||
*/
|
*/
|
||||||
private const SETTING_FIELDS = [
|
private const SETTING_FIELDS = [
|
||||||
'ai_policy_mode' => ['domain' => 'ai', 'key' => 'policy_mode', 'type' => 'string'],
|
|
||||||
'backup_retention_keep_last_default' => ['domain' => 'backup', 'key' => 'retention_keep_last_default', 'type' => 'int'],
|
'backup_retention_keep_last_default' => ['domain' => 'backup', 'key' => 'retention_keep_last_default', 'type' => 'int'],
|
||||||
'backup_retention_min_floor' => ['domain' => 'backup', 'key' => 'retention_min_floor', 'type' => 'int'],
|
'backup_retention_min_floor' => ['domain' => 'backup', 'key' => 'retention_min_floor', 'type' => 'int'],
|
||||||
'drift_severity_mapping' => ['domain' => 'drift', 'key' => 'severity_mapping', 'type' => 'json'],
|
'drift_severity_mapping' => ['domain' => 'drift', 'key' => 'severity_mapping', 'type' => 'json'],
|
||||||
@ -65,23 +58,10 @@ class WorkspaceSettings extends Page
|
|||||||
'baseline_alert_min_severity' => ['domain' => 'baseline', 'key' => 'alert_min_severity', 'type' => 'string'],
|
'baseline_alert_min_severity' => ['domain' => 'baseline', 'key' => 'alert_min_severity', 'type' => 'string'],
|
||||||
'baseline_auto_close_enabled' => ['domain' => 'baseline', 'key' => 'auto_close_enabled', 'type' => 'bool'],
|
'baseline_auto_close_enabled' => ['domain' => 'baseline', 'key' => 'auto_close_enabled', 'type' => 'bool'],
|
||||||
'findings_sla_days' => ['domain' => 'findings', 'key' => 'sla_days', 'type' => 'json'],
|
'findings_sla_days' => ['domain' => 'findings', 'key' => 'sla_days', 'type' => 'json'],
|
||||||
'entitlements_plan_profile' => ['domain' => 'entitlements', 'key' => 'plan_profile', 'type' => 'string'],
|
|
||||||
'entitlements_managed_tenant_limit_override_value' => ['domain' => 'entitlements', 'key' => 'managed_tenant_limit_override_value', 'type' => 'int'],
|
|
||||||
'entitlements_managed_tenant_limit_override_reason' => ['domain' => 'entitlements', 'key' => 'managed_tenant_limit_override_reason', 'type' => 'string'],
|
|
||||||
'entitlements_review_pack_generation_override_value' => ['domain' => 'entitlements', 'key' => 'review_pack_generation_override_value', 'type' => 'bool'],
|
|
||||||
'entitlements_review_pack_generation_override_reason' => ['domain' => 'entitlements', 'key' => 'review_pack_generation_override_reason', 'type' => 'string'],
|
|
||||||
'operations_operation_run_retention_days' => ['domain' => 'operations', 'key' => 'operation_run_retention_days', 'type' => 'int'],
|
'operations_operation_run_retention_days' => ['domain' => 'operations', 'key' => 'operation_run_retention_days', 'type' => 'int'],
|
||||||
'operations_stuck_run_threshold_minutes' => ['domain' => 'operations', 'key' => 'stuck_run_threshold_minutes', 'type' => 'int'],
|
'operations_stuck_run_threshold_minutes' => ['domain' => 'operations', 'key' => 'stuck_run_threshold_minutes', 'type' => 'int'],
|
||||||
];
|
];
|
||||||
|
|
||||||
/**
|
|
||||||
* @var array<string, string>
|
|
||||||
*/
|
|
||||||
private const ENTITLEMENT_OVERRIDE_REASON_FIELDS = [
|
|
||||||
'entitlements_managed_tenant_limit_override_value' => 'entitlements_managed_tenant_limit_override_reason',
|
|
||||||
'entitlements_review_pack_generation_override_value' => 'entitlements_review_pack_generation_override_reason',
|
|
||||||
];
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Fields rendered as Filament KeyValue components (array state, not JSON string).
|
* Fields rendered as Filament KeyValue components (array state, not JSON string).
|
||||||
*
|
*
|
||||||
@ -131,14 +111,6 @@ class WorkspaceSettings extends Page
|
|||||||
*/
|
*/
|
||||||
public array $resolvedSettings = [];
|
public array $resolvedSettings = [];
|
||||||
|
|
||||||
/**
|
|
||||||
* @var array{
|
|
||||||
* plan_profile?: array{id: string, label: string, description: string, managed_tenant_limit_default: int, review_pack_generation_default: bool, is_default: bool},
|
|
||||||
* decisions?: array<string, array<string, mixed>>
|
|
||||||
* }
|
|
||||||
*/
|
|
||||||
public array $entitlementSummary = [];
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Per-domain "last modified" metadata: domain => {user_name, updated_at}.
|
* Per-domain "last modified" metadata: domain => {user_name, updated_at}.
|
||||||
*
|
*
|
||||||
@ -208,71 +180,6 @@ public function content(Schema $schema): Schema
|
|||||||
return $schema
|
return $schema
|
||||||
->statePath('data')
|
->statePath('data')
|
||||||
->schema([
|
->schema([
|
||||||
Section::make('Workspace entitlements')
|
|
||||||
->description($this->sectionDescription('entitlements', 'Select a plan profile and optional first-slice overrides for onboarding activation and review pack generation.'))
|
|
||||||
->columns(2)
|
|
||||||
->schema([
|
|
||||||
Select::make('entitlements_plan_profile')
|
|
||||||
->label('Plan profile')
|
|
||||||
->options(app(WorkspacePlanProfileCatalog::class)->optionLabels())
|
|
||||||
->placeholder(sprintf('Use default profile (%s)', app(WorkspacePlanProfileCatalog::class)->default()['label']))
|
|
||||||
->native(false)
|
|
||||||
->columnSpanFull()
|
|
||||||
->disabled(fn (): bool => ! $this->currentUserCanManage())
|
|
||||||
->helperText(fn (): string => $this->planProfileFieldHelperText()),
|
|
||||||
TextInput::make('entitlements_managed_tenant_limit_override_value')
|
|
||||||
->label('Managed tenant activation limit override')
|
|
||||||
->placeholder('Unset (uses plan profile default)')
|
|
||||||
->suffix('tenants')
|
|
||||||
->hint('0 or greater')
|
|
||||||
->numeric()
|
|
||||||
->integer()
|
|
||||||
->minValue(0)
|
|
||||||
->disabled(fn (): bool => ! $this->currentUserCanManage())
|
|
||||||
->helperText(fn (): string => $this->managedTenantLimitHelperText())
|
|
||||||
->hintAction($this->makeResetAction('entitlements_managed_tenant_limit_override_value')),
|
|
||||||
Textarea::make('entitlements_managed_tenant_limit_override_reason')
|
|
||||||
->label('Managed tenant activation override reason')
|
|
||||||
->rows(3)
|
|
||||||
->maxLength(500)
|
|
||||||
->disabled(fn (): bool => ! $this->currentUserCanManage())
|
|
||||||
->helperText(fn (): string => $this->managedTenantLimitReasonHelperText()),
|
|
||||||
Select::make('entitlements_review_pack_generation_override_value')
|
|
||||||
->label('Review pack generation override')
|
|
||||||
->options(self::booleanOptions())
|
|
||||||
->placeholder('Unset (uses plan profile default)')
|
|
||||||
->native(false)
|
|
||||||
->disabled(fn (): bool => ! $this->currentUserCanManage())
|
|
||||||
->helperText(fn (): string => $this->reviewPackGenerationHelperText())
|
|
||||||
->hintAction($this->makeResetAction('entitlements_review_pack_generation_override_value')),
|
|
||||||
Textarea::make('entitlements_review_pack_generation_override_reason')
|
|
||||||
->label('Review pack generation override reason')
|
|
||||||
->rows(3)
|
|
||||||
->maxLength(500)
|
|
||||||
->disabled(fn (): bool => ! $this->currentUserCanManage())
|
|
||||||
->helperText(fn (): string => $this->reviewPackGenerationReasonHelperText()),
|
|
||||||
]),
|
|
||||||
Section::make('Workspace AI policy')
|
|
||||||
->description($this->sectionDescription('ai', 'Control whether the workspace disables AI entirely or allows approved internal-only drafts on private-only infrastructure.'))
|
|
||||||
->schema([
|
|
||||||
Select::make('ai_policy_mode')
|
|
||||||
->label('AI posture')
|
|
||||||
->options(AiPolicyMode::optionLabels())
|
|
||||||
->placeholder('Unset (uses default)')
|
|
||||||
->native(false)
|
|
||||||
->disabled(fn (): bool => ! $this->currentUserCanManage())
|
|
||||||
->helperText(fn (): string => $this->aiPolicyModeHelperText())
|
|
||||||
->hintAction($this->makeResetAction('ai_policy_mode')),
|
|
||||||
Placeholder::make('ai_approved_use_cases')
|
|
||||||
->label('Approved use cases')
|
|
||||||
->content(fn (): string => $this->aiApprovedUseCasesText()),
|
|
||||||
Placeholder::make('ai_allowed_provider_classes')
|
|
||||||
->label('Allowed provider classes')
|
|
||||||
->content(fn (): string => $this->aiAllowedProviderClassesText()),
|
|
||||||
Placeholder::make('ai_blocked_data_classifications')
|
|
||||||
->label('Blocked data classifications')
|
|
||||||
->content(fn (): string => $this->aiBlockedDataClassificationsText()),
|
|
||||||
]),
|
|
||||||
Section::make('Backup settings')
|
Section::make('Backup settings')
|
||||||
->description($this->sectionDescription('backup', 'Workspace defaults used when a schedule has no explicit value.'))
|
->description($this->sectionDescription('backup', 'Workspace defaults used when a schedule has no explicit value.'))
|
||||||
->schema([
|
->schema([
|
||||||
@ -548,56 +455,6 @@ public function resetSetting(string $field): void
|
|||||||
->send();
|
->send();
|
||||||
}
|
}
|
||||||
|
|
||||||
private function resetEntitlementOverridePair(string $field): void
|
|
||||||
{
|
|
||||||
$user = auth()->user();
|
|
||||||
|
|
||||||
if (! $user instanceof User) {
|
|
||||||
abort(403);
|
|
||||||
}
|
|
||||||
|
|
||||||
$this->authorizeWorkspaceManage($user);
|
|
||||||
|
|
||||||
if (! $this->hasEntitlementOverridePair($field)) {
|
|
||||||
Notification::make()
|
|
||||||
->title('Entitlement already uses plan profile default')
|
|
||||||
->success()
|
|
||||||
->send();
|
|
||||||
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
$writer = app(SettingsWriter::class);
|
|
||||||
$valueSetting = $this->settingForField($field);
|
|
||||||
$reasonField = self::ENTITLEMENT_OVERRIDE_REASON_FIELDS[$field];
|
|
||||||
$reasonSetting = $this->settingForField($reasonField);
|
|
||||||
|
|
||||||
if ($this->workspaceOverrideForField($field) !== null) {
|
|
||||||
$writer->resetWorkspaceSetting(
|
|
||||||
actor: $user,
|
|
||||||
workspace: $this->workspace,
|
|
||||||
domain: $valueSetting['domain'],
|
|
||||||
key: $valueSetting['key'],
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if ($this->workspaceOverrideForField($reasonField) !== null) {
|
|
||||||
$writer->resetWorkspaceSetting(
|
|
||||||
actor: $user,
|
|
||||||
workspace: $this->workspace,
|
|
||||||
domain: $reasonSetting['domain'],
|
|
||||||
key: $reasonSetting['key'],
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
$this->loadFormState();
|
|
||||||
|
|
||||||
Notification::make()
|
|
||||||
->title('Workspace entitlement override reset')
|
|
||||||
->success()
|
|
||||||
->send();
|
|
||||||
}
|
|
||||||
|
|
||||||
private function loadFormState(): void
|
private function loadFormState(): void
|
||||||
{
|
{
|
||||||
$resolver = app(SettingsResolver::class);
|
$resolver = app(SettingsResolver::class);
|
||||||
@ -633,7 +490,6 @@ private function loadFormState(): void
|
|||||||
$this->data = $data;
|
$this->data = $data;
|
||||||
$this->workspaceOverrides = $workspaceOverrides;
|
$this->workspaceOverrides = $workspaceOverrides;
|
||||||
$this->resolvedSettings = $resolvedSettings;
|
$this->resolvedSettings = $resolvedSettings;
|
||||||
$this->entitlementSummary = app(WorkspaceEntitlementResolver::class)->summary($this->workspace);
|
|
||||||
|
|
||||||
$this->loadDomainLastModified();
|
$this->loadDomainLastModified();
|
||||||
}
|
}
|
||||||
@ -707,25 +563,15 @@ private function makeResetAction(string $field): Action
|
|||||||
->color('danger')
|
->color('danger')
|
||||||
->requiresConfirmation()
|
->requiresConfirmation()
|
||||||
->action(function () use ($field): void {
|
->action(function () use ($field): void {
|
||||||
if ($this->isEntitlementOverrideValueField($field)) {
|
|
||||||
$this->resetEntitlementOverridePair($field);
|
|
||||||
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
$this->resetSetting($field);
|
$this->resetSetting($field);
|
||||||
})
|
})
|
||||||
->disabled(fn (): bool => ! $this->currentUserCanManage() || ! $this->canResetField($field))
|
->disabled(fn (): bool => ! $this->currentUserCanManage() || ! $this->hasWorkspaceOverride($field))
|
||||||
->tooltip(function () use ($field): ?string {
|
->tooltip(function () use ($field): ?string {
|
||||||
if (! $this->currentUserCanManage()) {
|
if (! $this->currentUserCanManage()) {
|
||||||
return 'You do not have permission to manage workspace settings.';
|
return 'You do not have permission to manage workspace settings.';
|
||||||
}
|
}
|
||||||
|
|
||||||
if (! $this->canResetField($field)) {
|
if (! $this->hasWorkspaceOverride($field)) {
|
||||||
if ($this->isEntitlementOverrideValueField($field)) {
|
|
||||||
return 'No workspace override to reset.';
|
|
||||||
}
|
|
||||||
|
|
||||||
return 'No workspace override to reset.';
|
return 'No workspace override to reset.';
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -733,200 +579,6 @@ private function makeResetAction(string $field): Action
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
private function canResetField(string $field): bool
|
|
||||||
{
|
|
||||||
if ($this->isEntitlementOverrideValueField($field)) {
|
|
||||||
return $this->hasEntitlementOverridePair($field);
|
|
||||||
}
|
|
||||||
|
|
||||||
return $this->hasWorkspaceOverride($field);
|
|
||||||
}
|
|
||||||
|
|
||||||
private function isEntitlementOverrideValueField(string $field): bool
|
|
||||||
{
|
|
||||||
return array_key_exists($field, self::ENTITLEMENT_OVERRIDE_REASON_FIELDS);
|
|
||||||
}
|
|
||||||
|
|
||||||
private function hasEntitlementOverridePair(string $field): bool
|
|
||||||
{
|
|
||||||
if (! $this->isEntitlementOverrideValueField($field)) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
$reasonField = self::ENTITLEMENT_OVERRIDE_REASON_FIELDS[$field];
|
|
||||||
|
|
||||||
return $this->workspaceOverrideForField($field) !== null
|
|
||||||
|| $this->workspaceOverrideForField($reasonField) !== null;
|
|
||||||
}
|
|
||||||
|
|
||||||
private function planProfileFieldHelperText(): string
|
|
||||||
{
|
|
||||||
$profile = $this->resolvedPlanProfile();
|
|
||||||
$selectedProfile = $this->workspaceOverrideForField('entitlements_plan_profile');
|
|
||||||
|
|
||||||
if (! is_string($selectedProfile) || $selectedProfile === '') {
|
|
||||||
return sprintf('Default profile: %s. %s', $profile['label'], $profile['description']);
|
|
||||||
}
|
|
||||||
|
|
||||||
return sprintf('Effective profile: %s. %s', $profile['label'], $profile['description']);
|
|
||||||
}
|
|
||||||
|
|
||||||
private function managedTenantLimitHelperText(): string
|
|
||||||
{
|
|
||||||
$decision = $this->entitlementDecision(WorkspaceEntitlementResolver::KEY_MANAGED_TENANT_ACTIVATION_LIMIT);
|
|
||||||
$effectiveValue = (int) ($decision['effective_value'] ?? 0);
|
|
||||||
$currentUsage = (int) ($decision['current_usage'] ?? 0);
|
|
||||||
$remainingCapacity = (int) ($decision['remaining_capacity'] ?? 0);
|
|
||||||
|
|
||||||
$capacityText = $remainingCapacity < 0
|
|
||||||
? sprintf('Over limit by %d.', abs($remainingCapacity))
|
|
||||||
: sprintf('%d remaining.', $remainingCapacity);
|
|
||||||
|
|
||||||
return sprintf(
|
|
||||||
'Effective limit: %d active managed tenants. Current usage: %d. %s Source: %s.',
|
|
||||||
$effectiveValue,
|
|
||||||
$currentUsage,
|
|
||||||
$capacityText,
|
|
||||||
$this->entitlementSourceLabel($decision),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
private function managedTenantLimitReasonHelperText(): string
|
|
||||||
{
|
|
||||||
return $this->entitlementReasonHelperText(
|
|
||||||
valueField: 'entitlements_managed_tenant_limit_override_value',
|
|
||||||
key: WorkspaceEntitlementResolver::KEY_MANAGED_TENANT_ACTIVATION_LIMIT,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
private function reviewPackGenerationHelperText(): string
|
|
||||||
{
|
|
||||||
$decision = $this->entitlementDecision(WorkspaceEntitlementResolver::KEY_REVIEW_PACK_GENERATION_ENABLED);
|
|
||||||
|
|
||||||
return sprintf(
|
|
||||||
'Effective state: %s. Source: %s.',
|
|
||||||
(bool) ($decision['effective_value'] ?? false) ? 'enabled' : 'disabled',
|
|
||||||
$this->entitlementSourceLabel($decision),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
private function reviewPackGenerationReasonHelperText(): string
|
|
||||||
{
|
|
||||||
return $this->entitlementReasonHelperText(
|
|
||||||
valueField: 'entitlements_review_pack_generation_override_value',
|
|
||||||
key: WorkspaceEntitlementResolver::KEY_REVIEW_PACK_GENERATION_ENABLED,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
private function aiPolicyModeHelperText(): string
|
|
||||||
{
|
|
||||||
$resolved = $this->resolvedSettings['ai_policy_mode'] ?? null;
|
|
||||||
|
|
||||||
if (! is_array($resolved)) {
|
|
||||||
return '';
|
|
||||||
}
|
|
||||||
|
|
||||||
$mode = AiPolicyMode::tryFrom((string) ($resolved['value'] ?? AiPolicyMode::Disabled->value))
|
|
||||||
?? AiPolicyMode::Disabled;
|
|
||||||
|
|
||||||
$prefix = ! $this->hasWorkspaceOverride('ai_policy_mode')
|
|
||||||
? sprintf('Effective posture: %s. Source: %s.', $mode->label(), $this->sourceLabel((string) ($resolved['source'] ?? 'system_default')))
|
|
||||||
: sprintf('Effective posture: %s.', $mode->label());
|
|
||||||
|
|
||||||
return sprintf('%s %s', $prefix, $mode->summary());
|
|
||||||
}
|
|
||||||
|
|
||||||
private function aiApprovedUseCasesText(): string
|
|
||||||
{
|
|
||||||
return implode('; ', app(AiUseCaseCatalog::class)->labels()).'.';
|
|
||||||
}
|
|
||||||
|
|
||||||
private function aiAllowedProviderClassesText(): string
|
|
||||||
{
|
|
||||||
$labels = app(AiUseCaseCatalog::class)->allowedProviderClassLabelsForMode($this->effectiveAiPolicyMode());
|
|
||||||
|
|
||||||
if ($labels === []) {
|
|
||||||
return 'No provider classes are allowed while AI is disabled.';
|
|
||||||
}
|
|
||||||
|
|
||||||
return implode(', ', $labels).'.';
|
|
||||||
}
|
|
||||||
|
|
||||||
private function aiBlockedDataClassificationsText(): string
|
|
||||||
{
|
|
||||||
return implode(', ', app(AiUseCaseCatalog::class)->blockedDataClassificationLabels()).'.';
|
|
||||||
}
|
|
||||||
|
|
||||||
private function effectiveAiPolicyMode(): AiPolicyMode
|
|
||||||
{
|
|
||||||
$resolved = $this->resolvedSettings['ai_policy_mode'] ?? null;
|
|
||||||
|
|
||||||
if (! is_array($resolved)) {
|
|
||||||
return AiPolicyMode::Disabled;
|
|
||||||
}
|
|
||||||
|
|
||||||
return AiPolicyMode::tryFrom((string) ($resolved['value'] ?? AiPolicyMode::Disabled->value))
|
|
||||||
?? AiPolicyMode::Disabled;
|
|
||||||
}
|
|
||||||
|
|
||||||
private function entitlementReasonHelperText(string $valueField, string $key): string
|
|
||||||
{
|
|
||||||
$decision = $this->entitlementDecision($key);
|
|
||||||
$rationale = is_string($decision['rationale'] ?? null) ? $decision['rationale'] : null;
|
|
||||||
|
|
||||||
if ($this->workspaceOverrideForField($valueField) === null) {
|
|
||||||
return 'Required when an explicit override value is set.';
|
|
||||||
}
|
|
||||||
|
|
||||||
if ($rationale === null || $rationale === '') {
|
|
||||||
return 'Required when an explicit override value is set.';
|
|
||||||
}
|
|
||||||
|
|
||||||
return sprintf('Current rationale: %s', $rationale);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @return array{id: string, label: string, description: string, managed_tenant_limit_default: int, review_pack_generation_default: bool, is_default: bool}
|
|
||||||
*/
|
|
||||||
private function resolvedPlanProfile(): array
|
|
||||||
{
|
|
||||||
$profile = $this->entitlementSummary['plan_profile'] ?? null;
|
|
||||||
|
|
||||||
if (is_array($profile)) {
|
|
||||||
return $profile;
|
|
||||||
}
|
|
||||||
|
|
||||||
return app(WorkspacePlanProfileCatalog::class)->default();
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @return array<string, mixed>
|
|
||||||
*/
|
|
||||||
private function entitlementDecision(string $key): array
|
|
||||||
{
|
|
||||||
$decision = $this->entitlementSummary['decisions'][$key] ?? null;
|
|
||||||
|
|
||||||
return is_array($decision) ? $decision : [];
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @param array<string, mixed> $decision
|
|
||||||
*/
|
|
||||||
private function entitlementSourceLabel(array $decision): string
|
|
||||||
{
|
|
||||||
if (($decision['source'] ?? null) === 'workspace_override') {
|
|
||||||
return 'workspace override';
|
|
||||||
}
|
|
||||||
|
|
||||||
$planProfileLabel = $decision['plan_profile_label'] ?? null;
|
|
||||||
|
|
||||||
if (is_string($planProfileLabel) && $planProfileLabel !== '') {
|
|
||||||
return sprintf('%s plan profile', $planProfileLabel);
|
|
||||||
}
|
|
||||||
|
|
||||||
return 'plan profile default';
|
|
||||||
}
|
|
||||||
|
|
||||||
private function helperTextFor(string $field): string
|
private function helperTextFor(string $field): string
|
||||||
{
|
{
|
||||||
$resolved = $this->resolvedSettings[$field] ?? null;
|
$resolved = $this->resolvedSettings[$field] ?? null;
|
||||||
@ -1069,27 +721,6 @@ private function normalizedInputValues(): array
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
foreach (self::ENTITLEMENT_OVERRIDE_REASON_FIELDS as $valueField => $reasonField) {
|
|
||||||
if (($normalizedValues[$valueField] ?? null) === null) {
|
|
||||||
$normalizedValues[$reasonField] = null;
|
|
||||||
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (($normalizedValues[$reasonField] ?? null) !== null) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
$message = match ($valueField) {
|
|
||||||
'entitlements_managed_tenant_limit_override_value' => 'Override reason is required when a managed tenant activation limit override is set.',
|
|
||||||
'entitlements_review_pack_generation_override_value' => 'Override reason is required when a review pack generation override is set.',
|
|
||||||
default => 'Override reason is required when an explicit override is set.',
|
|
||||||
};
|
|
||||||
|
|
||||||
$validationErrors['data.'.$reasonField] ??= [];
|
|
||||||
$validationErrors['data.'.$reasonField][] = $message;
|
|
||||||
}
|
|
||||||
|
|
||||||
return [$normalizedValues, $validationErrors];
|
return [$normalizedValues, $validationErrors];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -30,8 +30,6 @@
|
|||||||
use App\Services\Onboarding\OnboardingDraftResolver;
|
use App\Services\Onboarding\OnboardingDraftResolver;
|
||||||
use App\Services\Onboarding\OnboardingDraftStageResolver;
|
use App\Services\Onboarding\OnboardingDraftStageResolver;
|
||||||
use App\Services\Onboarding\OnboardingLifecycleService;
|
use App\Services\Onboarding\OnboardingLifecycleService;
|
||||||
use App\Services\Entitlements\WorkspaceCommercialLifecycleResolver;
|
|
||||||
use App\Services\Entitlements\WorkspaceEntitlementResolver;
|
|
||||||
use App\Services\OperationRunService;
|
use App\Services\OperationRunService;
|
||||||
use App\Services\Providers\ProviderConnectionMutationService;
|
use App\Services\Providers\ProviderConnectionMutationService;
|
||||||
use App\Services\Providers\ProviderOperationRegistry;
|
use App\Services\Providers\ProviderOperationRegistry;
|
||||||
@ -664,16 +662,7 @@ public function content(Schema $schema): Schema
|
|||||||
Text::make(fn (): string => $this->completionSummaryBootstrapSummary())
|
Text::make(fn (): string => $this->completionSummaryBootstrapSummary())
|
||||||
->badge()
|
->badge()
|
||||||
->color(fn (): string => $this->completionSummaryBootstrapColor()),
|
->color(fn (): string => $this->completionSummaryBootstrapColor()),
|
||||||
Text::make('Activation entitlement')
|
|
||||||
->color('gray'),
|
|
||||||
Text::make(fn (): string => $this->completionSummaryEntitlementSummary())
|
|
||||||
->badge()
|
|
||||||
->color(fn (): string => $this->completionSummaryEntitlementColor()),
|
|
||||||
]),
|
]),
|
||||||
Callout::make('Activation entitlement')
|
|
||||||
->description(fn (): string => $this->completionSummaryEntitlementDetail())
|
|
||||||
->warning()
|
|
||||||
->visible(fn (): bool => $this->completionSummaryEntitlementBlocked()),
|
|
||||||
Callout::make('Bootstrap needs attention')
|
Callout::make('Bootstrap needs attention')
|
||||||
->description(fn (): string => $this->completionSummaryBootstrapRecoveryMessage())
|
->description(fn (): string => $this->completionSummaryBootstrapRecoveryMessage())
|
||||||
->warning()
|
->warning()
|
||||||
@ -711,7 +700,9 @@ public function content(Schema $schema): Schema
|
|||||||
->modalSubmitActionLabel('Yes, complete onboarding')
|
->modalSubmitActionLabel('Yes, complete onboarding')
|
||||||
->disabled(fn (): bool => ! $this->canCompleteOnboarding()
|
->disabled(fn (): bool => ! $this->canCompleteOnboarding()
|
||||||
|| ! $this->currentUserCan(Capabilities::WORKSPACE_MANAGED_TENANT_ONBOARD_ACTIVATE))
|
|| ! $this->currentUserCan(Capabilities::WORKSPACE_MANAGED_TENANT_ONBOARD_ACTIVATE))
|
||||||
->tooltip(fn (): ?string => $this->completionActionTooltip())
|
->tooltip(fn (): ?string => $this->currentUserCan(Capabilities::WORKSPACE_MANAGED_TENANT_ONBOARD_ACTIVATE)
|
||||||
|
? null
|
||||||
|
: 'Owner required to complete onboarding.')
|
||||||
->action(fn () => $this->completeOnboarding()),
|
->action(fn () => $this->completeOnboarding()),
|
||||||
]),
|
]),
|
||||||
]),
|
]),
|
||||||
@ -4507,10 +4498,6 @@ private function canCompleteOnboarding(): bool
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
if ($this->completionSummaryEntitlementBlocked()) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
$user = $this->currentUser();
|
$user = $this->currentUser();
|
||||||
|
|
||||||
if (! app(TenantOperabilityService::class)->outcomeFor(
|
if (! app(TenantOperabilityService::class)->outcomeFor(
|
||||||
@ -4543,116 +4530,6 @@ private function canCompleteOnboarding(): bool
|
|||||||
return trim((string) ($this->data['override_reason'] ?? '')) !== '';
|
return trim((string) ($this->data['override_reason'] ?? '')) !== '';
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* @return array<string, mixed>
|
|
||||||
*/
|
|
||||||
private function completionSummaryEntitlementDecision(): array
|
|
||||||
{
|
|
||||||
if (! isset($this->workspace) || ! $this->workspace instanceof Workspace) {
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
|
|
||||||
return app(WorkspaceCommercialLifecycleResolver::class)->actionDecision(
|
|
||||||
$this->workspace,
|
|
||||||
WorkspaceCommercialLifecycleResolver::ACTION_MANAGED_TENANT_ACTIVATION,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
private function completionSummaryEntitlementBlocked(): bool
|
|
||||||
{
|
|
||||||
return ($this->completionSummaryEntitlementDecision()['outcome'] ?? null) === WorkspaceCommercialLifecycleResolver::OUTCOME_BLOCK;
|
|
||||||
}
|
|
||||||
|
|
||||||
private function completionSummaryEntitlementSummary(): string
|
|
||||||
{
|
|
||||||
$decision = $this->completionSummaryEntitlementDecision();
|
|
||||||
$entitlementDecision = is_array($decision['entitlement_decision'] ?? null) ? $decision['entitlement_decision'] : [];
|
|
||||||
$currentUsage = (int) ($entitlementDecision['current_usage'] ?? 0);
|
|
||||||
$effectiveValue = (int) ($entitlementDecision['effective_value'] ?? 0);
|
|
||||||
$sourceLabel = $this->completionSummaryEntitlementSourceLabel($entitlementDecision);
|
|
||||||
$stateLabel = is_string($decision['state_label'] ?? null) ? $decision['state_label'] : 'Active paid';
|
|
||||||
|
|
||||||
return sprintf(
|
|
||||||
'%s - %s - %d active of %d allowed (%s)',
|
|
||||||
$this->completionSummaryEntitlementBlocked() ? 'Blocked' : 'Allowed',
|
|
||||||
$stateLabel,
|
|
||||||
$currentUsage,
|
|
||||||
$effectiveValue,
|
|
||||||
$sourceLabel,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
private function completionSummaryEntitlementDetail(): string
|
|
||||||
{
|
|
||||||
$decision = $this->completionSummaryEntitlementDecision();
|
|
||||||
$entitlementDecision = is_array($decision['entitlement_decision'] ?? null) ? $decision['entitlement_decision'] : [];
|
|
||||||
$currentUsage = (int) ($entitlementDecision['current_usage'] ?? 0);
|
|
||||||
$effectiveValue = (int) ($entitlementDecision['effective_value'] ?? 0);
|
|
||||||
$remainingCapacity = (int) ($entitlementDecision['remaining_capacity'] ?? 0);
|
|
||||||
$sourceLabel = $this->completionSummaryEntitlementSourceLabel($entitlementDecision);
|
|
||||||
$rationale = is_string($decision['rationale'] ?? null) ? $decision['rationale'] : null;
|
|
||||||
$message = sprintf(
|
|
||||||
'%s Current usage is %d active managed tenant%s out of %d allowed. Source: %s.',
|
|
||||||
(string) ($decision['message'] ?? 'Managed-tenant activation is available for this workspace commercial state.'),
|
|
||||||
$currentUsage,
|
|
||||||
$currentUsage === 1 ? '' : 's',
|
|
||||||
$effectiveValue,
|
|
||||||
$sourceLabel,
|
|
||||||
);
|
|
||||||
|
|
||||||
if ($remainingCapacity >= 0) {
|
|
||||||
$message .= sprintf(' Remaining capacity: %d.', $remainingCapacity);
|
|
||||||
}
|
|
||||||
|
|
||||||
if ($this->completionSummaryEntitlementBlocked()) {
|
|
||||||
$blockReason = is_string($decision['block_reason'] ?? null) ? $decision['block_reason'] : null;
|
|
||||||
|
|
||||||
if ($blockReason !== null && $blockReason !== '') {
|
|
||||||
$message = $blockReason;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if ($rationale !== null && $rationale !== '' && ($decision['source'] ?? null) === WorkspaceCommercialLifecycleResolver::SOURCE_WORKSPACE_SETTING) {
|
|
||||||
$message .= ' Rationale: '.$rationale;
|
|
||||||
}
|
|
||||||
|
|
||||||
return $message;
|
|
||||||
}
|
|
||||||
|
|
||||||
private function completionSummaryEntitlementColor(): string
|
|
||||||
{
|
|
||||||
return $this->completionSummaryEntitlementBlocked() ? 'warning' : 'success';
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @param array<string, mixed> $decision
|
|
||||||
*/
|
|
||||||
private function completionSummaryEntitlementSourceLabel(array $decision): string
|
|
||||||
{
|
|
||||||
if (($decision['source'] ?? null) === 'workspace_override') {
|
|
||||||
return 'workspace override';
|
|
||||||
}
|
|
||||||
|
|
||||||
$label = $decision['plan_profile_label'] ?? null;
|
|
||||||
|
|
||||||
return is_string($label) && $label !== ''
|
|
||||||
? sprintf('%s plan profile', $label)
|
|
||||||
: 'plan profile default';
|
|
||||||
}
|
|
||||||
|
|
||||||
private function completionActionTooltip(): ?string
|
|
||||||
{
|
|
||||||
if (! $this->currentUserCan(Capabilities::WORKSPACE_MANAGED_TENANT_ONBOARD_ACTIVATE)) {
|
|
||||||
return 'Owner required to complete onboarding.';
|
|
||||||
}
|
|
||||||
|
|
||||||
if ($this->completionSummaryEntitlementBlocked()) {
|
|
||||||
return $this->completionSummaryEntitlementDetail();
|
|
||||||
}
|
|
||||||
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
private function completionSummaryTenantLine(): string
|
private function completionSummaryTenantLine(): string
|
||||||
{
|
{
|
||||||
$tenant = $this->currentManagedTenantRecord();
|
$tenant = $this->currentManagedTenantRecord();
|
||||||
@ -4986,16 +4863,6 @@ public function completeOnboarding(): void
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if ($this->completionSummaryEntitlementBlocked()) {
|
|
||||||
Notification::make()
|
|
||||||
->title('Activation unavailable')
|
|
||||||
->body($this->completionSummaryEntitlementDetail())
|
|
||||||
->warning()
|
|
||||||
->send();
|
|
||||||
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
$run = $this->verificationRun();
|
$run = $this->verificationRun();
|
||||||
$verificationSucceeded = $this->verificationHasSucceeded();
|
$verificationSucceeded = $this->verificationHasSucceeded();
|
||||||
$verificationCanProceed = $this->verificationCanProceed();
|
$verificationCanProceed = $this->verificationCanProceed();
|
||||||
|
|||||||
@ -6,7 +6,6 @@
|
|||||||
|
|
||||||
use App\Filament\Concerns\InteractsWithTenantOwnedRecords;
|
use App\Filament\Concerns\InteractsWithTenantOwnedRecords;
|
||||||
use App\Filament\Concerns\ResolvesPanelTenantContext;
|
use App\Filament\Concerns\ResolvesPanelTenantContext;
|
||||||
use App\Filament\Pages\Reviews\CustomerReviewWorkspace;
|
|
||||||
use App\Filament\Resources\EvidenceSnapshotResource\Pages;
|
use App\Filament\Resources\EvidenceSnapshotResource\Pages;
|
||||||
use App\Filament\Resources\ReviewPackResource;
|
use App\Filament\Resources\ReviewPackResource;
|
||||||
use App\Models\EvidenceSnapshot;
|
use App\Models\EvidenceSnapshot;
|
||||||
@ -268,20 +267,6 @@ public static function relatedContextEntries(EvidenceSnapshot $record): array
|
|||||||
)->toArray();
|
)->toArray();
|
||||||
}
|
}
|
||||||
|
|
||||||
if ($record->tenant instanceof Tenant) {
|
|
||||||
$entries[] = RelatedContextEntry::available(
|
|
||||||
key: 'customer_review_workspace',
|
|
||||||
label: 'Customer workspace',
|
|
||||||
value: $record->tenant->name,
|
|
||||||
secondaryValue: 'Open the customer-safe review workspace prefiltered to this tenant.',
|
|
||||||
targetUrl: CustomerReviewWorkspace::tenantPrefilterUrl($record->tenant),
|
|
||||||
targetKind: 'canonical_page',
|
|
||||||
priority: 30,
|
|
||||||
actionLabel: 'Open customer workspace',
|
|
||||||
contextBadge: 'Reporting',
|
|
||||||
)->toArray();
|
|
||||||
}
|
|
||||||
|
|
||||||
return $entries;
|
return $entries;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -2,10 +2,7 @@
|
|||||||
|
|
||||||
namespace App\Filament\Resources;
|
namespace App\Filament\Resources;
|
||||||
|
|
||||||
use App\Filament\Concerns\ResolvesPanelTenantContext;
|
|
||||||
use App\Exceptions\Entitlements\WorkspaceEntitlementBlockedException;
|
|
||||||
use App\Exceptions\ReviewPackEvidenceResolutionException;
|
use App\Exceptions\ReviewPackEvidenceResolutionException;
|
||||||
use App\Filament\Pages\Reviews\CustomerReviewWorkspace;
|
|
||||||
use App\Filament\Resources\EvidenceSnapshotResource as TenantEvidenceSnapshotResource;
|
use App\Filament\Resources\EvidenceSnapshotResource as TenantEvidenceSnapshotResource;
|
||||||
use App\Filament\Resources\ReviewPackResource\Pages;
|
use App\Filament\Resources\ReviewPackResource\Pages;
|
||||||
use App\Models\ReviewPack;
|
use App\Models\ReviewPack;
|
||||||
@ -13,7 +10,6 @@
|
|||||||
use App\Models\User;
|
use App\Models\User;
|
||||||
use App\Services\ReviewPackService;
|
use App\Services\ReviewPackService;
|
||||||
use App\Support\Auth\Capabilities;
|
use App\Support\Auth\Capabilities;
|
||||||
use App\Support\Auth\UiTooltips as AuthUiTooltips;
|
|
||||||
use App\Support\Badges\BadgeCatalog;
|
use App\Support\Badges\BadgeCatalog;
|
||||||
use App\Support\Badges\BadgeDomain;
|
use App\Support\Badges\BadgeDomain;
|
||||||
use App\Support\Badges\BadgeRenderer;
|
use App\Support\Badges\BadgeRenderer;
|
||||||
@ -49,8 +45,6 @@
|
|||||||
|
|
||||||
class ReviewPackResource extends Resource
|
class ReviewPackResource extends Resource
|
||||||
{
|
{
|
||||||
use ResolvesPanelTenantContext;
|
|
||||||
|
|
||||||
protected static ?string $model = ReviewPack::class;
|
protected static ?string $model = ReviewPack::class;
|
||||||
|
|
||||||
protected static ?string $tenantOwnershipRelationshipName = 'tenant';
|
protected static ?string $tenantOwnershipRelationshipName = 'tenant';
|
||||||
@ -108,9 +102,9 @@ public static function canView(Model $record): bool
|
|||||||
public static function actionSurfaceDeclaration(): ActionSurfaceDeclaration
|
public static function actionSurfaceDeclaration(): ActionSurfaceDeclaration
|
||||||
{
|
{
|
||||||
return ActionSurfaceDeclaration::forResource(ActionSurfaceProfile::CrudListAndView, ActionSurfaceType::ReadOnlyRegistryReport)
|
return ActionSurfaceDeclaration::forResource(ActionSurfaceProfile::CrudListAndView, ActionSurfaceType::ReadOnlyRegistryReport)
|
||||||
->satisfy(ActionSurfaceSlot::ListHeader, 'Generate Pack action appears in the list header once review packs exist.')
|
->satisfy(ActionSurfaceSlot::ListHeader, 'Generate Pack action available in list header.')
|
||||||
->satisfy(ActionSurfaceSlot::InspectAffordance, ActionSurfaceInspectAffordance::ClickableRow->value)
|
->satisfy(ActionSurfaceSlot::InspectAffordance, ActionSurfaceInspectAffordance::ClickableRow->value)
|
||||||
->satisfy(ActionSurfaceSlot::ListEmptyState, 'Empty state carries the single Generate CTA while the registry is empty.')
|
->satisfy(ActionSurfaceSlot::ListEmptyState, 'Empty state includes Generate CTA.')
|
||||||
->satisfy(ActionSurfaceSlot::ListRowMoreMenu, 'Clickable-row inspection stays primary while Download remains the only direct row shortcut and Expire is grouped under More.')
|
->satisfy(ActionSurfaceSlot::ListRowMoreMenu, 'Clickable-row inspection stays primary while Download remains the only direct row shortcut and Expire is grouped under More.')
|
||||||
->exempt(ActionSurfaceSlot::ListBulkMoreGroup, 'No bulk operations are supported for review packs.')
|
->exempt(ActionSurfaceSlot::ListBulkMoreGroup, 'No bulk operations are supported for review packs.')
|
||||||
->satisfy(ActionSurfaceSlot::DetailHeader, 'Download and Regenerate actions in ViewReviewPack header.');
|
->satisfy(ActionSurfaceSlot::DetailHeader, 'Download and Regenerate actions in ViewReviewPack header.');
|
||||||
@ -196,13 +190,6 @@ public static function infolist(Schema $schema): Schema
|
|||||||
? TenantReviewResource::tenantScopedUrl('view', ['record' => $record->tenantReview], $record->tenant)
|
? TenantReviewResource::tenantScopedUrl('view', ['record' => $record->tenantReview], $record->tenant)
|
||||||
: null)
|
: null)
|
||||||
->placeholder('—'),
|
->placeholder('—'),
|
||||||
TextEntry::make('customer_workspace')
|
|
||||||
->label('Customer workspace')
|
|
||||||
->state(fn (): string => 'Open workspace')
|
|
||||||
->url(fn (ReviewPack $record): ?string => $record->tenant instanceof Tenant
|
|
||||||
? CustomerReviewWorkspace::tenantPrefilterUrl($record->tenant)
|
|
||||||
: null)
|
|
||||||
->placeholder('—'),
|
|
||||||
TextEntry::make('summary.review_status')
|
TextEntry::make('summary.review_status')
|
||||||
->label('Review status')
|
->label('Review status')
|
||||||
->badge()
|
->badge()
|
||||||
@ -363,62 +350,41 @@ public static function table(Table $table): Table
|
|||||||
->emptyStateDescription('Generate a review pack to export tenant data for external review.')
|
->emptyStateDescription('Generate a review pack to export tenant data for external review.')
|
||||||
->emptyStateIcon('heroicon-o-document-arrow-down')
|
->emptyStateIcon('heroicon-o-document-arrow-down')
|
||||||
->emptyStateActions([
|
->emptyStateActions([
|
||||||
static::generatePackAction(name: 'generate_first', label: 'Generate first pack'),
|
UiEnforcement::forAction(
|
||||||
|
Actions\Action::make('generate_first')
|
||||||
|
->label('Generate first pack')
|
||||||
|
->icon('heroicon-o-plus')
|
||||||
|
->action(function (array $data): void {
|
||||||
|
static::executeGeneration($data);
|
||||||
|
})
|
||||||
|
->form([
|
||||||
|
Section::make('Pack options')
|
||||||
|
->schema([
|
||||||
|
Toggle::make('include_pii')
|
||||||
|
->label('Include PII')
|
||||||
|
->helperText('Include personally identifiable information in the export.')
|
||||||
|
->default(config('tenantpilot.review_pack.include_pii_default', true)),
|
||||||
|
Toggle::make('include_operations')
|
||||||
|
->label('Include operations')
|
||||||
|
->helperText('Include recent operation history in the export.')
|
||||||
|
->default(config('tenantpilot.review_pack.include_operations_default', true)),
|
||||||
|
]),
|
||||||
|
])
|
||||||
|
)
|
||||||
|
->requireCapability(Capabilities::REVIEW_PACK_MANAGE)
|
||||||
|
->apply(),
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
public static function generatePackAction(string $name = 'generate_pack', string $label = 'Generate Pack'): Actions\Action
|
|
||||||
{
|
|
||||||
$action = UiEnforcement::forAction(
|
|
||||||
Actions\Action::make($name)
|
|
||||||
->label($label)
|
|
||||||
->icon('heroicon-o-plus')
|
|
||||||
->disabled(fn (): bool => static::reviewPackGenerationBlocked())
|
|
||||||
->action(function (array $data): void {
|
|
||||||
static::executeGeneration($data);
|
|
||||||
})
|
|
||||||
->form(static::reviewPackGenerationFormSchema())
|
|
||||||
)
|
|
||||||
->requireCapability(Capabilities::REVIEW_PACK_MANAGE)
|
|
||||||
->preserveDisabled()
|
|
||||||
->apply();
|
|
||||||
|
|
||||||
$action->tooltip(fn (): ?string => static::reviewPackGenerationActionTooltip());
|
|
||||||
|
|
||||||
return $action;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @return array<int, Section>
|
|
||||||
*/
|
|
||||||
public static function reviewPackGenerationFormSchema(): array
|
|
||||||
{
|
|
||||||
return [
|
|
||||||
Section::make('Pack options')
|
|
||||||
->schema([
|
|
||||||
Toggle::make('include_pii')
|
|
||||||
->label('Include PII')
|
|
||||||
->helperText('Include personally identifiable information in the export.')
|
|
||||||
->default(config('tenantpilot.review_pack.include_pii_default', true)),
|
|
||||||
Toggle::make('include_operations')
|
|
||||||
->label('Include operations')
|
|
||||||
->helperText('Include recent operation history in the export.')
|
|
||||||
->default(config('tenantpilot.review_pack.include_operations_default', true)),
|
|
||||||
]),
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
public static function getEloquentQuery(): Builder
|
public static function getEloquentQuery(): Builder
|
||||||
{
|
{
|
||||||
$tenant = Tenant::current() ?? static::resolveTenantContextForCurrentPanel() ?? Filament::getTenant();
|
$tenant = Filament::getTenant();
|
||||||
|
|
||||||
if (! $tenant instanceof Tenant) {
|
if (! $tenant instanceof Tenant) {
|
||||||
return parent::getEloquentQuery()->whereRaw('1 = 0');
|
return parent::getEloquentQuery()->whereRaw('1 = 0');
|
||||||
}
|
}
|
||||||
|
|
||||||
return parent::getEloquentQuery()
|
return parent::getEloquentQuery()->where('tenant_id', (int) $tenant->getKey());
|
||||||
->with(['tenant', 'operationRun', 'evidenceSnapshot', 'tenantReview'])
|
|
||||||
->where('tenant_id', (int) $tenant->getKey());
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public static function getPages(): array
|
public static function getPages(): array
|
||||||
@ -492,14 +458,6 @@ public static function executeGeneration(array $data): void
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
$reviewPack = $service->generate($tenant, $user, $options);
|
$reviewPack = $service->generate($tenant, $user, $options);
|
||||||
} catch (WorkspaceEntitlementBlockedException $exception) {
|
|
||||||
Notification::make()
|
|
||||||
->warning()
|
|
||||||
->title('Review pack generation unavailable')
|
|
||||||
->body($exception->getMessage())
|
|
||||||
->send();
|
|
||||||
|
|
||||||
return;
|
|
||||||
} catch (ReviewPackEvidenceResolutionException $exception) {
|
} catch (ReviewPackEvidenceResolutionException $exception) {
|
||||||
$reasons = $exception->result->reasons;
|
$reasons = $exception->result->reasons;
|
||||||
|
|
||||||
@ -535,69 +493,4 @@ public static function executeGeneration(array $data): void
|
|||||||
|
|
||||||
OperationUxPresenter::queuedToast('tenant.review_pack.generate')->send();
|
OperationUxPresenter::queuedToast('tenant.review_pack.generate')->send();
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* @return array<string, mixed>
|
|
||||||
*/
|
|
||||||
public static function reviewPackGenerationDecision(?Tenant $tenant = null): array
|
|
||||||
{
|
|
||||||
$tenant ??= Tenant::current() ?? static::resolveTenantContextForCurrentPanel() ?? Filament::getTenant();
|
|
||||||
|
|
||||||
if (! $tenant instanceof Tenant) {
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
|
|
||||||
return app(ReviewPackService::class)->reviewPackGenerationDecisionForTenant($tenant);
|
|
||||||
}
|
|
||||||
|
|
||||||
public static function currentTenantContext(): ?Tenant
|
|
||||||
{
|
|
||||||
$tenant = Tenant::current() ?? static::resolveTenantContextForCurrentPanel() ?? Filament::getTenant();
|
|
||||||
|
|
||||||
return $tenant instanceof Tenant ? $tenant : null;
|
|
||||||
}
|
|
||||||
|
|
||||||
public static function reviewPackGenerationBlocked(?Tenant $tenant = null): bool
|
|
||||||
{
|
|
||||||
return (bool) (static::reviewPackGenerationDecision($tenant)['is_blocked'] ?? false);
|
|
||||||
}
|
|
||||||
|
|
||||||
public static function reviewPackGenerationBlockReason(?Tenant $tenant = null): ?string
|
|
||||||
{
|
|
||||||
$decision = static::reviewPackGenerationDecision($tenant);
|
|
||||||
|
|
||||||
if (! (bool) ($decision['is_blocked'] ?? false)) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
$reason = $decision['block_reason'] ?? null;
|
|
||||||
|
|
||||||
return is_string($reason) && $reason !== '' ? $reason : null;
|
|
||||||
}
|
|
||||||
|
|
||||||
public static function reviewPackGenerationWarningReason(?Tenant $tenant = null): ?string
|
|
||||||
{
|
|
||||||
$decision = static::reviewPackGenerationDecision($tenant);
|
|
||||||
|
|
||||||
if (! (bool) ($decision['is_warning'] ?? false)) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
$reason = $decision['warning_reason'] ?? null;
|
|
||||||
|
|
||||||
return is_string($reason) && $reason !== '' ? $reason : null;
|
|
||||||
}
|
|
||||||
|
|
||||||
public static function reviewPackGenerationActionTooltip(?Tenant $tenant = null): ?string
|
|
||||||
{
|
|
||||||
$tenant ??= static::currentTenantContext();
|
|
||||||
$user = auth()->user();
|
|
||||||
|
|
||||||
if ($tenant instanceof Tenant && $user instanceof User && ! $user->can(Capabilities::REVIEW_PACK_MANAGE, $tenant)) {
|
|
||||||
return AuthUiTooltips::insufficientPermission();
|
|
||||||
}
|
|
||||||
|
|
||||||
return static::reviewPackGenerationBlockReason($tenant)
|
|
||||||
?? static::reviewPackGenerationWarningReason($tenant);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -3,7 +3,12 @@
|
|||||||
namespace App\Filament\Resources\ReviewPackResource\Pages;
|
namespace App\Filament\Resources\ReviewPackResource\Pages;
|
||||||
|
|
||||||
use App\Filament\Resources\ReviewPackResource;
|
use App\Filament\Resources\ReviewPackResource;
|
||||||
|
use App\Support\Auth\Capabilities;
|
||||||
|
use App\Support\Rbac\UiEnforcement;
|
||||||
|
use Filament\Actions;
|
||||||
|
use Filament\Forms\Components\Toggle;
|
||||||
use Filament\Resources\Pages\ListRecords;
|
use Filament\Resources\Pages\ListRecords;
|
||||||
|
use Filament\Schemas\Components\Section;
|
||||||
|
|
||||||
class ListReviewPacks extends ListRecords
|
class ListReviewPacks extends ListRecords
|
||||||
{
|
{
|
||||||
@ -12,13 +17,29 @@ class ListReviewPacks extends ListRecords
|
|||||||
protected function getHeaderActions(): array
|
protected function getHeaderActions(): array
|
||||||
{
|
{
|
||||||
return [
|
return [
|
||||||
ReviewPackResource::generatePackAction()
|
UiEnforcement::forAction(
|
||||||
->visible(fn (): bool => $this->tableHasRecords()),
|
Actions\Action::make('generate_pack')
|
||||||
|
->label('Generate Pack')
|
||||||
|
->icon('heroicon-o-plus')
|
||||||
|
->action(function (array $data): void {
|
||||||
|
ReviewPackResource::executeGeneration($data);
|
||||||
|
})
|
||||||
|
->form([
|
||||||
|
Section::make('Pack options')
|
||||||
|
->schema([
|
||||||
|
Toggle::make('include_pii')
|
||||||
|
->label('Include PII')
|
||||||
|
->helperText('Include personally identifiable information in the export.')
|
||||||
|
->default(config('tenantpilot.review_pack.include_pii_default', true)),
|
||||||
|
Toggle::make('include_operations')
|
||||||
|
->label('Include operations')
|
||||||
|
->helperText('Include recent operation history in the export.')
|
||||||
|
->default(config('tenantpilot.review_pack.include_operations_default', true)),
|
||||||
|
]),
|
||||||
|
])
|
||||||
|
)
|
||||||
|
->requireCapability(Capabilities::REVIEW_PACK_MANAGE)
|
||||||
|
->apply(),
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
private function tableHasRecords(): bool
|
|
||||||
{
|
|
||||||
return $this->getTableRecords()->count() > 0;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -19,51 +19,6 @@ class ViewReviewPack extends ViewRecord
|
|||||||
|
|
||||||
protected function getHeaderActions(): array
|
protected function getHeaderActions(): array
|
||||||
{
|
{
|
||||||
$regenerateAction = UiEnforcement::forAction(
|
|
||||||
Actions\Action::make('regenerate')
|
|
||||||
->label('Regenerate')
|
|
||||||
->icon('heroicon-o-arrow-path')
|
|
||||||
->color('primary')
|
|
||||||
->disabled(fn (): bool => ReviewPackResource::reviewPackGenerationBlocked($this->record->tenant))
|
|
||||||
->requiresConfirmation()
|
|
||||||
->modalDescription('This will generate a new review pack with the same options. The current pack will remain available until it expires.')
|
|
||||||
->action(function (array $data): void {
|
|
||||||
/** @var ReviewPack $record */
|
|
||||||
$record = $this->record;
|
|
||||||
|
|
||||||
$options = array_merge($record->options ?? [], [
|
|
||||||
'include_pii' => (bool) ($data['include_pii'] ?? ($record->options['include_pii'] ?? true)),
|
|
||||||
'include_operations' => (bool) ($data['include_operations'] ?? ($record->options['include_operations'] ?? true)),
|
|
||||||
]);
|
|
||||||
|
|
||||||
ReviewPackResource::executeGeneration($options);
|
|
||||||
})
|
|
||||||
->form(function (): array {
|
|
||||||
/** @var ReviewPack $record */
|
|
||||||
$record = $this->record;
|
|
||||||
$currentOptions = $record->options ?? [];
|
|
||||||
|
|
||||||
return [
|
|
||||||
Section::make('Pack options')
|
|
||||||
->schema([
|
|
||||||
Toggle::make('include_pii')
|
|
||||||
->label('Include PII')
|
|
||||||
->helperText('Include personally identifiable information in the export.')
|
|
||||||
->default((bool) ($currentOptions['include_pii'] ?? true)),
|
|
||||||
Toggle::make('include_operations')
|
|
||||||
->label('Include operations')
|
|
||||||
->helperText('Include recent operation history in the export.')
|
|
||||||
->default((bool) ($currentOptions['include_operations'] ?? true)),
|
|
||||||
]),
|
|
||||||
];
|
|
||||||
})
|
|
||||||
)
|
|
||||||
->requireCapability(Capabilities::REVIEW_PACK_MANAGE)
|
|
||||||
->preserveDisabled()
|
|
||||||
->apply();
|
|
||||||
|
|
||||||
$regenerateAction->tooltip(fn (): ?string => ReviewPackResource::reviewPackGenerationActionTooltip($this->record->tenant));
|
|
||||||
|
|
||||||
return [
|
return [
|
||||||
Actions\Action::make('download')
|
Actions\Action::make('download')
|
||||||
->label('Download')
|
->label('Download')
|
||||||
@ -73,7 +28,46 @@ protected function getHeaderActions(): array
|
|||||||
->url(fn (): string => app(ReviewPackService::class)->generateDownloadUrl($this->record))
|
->url(fn (): string => app(ReviewPackService::class)->generateDownloadUrl($this->record))
|
||||||
->openUrlInNewTab(),
|
->openUrlInNewTab(),
|
||||||
|
|
||||||
$regenerateAction,
|
UiEnforcement::forAction(
|
||||||
|
Actions\Action::make('regenerate')
|
||||||
|
->label('Regenerate')
|
||||||
|
->icon('heroicon-o-arrow-path')
|
||||||
|
->color('primary')
|
||||||
|
->requiresConfirmation()
|
||||||
|
->modalDescription('This will generate a new review pack with the same options. The current pack will remain available until it expires.')
|
||||||
|
->action(function (array $data): void {
|
||||||
|
/** @var ReviewPack $record */
|
||||||
|
$record = $this->record;
|
||||||
|
|
||||||
|
$options = array_merge($record->options ?? [], [
|
||||||
|
'include_pii' => (bool) ($data['include_pii'] ?? ($record->options['include_pii'] ?? true)),
|
||||||
|
'include_operations' => (bool) ($data['include_operations'] ?? ($record->options['include_operations'] ?? true)),
|
||||||
|
]);
|
||||||
|
|
||||||
|
ReviewPackResource::executeGeneration($options);
|
||||||
|
})
|
||||||
|
->form(function (): array {
|
||||||
|
/** @var ReviewPack $record */
|
||||||
|
$record = $this->record;
|
||||||
|
$currentOptions = $record->options ?? [];
|
||||||
|
|
||||||
|
return [
|
||||||
|
Section::make('Pack options')
|
||||||
|
->schema([
|
||||||
|
Toggle::make('include_pii')
|
||||||
|
->label('Include PII')
|
||||||
|
->helperText('Include personally identifiable information in the export.')
|
||||||
|
->default((bool) ($currentOptions['include_pii'] ?? true)),
|
||||||
|
Toggle::make('include_operations')
|
||||||
|
->label('Include operations')
|
||||||
|
->helperText('Include recent operation history in the export.')
|
||||||
|
->default((bool) ($currentOptions['include_operations'] ?? true)),
|
||||||
|
]),
|
||||||
|
];
|
||||||
|
})
|
||||||
|
)
|
||||||
|
->requireCapability(Capabilities::REVIEW_PACK_MANAGE)
|
||||||
|
->apply(),
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -6,9 +6,7 @@
|
|||||||
|
|
||||||
use App\Filament\Concerns\InteractsWithTenantOwnedRecords;
|
use App\Filament\Concerns\InteractsWithTenantOwnedRecords;
|
||||||
use App\Filament\Concerns\ResolvesPanelTenantContext;
|
use App\Filament\Concerns\ResolvesPanelTenantContext;
|
||||||
use App\Filament\Pages\Reviews\CustomerReviewWorkspace;
|
|
||||||
use App\Filament\Resources\TenantReviewResource\Pages;
|
use App\Filament\Resources\TenantReviewResource\Pages;
|
||||||
use App\Exceptions\Entitlements\WorkspaceEntitlementBlockedException;
|
|
||||||
use App\Models\EvidenceSnapshot;
|
use App\Models\EvidenceSnapshot;
|
||||||
use App\Models\Tenant;
|
use App\Models\Tenant;
|
||||||
use App\Models\TenantReview;
|
use App\Models\TenantReview;
|
||||||
@ -17,7 +15,6 @@
|
|||||||
use App\Services\ReviewPackService;
|
use App\Services\ReviewPackService;
|
||||||
use App\Services\TenantReviews\TenantReviewService;
|
use App\Services\TenantReviews\TenantReviewService;
|
||||||
use App\Support\Auth\Capabilities;
|
use App\Support\Auth\Capabilities;
|
||||||
use App\Support\Auth\UiTooltips as AuthUiTooltips;
|
|
||||||
use App\Support\Badges\BadgeCatalog;
|
use App\Support\Badges\BadgeCatalog;
|
||||||
use App\Support\Badges\BadgeDomain;
|
use App\Support\Badges\BadgeDomain;
|
||||||
use App\Support\Badges\BadgeRenderer;
|
use App\Support\Badges\BadgeRenderer;
|
||||||
@ -244,25 +241,6 @@ public static function infolist(Schema $schema): Schema
|
|||||||
|
|
||||||
public static function table(Table $table): Table
|
public static function table(Table $table): Table
|
||||||
{
|
{
|
||||||
$exportExecutivePackAction = UiEnforcement::forTableAction(
|
|
||||||
Actions\Action::make('export_executive_pack')
|
|
||||||
->label('Export executive pack')
|
|
||||||
->icon('heroicon-o-arrow-down-tray')
|
|
||||||
->visible(fn (TenantReview $record): bool => in_array($record->status, [
|
|
||||||
TenantReviewStatus::Ready->value,
|
|
||||||
TenantReviewStatus::Published->value,
|
|
||||||
], true))
|
|
||||||
->disabled(fn (TenantReview $record): bool => static::reviewPackGenerationBlocked($record->tenant))
|
|
||||||
->action(fn (TenantReview $record): mixed => static::executeExport($record)),
|
|
||||||
fn (TenantReview $record): TenantReview => $record,
|
|
||||||
)
|
|
||||||
->requireCapability(Capabilities::TENANT_REVIEW_MANAGE)
|
|
||||||
->preserveVisibility()
|
|
||||||
->preserveDisabled()
|
|
||||||
->apply();
|
|
||||||
|
|
||||||
$exportExecutivePackAction->tooltip(fn (TenantReview $record): ?string => static::reviewPackGenerationActionTooltip($record->tenant));
|
|
||||||
|
|
||||||
return $table
|
return $table
|
||||||
->defaultSort('generated_at', 'desc')
|
->defaultSort('generated_at', 'desc')
|
||||||
->persistFiltersInSession()
|
->persistFiltersInSession()
|
||||||
@ -309,7 +287,20 @@ public static function table(Table $table): Table
|
|||||||
\App\Support\Filament\FilterPresets::dateRange('review_date', 'Review date', 'generated_at'),
|
\App\Support\Filament\FilterPresets::dateRange('review_date', 'Review date', 'generated_at'),
|
||||||
])
|
])
|
||||||
->actions([
|
->actions([
|
||||||
$exportExecutivePackAction,
|
UiEnforcement::forTableAction(
|
||||||
|
Actions\Action::make('export_executive_pack')
|
||||||
|
->label('Export executive pack')
|
||||||
|
->icon('heroicon-o-arrow-down-tray')
|
||||||
|
->visible(fn (TenantReview $record): bool => in_array($record->status, [
|
||||||
|
TenantReviewStatus::Ready->value,
|
||||||
|
TenantReviewStatus::Published->value,
|
||||||
|
], true))
|
||||||
|
->action(fn (TenantReview $record): mixed => static::executeExport($record)),
|
||||||
|
fn (TenantReview $record): TenantReview => $record,
|
||||||
|
)
|
||||||
|
->requireCapability(Capabilities::TENANT_REVIEW_MANAGE)
|
||||||
|
->preserveVisibility()
|
||||||
|
->apply(),
|
||||||
])
|
])
|
||||||
->bulkActions([])
|
->bulkActions([])
|
||||||
->emptyStateHeading('No tenant reviews yet')
|
->emptyStateHeading('No tenant reviews yet')
|
||||||
@ -432,64 +423,6 @@ public static function executeCreateReview(array $data): void
|
|||||||
$toast->send();
|
$toast->send();
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* @return array<string, mixed>
|
|
||||||
*/
|
|
||||||
public static function reviewPackGenerationDecision(?Tenant $tenant = null): array
|
|
||||||
{
|
|
||||||
$tenant ??= Filament::getTenant();
|
|
||||||
|
|
||||||
if (! $tenant instanceof Tenant) {
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
|
|
||||||
return app(ReviewPackService::class)->reviewPackGenerationDecisionForTenant($tenant);
|
|
||||||
}
|
|
||||||
|
|
||||||
public static function reviewPackGenerationBlocked(?Tenant $tenant = null): bool
|
|
||||||
{
|
|
||||||
return (bool) (static::reviewPackGenerationDecision($tenant)['is_blocked'] ?? false);
|
|
||||||
}
|
|
||||||
|
|
||||||
public static function reviewPackGenerationBlockReason(?Tenant $tenant = null): ?string
|
|
||||||
{
|
|
||||||
$decision = static::reviewPackGenerationDecision($tenant);
|
|
||||||
|
|
||||||
if (! (bool) ($decision['is_blocked'] ?? false)) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
$reason = $decision['block_reason'] ?? null;
|
|
||||||
|
|
||||||
return is_string($reason) && $reason !== '' ? $reason : null;
|
|
||||||
}
|
|
||||||
|
|
||||||
public static function reviewPackGenerationWarningReason(?Tenant $tenant = null): ?string
|
|
||||||
{
|
|
||||||
$decision = static::reviewPackGenerationDecision($tenant);
|
|
||||||
|
|
||||||
if (! (bool) ($decision['is_warning'] ?? false)) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
$reason = $decision['warning_reason'] ?? null;
|
|
||||||
|
|
||||||
return is_string($reason) && $reason !== '' ? $reason : null;
|
|
||||||
}
|
|
||||||
|
|
||||||
public static function reviewPackGenerationActionTooltip(?Tenant $tenant = null): ?string
|
|
||||||
{
|
|
||||||
$tenant ??= static::panelTenantContext();
|
|
||||||
$user = auth()->user();
|
|
||||||
|
|
||||||
if ($tenant instanceof Tenant && $user instanceof User && ! $user->can(Capabilities::TENANT_REVIEW_MANAGE, $tenant)) {
|
|
||||||
return AuthUiTooltips::insufficientPermission();
|
|
||||||
}
|
|
||||||
|
|
||||||
return static::reviewPackGenerationBlockReason($tenant)
|
|
||||||
?? static::reviewPackGenerationWarningReason($tenant);
|
|
||||||
}
|
|
||||||
|
|
||||||
public static function executeExport(TenantReview $review): void
|
public static function executeExport(TenantReview $review): void
|
||||||
{
|
{
|
||||||
$review->loadMissing(['tenant', 'currentExportReviewPack']);
|
$review->loadMissing(['tenant', 'currentExportReviewPack']);
|
||||||
@ -524,10 +457,6 @@ public static function executeExport(TenantReview $review): void
|
|||||||
'include_pii' => true,
|
'include_pii' => true,
|
||||||
'include_operations' => true,
|
'include_operations' => true,
|
||||||
]);
|
]);
|
||||||
} catch (WorkspaceEntitlementBlockedException $exception) {
|
|
||||||
Notification::make()->warning()->title('Executive pack export unavailable')->body($exception->getMessage())->send();
|
|
||||||
|
|
||||||
return;
|
|
||||||
} catch (\Throwable $throwable) {
|
} catch (\Throwable $throwable) {
|
||||||
Notification::make()->danger()->title('Unable to export executive pack')->body($throwable->getMessage())->send();
|
Notification::make()->danger()->title('Unable to export executive pack')->body($throwable->getMessage())->send();
|
||||||
|
|
||||||
@ -664,15 +593,6 @@ private static function summaryContextLinks(TenantReview $record): array
|
|||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
if ($record->tenant) {
|
|
||||||
$links[] = [
|
|
||||||
'title' => 'Customer workspace',
|
|
||||||
'label' => 'Open customer workspace',
|
|
||||||
'url' => CustomerReviewWorkspace::tenantPrefilterUrl($record->tenant),
|
|
||||||
'description' => 'Open the customer-safe review workspace prefiltered to this tenant.',
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
if ($record->evidenceSnapshot && $record->tenant) {
|
if ($record->evidenceSnapshot && $record->tenant) {
|
||||||
$links[] = [
|
$links[] = [
|
||||||
'title' => 'Evidence snapshot',
|
'title' => 'Evidence snapshot',
|
||||||
|
|||||||
@ -4,15 +4,12 @@
|
|||||||
|
|
||||||
namespace App\Filament\Resources\TenantReviewResource\Pages;
|
namespace App\Filament\Resources\TenantReviewResource\Pages;
|
||||||
|
|
||||||
use App\Filament\Pages\Reviews\CustomerReviewWorkspace;
|
|
||||||
use App\Filament\Resources\TenantReviewResource;
|
use App\Filament\Resources\TenantReviewResource;
|
||||||
use App\Models\Tenant;
|
use App\Models\Tenant;
|
||||||
use App\Models\TenantReview;
|
use App\Models\TenantReview;
|
||||||
use App\Models\User;
|
use App\Models\User;
|
||||||
use App\Services\Audit\WorkspaceAuditLogger;
|
|
||||||
use App\Services\TenantReviews\TenantReviewLifecycleService;
|
use App\Services\TenantReviews\TenantReviewLifecycleService;
|
||||||
use App\Services\TenantReviews\TenantReviewService;
|
use App\Services\TenantReviews\TenantReviewService;
|
||||||
use App\Support\Audit\AuditActionId;
|
|
||||||
use App\Support\Auth\Capabilities;
|
use App\Support\Auth\Capabilities;
|
||||||
use App\Support\Rbac\UiEnforcement;
|
use App\Support\Rbac\UiEnforcement;
|
||||||
use App\Support\TenantReviewStatus;
|
use App\Support\TenantReviewStatus;
|
||||||
@ -27,13 +24,6 @@ class ViewTenantReview extends ViewRecord
|
|||||||
{
|
{
|
||||||
protected static string $resource = TenantReviewResource::class;
|
protected static string $resource = TenantReviewResource::class;
|
||||||
|
|
||||||
public function mount(int|string $record): void
|
|
||||||
{
|
|
||||||
parent::mount($record);
|
|
||||||
|
|
||||||
$this->auditCustomerWorkspaceOpen();
|
|
||||||
}
|
|
||||||
|
|
||||||
protected function resolveRecord(int|string $key): Model
|
protected function resolveRecord(int|string $key): Model
|
||||||
{
|
{
|
||||||
return TenantReviewResource::resolveScopedRecordOrFail($key);
|
return TenantReviewResource::resolveScopedRecordOrFail($key);
|
||||||
@ -79,7 +69,7 @@ protected function getHeaderActions(): array
|
|||||||
->label('Danger')
|
->label('Danger')
|
||||||
->icon('heroicon-o-archive-box')
|
->icon('heroicon-o-archive-box')
|
||||||
->color('danger')
|
->color('danger')
|
||||||
->visible(fn (): bool => ! $this->isCustomerWorkspaceView() && ! $this->record->statusEnum()->isTerminal()),
|
->visible(fn (): bool => ! $this->record->statusEnum()->isTerminal()),
|
||||||
]));
|
]));
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -95,10 +85,6 @@ private function primaryLifecycleAction(): ?Actions\Action
|
|||||||
|
|
||||||
private function primaryLifecycleActionName(): ?string
|
private function primaryLifecycleActionName(): ?string
|
||||||
{
|
{
|
||||||
if ($this->isCustomerWorkspaceView()) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
if ((string) $this->record->status === TenantReviewStatus::Published->value) {
|
if ((string) $this->record->status === TenantReviewStatus::Published->value) {
|
||||||
return 'export_executive_pack';
|
return 'export_executive_pack';
|
||||||
}
|
}
|
||||||
@ -136,10 +122,6 @@ private function secondaryLifecycleActions(): array
|
|||||||
*/
|
*/
|
||||||
private function secondaryLifecycleActionNames(): array
|
private function secondaryLifecycleActionNames(): array
|
||||||
{
|
{
|
||||||
if ($this->isCustomerWorkspaceView()) {
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
|
|
||||||
$names = [];
|
$names = [];
|
||||||
|
|
||||||
if ($this->record->isMutable()) {
|
if ($this->record->isMutable()) {
|
||||||
@ -196,6 +178,7 @@ private function refreshReviewAction(): Actions\Action
|
|||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
->requireCapability(Capabilities::TENANT_REVIEW_MANAGE)
|
->requireCapability(Capabilities::TENANT_REVIEW_MANAGE)
|
||||||
|
->preserveVisibility()
|
||||||
->apply();
|
->apply();
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -249,7 +232,7 @@ private function publishReviewAction(): Actions\Action
|
|||||||
|
|
||||||
private function exportExecutivePackAction(): Actions\Action
|
private function exportExecutivePackAction(): Actions\Action
|
||||||
{
|
{
|
||||||
$action = UiEnforcement::forAction(
|
return UiEnforcement::forAction(
|
||||||
Actions\Action::make('export_executive_pack')
|
Actions\Action::make('export_executive_pack')
|
||||||
->label('Export executive pack')
|
->label('Export executive pack')
|
||||||
->icon('heroicon-o-arrow-down-tray')
|
->icon('heroicon-o-arrow-down-tray')
|
||||||
@ -258,17 +241,11 @@ private function exportExecutivePackAction(): Actions\Action
|
|||||||
TenantReviewStatus::Ready->value,
|
TenantReviewStatus::Ready->value,
|
||||||
TenantReviewStatus::Published->value,
|
TenantReviewStatus::Published->value,
|
||||||
], true))
|
], true))
|
||||||
->disabled(fn (): bool => TenantReviewResource::reviewPackGenerationBlocked($this->record->tenant))
|
|
||||||
->action(fn (): mixed => TenantReviewResource::executeExport($this->record)),
|
->action(fn (): mixed => TenantReviewResource::executeExport($this->record)),
|
||||||
)
|
)
|
||||||
->requireCapability(Capabilities::TENANT_REVIEW_MANAGE)
|
->requireCapability(Capabilities::TENANT_REVIEW_MANAGE)
|
||||||
->preserveVisibility()
|
->preserveVisibility()
|
||||||
->preserveDisabled()
|
|
||||||
->apply();
|
->apply();
|
||||||
|
|
||||||
$action->tooltip(fn (): ?string => TenantReviewResource::reviewPackGenerationActionTooltip($this->record->tenant));
|
|
||||||
|
|
||||||
return $action;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private function createNextReviewAction(): Actions\Action
|
private function createNextReviewAction(): Actions\Action
|
||||||
@ -342,39 +319,4 @@ private function archiveReviewAction(): Actions\Action
|
|||||||
->preserveVisibility()
|
->preserveVisibility()
|
||||||
->apply();
|
->apply();
|
||||||
}
|
}
|
||||||
|
|
||||||
private function isCustomerWorkspaceView(): bool
|
|
||||||
{
|
|
||||||
return request()->boolean(CustomerReviewWorkspace::DETAIL_CONTEXT_QUERY_KEY);
|
|
||||||
}
|
|
||||||
|
|
||||||
private function auditCustomerWorkspaceOpen(): void
|
|
||||||
{
|
|
||||||
if (! $this->isCustomerWorkspaceView()) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
$user = auth()->user();
|
|
||||||
$tenant = $this->record->tenant;
|
|
||||||
|
|
||||||
if (! $user instanceof User || ! $tenant instanceof Tenant) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
app(WorkspaceAuditLogger::class)->log(
|
|
||||||
workspace: $tenant->workspace,
|
|
||||||
action: AuditActionId::TenantReviewOpened,
|
|
||||||
context: [
|
|
||||||
'metadata' => [
|
|
||||||
'review_id' => (int) $this->record->getKey(),
|
|
||||||
'source_surface' => 'customer_review_workspace',
|
|
||||||
],
|
|
||||||
],
|
|
||||||
actor: $user,
|
|
||||||
resourceType: 'tenant_review',
|
|
||||||
resourceId: (string) $this->record->getKey(),
|
|
||||||
targetLabel: sprintf('Tenant review #%d', (int) $this->record->getKey()),
|
|
||||||
tenant: $tenant,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -9,19 +9,12 @@
|
|||||||
use App\Models\PlatformUser;
|
use App\Models\PlatformUser;
|
||||||
use App\Models\Tenant;
|
use App\Models\Tenant;
|
||||||
use App\Models\Workspace;
|
use App\Models\Workspace;
|
||||||
use App\Services\Entitlements\WorkspaceCommercialLifecycleResolver;
|
|
||||||
use App\Services\Entitlements\WorkspaceEntitlementResolver;
|
|
||||||
use App\Services\Settings\SettingsWriter;
|
|
||||||
use App\Support\Auth\PlatformCapabilities;
|
use App\Support\Auth\PlatformCapabilities;
|
||||||
use App\Support\CustomerHealth\WorkspaceHealthSummaryQuery;
|
use App\Support\CustomerHealth\WorkspaceHealthSummaryQuery;
|
||||||
use App\Support\OperationCatalog;
|
use App\Support\OperationCatalog;
|
||||||
use App\Support\System\SystemDirectoryLinks;
|
use App\Support\System\SystemDirectoryLinks;
|
||||||
use App\Support\System\SystemOperationRunLinks;
|
use App\Support\System\SystemOperationRunLinks;
|
||||||
use App\Support\SystemConsole\SystemConsoleWindow;
|
use App\Support\SystemConsole\SystemConsoleWindow;
|
||||||
use Filament\Actions\Action;
|
|
||||||
use Filament\Forms\Components\Select;
|
|
||||||
use Filament\Forms\Components\Textarea;
|
|
||||||
use Filament\Notifications\Notification;
|
|
||||||
use Filament\Pages\Page;
|
use Filament\Pages\Page;
|
||||||
use Illuminate\Support\Collection;
|
use Illuminate\Support\Collection;
|
||||||
|
|
||||||
@ -92,85 +85,6 @@ public function runsUrl(): string
|
|||||||
return SystemOperationRunLinks::index();
|
return SystemOperationRunLinks::index();
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* @return array<string, mixed>
|
|
||||||
*/
|
|
||||||
public function workspaceEntitlementSummary(): array
|
|
||||||
{
|
|
||||||
return app(WorkspaceEntitlementResolver::class)->summary($this->workspace);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @return array<string, mixed>
|
|
||||||
*/
|
|
||||||
public function workspaceCommercialLifecycleSummary(): array
|
|
||||||
{
|
|
||||||
return app(WorkspaceCommercialLifecycleResolver::class)->summary($this->workspace);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @return array<Action>
|
|
||||||
*/
|
|
||||||
protected function getHeaderActions(): array
|
|
||||||
{
|
|
||||||
return [
|
|
||||||
Action::make('change_commercial_state')
|
|
||||||
->label('Change commercial state')
|
|
||||||
->icon('heroicon-o-adjustments-horizontal')
|
|
||||||
->color('warning')
|
|
||||||
->visible(fn (): bool => $this->canManageCommercialLifecycle())
|
|
||||||
->requiresConfirmation()
|
|
||||||
->modalHeading('Change commercial state')
|
|
||||||
->modalDescription('This changes the workspace commercial lifecycle overlay. The rationale is audited and affects onboarding activation and review-pack starts.')
|
|
||||||
->form([
|
|
||||||
Select::make('state')
|
|
||||||
->label('Commercial state')
|
|
||||||
->options(WorkspaceCommercialLifecycleResolver::stateLabels())
|
|
||||||
->required()
|
|
||||||
->default(fn (): string => (string) ($this->workspaceCommercialLifecycleSummary()['state'] ?? WorkspaceCommercialLifecycleResolver::STATE_ACTIVE_PAID)),
|
|
||||||
Textarea::make('reason')
|
|
||||||
->label('Rationale')
|
|
||||||
->required()
|
|
||||||
->minLength(5)
|
|
||||||
->maxLength(500)
|
|
||||||
->rows(4),
|
|
||||||
])
|
|
||||||
->action(function (array $data, SettingsWriter $settingsWriter): void {
|
|
||||||
$actor = auth('platform')->user();
|
|
||||||
|
|
||||||
if (! $actor instanceof PlatformUser) {
|
|
||||||
abort(403);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (! $actor->hasCapability(PlatformCapabilities::COMMERCIAL_LIFECYCLE_MANAGE)) {
|
|
||||||
abort(403);
|
|
||||||
}
|
|
||||||
|
|
||||||
$settingsWriter->updateWorkspaceCommercialLifecycle(
|
|
||||||
actor: $actor,
|
|
||||||
workspace: $this->workspace,
|
|
||||||
state: (string) ($data['state'] ?? ''),
|
|
||||||
reason: (string) ($data['reason'] ?? ''),
|
|
||||||
);
|
|
||||||
|
|
||||||
$this->workspace->refresh();
|
|
||||||
|
|
||||||
Notification::make()
|
|
||||||
->title('Commercial state updated')
|
|
||||||
->success()
|
|
||||||
->send();
|
|
||||||
}),
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
private function canManageCommercialLifecycle(): bool
|
|
||||||
{
|
|
||||||
$user = auth('platform')->user();
|
|
||||||
|
|
||||||
return $user instanceof PlatformUser
|
|
||||||
&& $user->hasCapability(PlatformCapabilities::COMMERCIAL_LIFECYCLE_MANAGE);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @return array{
|
* @return array{
|
||||||
* overall: array{label: string, color: string, icon: string|null},
|
* overall: array{label: string, color: string, icon: string|null},
|
||||||
|
|||||||
@ -80,9 +80,6 @@ protected function getHeaderActions(): array
|
|||||||
$this->pauseRestoreExecuteAction(),
|
$this->pauseRestoreExecuteAction(),
|
||||||
$this->resumeRestoreExecuteAction(),
|
$this->resumeRestoreExecuteAction(),
|
||||||
$this->viewHistoryRestoreExecuteAction(),
|
$this->viewHistoryRestoreExecuteAction(),
|
||||||
$this->pauseAiExecutionAction(),
|
|
||||||
$this->resumeAiExecutionAction(),
|
|
||||||
$this->viewHistoryAiExecutionAction(),
|
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -202,21 +199,6 @@ public function viewHistoryRestoreExecuteAction(): Action
|
|||||||
return $this->historyActionFor('restore.execute');
|
return $this->historyActionFor('restore.execute');
|
||||||
}
|
}
|
||||||
|
|
||||||
public function pauseAiExecutionAction(): Action
|
|
||||||
{
|
|
||||||
return $this->pauseActionFor('ai.execution');
|
|
||||||
}
|
|
||||||
|
|
||||||
public function resumeAiExecutionAction(): Action
|
|
||||||
{
|
|
||||||
return $this->resumeActionFor('ai.execution');
|
|
||||||
}
|
|
||||||
|
|
||||||
public function viewHistoryAiExecutionAction(): Action
|
|
||||||
{
|
|
||||||
return $this->historyActionFor('ai.execution');
|
|
||||||
}
|
|
||||||
|
|
||||||
private function pauseActionFor(string $controlKey): Action
|
private function pauseActionFor(string $controlKey): Action
|
||||||
{
|
{
|
||||||
$label = app(OperationalControlCatalog::class)->label($controlKey);
|
$label = app(OperationalControlCatalog::class)->label($controlKey);
|
||||||
@ -231,7 +213,7 @@ private function pauseActionFor(string $controlKey): Action
|
|||||||
->form($this->pauseFormSchema($controlKey))
|
->form($this->pauseFormSchema($controlKey))
|
||||||
->action(function (array $data, AuditRecorder $auditRecorder, WorkspaceAuditLogger $workspaceAuditLogger) use ($controlKey, $label): void {
|
->action(function (array $data, AuditRecorder $auditRecorder, WorkspaceAuditLogger $workspaceAuditLogger) use ($controlKey, $label): void {
|
||||||
$actor = $this->controlsActor();
|
$actor = $this->controlsActor();
|
||||||
[$scopeType, $workspace, $reasonText, $expiresAt] = $this->normalizePauseInput($controlKey, $data);
|
[$scopeType, $workspace, $reasonText, $expiresAt] = $this->normalizePauseInput($data);
|
||||||
|
|
||||||
$scopeQuery = $this->activationScopeQuery($controlKey, $scopeType, $workspace);
|
$scopeQuery = $this->activationScopeQuery($controlKey, $scopeType, $workspace);
|
||||||
|
|
||||||
@ -291,7 +273,7 @@ private function resumeActionFor(string $controlKey): Action
|
|||||||
->form($this->resumeFormSchema($controlKey))
|
->form($this->resumeFormSchema($controlKey))
|
||||||
->action(function (array $data, AuditRecorder $auditRecorder, WorkspaceAuditLogger $workspaceAuditLogger) use ($controlKey, $label): void {
|
->action(function (array $data, AuditRecorder $auditRecorder, WorkspaceAuditLogger $workspaceAuditLogger) use ($controlKey, $label): void {
|
||||||
$actor = $this->controlsActor();
|
$actor = $this->controlsActor();
|
||||||
[$scopeType, $workspace] = $this->normalizeResumeInput($controlKey, $data);
|
[$scopeType, $workspace] = $this->normalizeResumeInput($data);
|
||||||
|
|
||||||
$activation = $this->activationScopeQuery($controlKey, $scopeType, $workspace)
|
$activation = $this->activationScopeQuery($controlKey, $scopeType, $workspace)
|
||||||
->notExpired()
|
->notExpired()
|
||||||
@ -349,8 +331,11 @@ private function pauseFormSchema(string $controlKey): array
|
|||||||
return [
|
return [
|
||||||
Radio::make('scope_type')
|
Radio::make('scope_type')
|
||||||
->label('Scope')
|
->label('Scope')
|
||||||
->options($this->scopeOptions($controlKey))
|
->options([
|
||||||
->default($this->defaultScopeFor($controlKey))
|
'global' => 'Global',
|
||||||
|
'workspace' => 'One workspace',
|
||||||
|
])
|
||||||
|
->default('global')
|
||||||
->live()
|
->live()
|
||||||
->required(),
|
->required(),
|
||||||
|
|
||||||
@ -410,8 +395,11 @@ private function resumeFormSchema(string $controlKey): array
|
|||||||
return [
|
return [
|
||||||
Radio::make('scope_type')
|
Radio::make('scope_type')
|
||||||
->label('Scope')
|
->label('Scope')
|
||||||
->options($this->scopeOptions($controlKey))
|
->options([
|
||||||
->default($this->defaultScopeFor($controlKey))
|
'global' => 'Global',
|
||||||
|
'workspace' => 'One workspace',
|
||||||
|
])
|
||||||
|
->default('global')
|
||||||
->live()
|
->live()
|
||||||
->required(),
|
->required(),
|
||||||
|
|
||||||
@ -468,9 +456,9 @@ private function controlsActor(): PlatformUser
|
|||||||
/**
|
/**
|
||||||
* @return array{0: string, 1: ?Workspace, 2: string, 3: ?CarbonInterface}
|
* @return array{0: string, 1: ?Workspace, 2: string, 3: ?CarbonInterface}
|
||||||
*/
|
*/
|
||||||
private function normalizePauseInput(string $controlKey, array $data): array
|
private function normalizePauseInput(array $data): array
|
||||||
{
|
{
|
||||||
[$scopeType, $workspace] = $this->resolveScopeInput($controlKey, $data);
|
[$scopeType, $workspace] = $this->resolveScopeInput($data);
|
||||||
$reasonText = trim((string) ($data['reason_text'] ?? ''));
|
$reasonText = trim((string) ($data['reason_text'] ?? ''));
|
||||||
|
|
||||||
if ($reasonText === '') {
|
if ($reasonText === '') {
|
||||||
@ -497,20 +485,19 @@ private function normalizePauseInput(string $controlKey, array $data): array
|
|||||||
/**
|
/**
|
||||||
* @return array{0: string, 1: ?Workspace}
|
* @return array{0: string, 1: ?Workspace}
|
||||||
*/
|
*/
|
||||||
private function normalizeResumeInput(string $controlKey, array $data): array
|
private function normalizeResumeInput(array $data): array
|
||||||
{
|
{
|
||||||
return $this->resolveScopeInput($controlKey, $data);
|
return $this->resolveScopeInput($data);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @return array{0: string, 1: ?Workspace}
|
* @return array{0: string, 1: ?Workspace}
|
||||||
*/
|
*/
|
||||||
private function resolveScopeInput(string $controlKey, array $data): array
|
private function resolveScopeInput(array $data): array
|
||||||
{
|
{
|
||||||
$scopeType = (string) ($data['scope_type'] ?? 'global');
|
$scopeType = (string) ($data['scope_type'] ?? 'global');
|
||||||
$supportedScopes = app(OperationalControlCatalog::class)->definition($controlKey)['supported_scopes'] ?? ['global'];
|
|
||||||
|
|
||||||
if (! in_array($scopeType, $supportedScopes, true)) {
|
if (! in_array($scopeType, ['global', 'workspace'], true)) {
|
||||||
throw ValidationException::withMessages([
|
throw ValidationException::withMessages([
|
||||||
'scope_type' => 'Invalid scope selected.',
|
'scope_type' => 'Invalid scope selected.',
|
||||||
]);
|
]);
|
||||||
@ -539,26 +526,6 @@ private function resolveScopeInput(string $controlKey, array $data): array
|
|||||||
return [$scopeType, $workspace];
|
return [$scopeType, $workspace];
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* @return array<string, string>
|
|
||||||
*/
|
|
||||||
private function scopeOptions(string $controlKey): array
|
|
||||||
{
|
|
||||||
$supportedScopes = app(OperationalControlCatalog::class)->definition($controlKey)['supported_scopes'];
|
|
||||||
|
|
||||||
return Arr::only([
|
|
||||||
'global' => 'Global',
|
|
||||||
'workspace' => 'One workspace',
|
|
||||||
], $supportedScopes);
|
|
||||||
}
|
|
||||||
|
|
||||||
private function defaultScopeFor(string $controlKey): string
|
|
||||||
{
|
|
||||||
$supportedScopes = app(OperationalControlCatalog::class)->definition($controlKey)['supported_scopes'];
|
|
||||||
|
|
||||||
return $supportedScopes[0] ?? 'global';
|
|
||||||
}
|
|
||||||
|
|
||||||
private function activationScopeQuery(string $controlKey, string $scopeType, ?Workspace $workspace): \Illuminate\Database\Eloquent\Builder
|
private function activationScopeQuery(string $controlKey, string $scopeType, ?Workspace $workspace): \Illuminate\Database\Eloquent\Builder
|
||||||
{
|
{
|
||||||
$query = OperationalControlActivation::query()
|
$query = OperationalControlActivation::query()
|
||||||
|
|||||||
@ -4,8 +4,6 @@
|
|||||||
|
|
||||||
namespace App\Filament\Widgets\Tenant;
|
namespace App\Filament\Widgets\Tenant;
|
||||||
|
|
||||||
use App\Exceptions\Entitlements\WorkspaceEntitlementBlockedException;
|
|
||||||
use App\Filament\Pages\Reviews\CustomerReviewWorkspace;
|
|
||||||
use App\Models\OperationRun;
|
use App\Models\OperationRun;
|
||||||
use App\Models\ReviewPack;
|
use App\Models\ReviewPack;
|
||||||
use App\Models\Tenant;
|
use App\Models\Tenant;
|
||||||
@ -20,7 +18,6 @@
|
|||||||
use App\Support\ReviewPackStatus;
|
use App\Support\ReviewPackStatus;
|
||||||
use Filament\Actions\Action;
|
use Filament\Actions\Action;
|
||||||
use Filament\Facades\Filament;
|
use Filament\Facades\Filament;
|
||||||
use Filament\Notifications\Notification;
|
|
||||||
use Filament\Widgets\Widget;
|
use Filament\Widgets\Widget;
|
||||||
|
|
||||||
class TenantReviewPackCard extends Widget
|
class TenantReviewPackCard extends Widget
|
||||||
@ -69,26 +66,6 @@ public function generatePack(bool $includePii = true, bool $includeOperations =
|
|||||||
/** @var ReviewPackService $service */
|
/** @var ReviewPackService $service */
|
||||||
$service = app(ReviewPackService::class);
|
$service = app(ReviewPackService::class);
|
||||||
|
|
||||||
$decision = $service->reviewPackGenerationDecisionForTenant($tenant);
|
|
||||||
|
|
||||||
if ((bool) ($decision['is_blocked'] ?? false)) {
|
|
||||||
Notification::make()
|
|
||||||
->title('Review pack generation unavailable')
|
|
||||||
->body((string) ($decision['block_reason'] ?? 'Workspace entitlement currently blocks review pack generation.'))
|
|
||||||
->warning()
|
|
||||||
->send();
|
|
||||||
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if ((bool) ($decision['is_warning'] ?? false) && is_string($decision['warning_reason'] ?? null)) {
|
|
||||||
Notification::make()
|
|
||||||
->title('Review pack generation allowed with warning')
|
|
||||||
->body($decision['warning_reason'])
|
|
||||||
->warning()
|
|
||||||
->send();
|
|
||||||
}
|
|
||||||
|
|
||||||
$activeRun = $service->checkActiveRun($tenant)
|
$activeRun = $service->checkActiveRun($tenant)
|
||||||
? OperationRun::query()
|
? OperationRun::query()
|
||||||
->where('tenant_id', (int) $tenant->getKey())
|
->where('tenant_id', (int) $tenant->getKey())
|
||||||
@ -113,20 +90,10 @@ public function generatePack(bool $includePii = true, bool $includeOperations =
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
$reviewPack = $service->generate($tenant, $user, [
|
||||||
$reviewPack = $service->generate($tenant, $user, [
|
'include_pii' => $includePii,
|
||||||
'include_pii' => $includePii,
|
'include_operations' => $includeOperations,
|
||||||
'include_operations' => $includeOperations,
|
]);
|
||||||
]);
|
|
||||||
} catch (WorkspaceEntitlementBlockedException $exception) {
|
|
||||||
Notification::make()
|
|
||||||
->title('Review pack generation unavailable')
|
|
||||||
->body($exception->getMessage())
|
|
||||||
->warning()
|
|
||||||
->send();
|
|
||||||
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
$runUrl = $reviewPack->operationRun
|
$runUrl = $reviewPack->operationRun
|
||||||
? OperationRunLinks::tenantlessView($reviewPack->operationRun)
|
? OperationRunLinks::tenantlessView($reviewPack->operationRun)
|
||||||
@ -163,17 +130,6 @@ protected function getViewData(): array
|
|||||||
$isTenantMember = $user instanceof User && $user->canAccessTenant($tenant);
|
$isTenantMember = $user instanceof User && $user->canAccessTenant($tenant);
|
||||||
$canView = $isTenantMember && $user->can(Capabilities::REVIEW_PACK_VIEW, $tenant);
|
$canView = $isTenantMember && $user->can(Capabilities::REVIEW_PACK_VIEW, $tenant);
|
||||||
$canManage = $isTenantMember && $user->can(Capabilities::REVIEW_PACK_MANAGE, $tenant);
|
$canManage = $isTenantMember && $user->can(Capabilities::REVIEW_PACK_MANAGE, $tenant);
|
||||||
$service = app(ReviewPackService::class);
|
|
||||||
$generationEntitlement = $canManage
|
|
||||||
? $service->reviewPackGenerationDecisionForTenant($tenant)
|
|
||||||
: null;
|
|
||||||
$generationBlocked = (bool) ($generationEntitlement['is_blocked'] ?? false);
|
|
||||||
$generationBlockReason = is_string($generationEntitlement['block_reason'] ?? null)
|
|
||||||
? $generationEntitlement['block_reason']
|
|
||||||
: null;
|
|
||||||
$generationWarningReason = is_string($generationEntitlement['warning_reason'] ?? null)
|
|
||||||
? $generationEntitlement['warning_reason']
|
|
||||||
: null;
|
|
||||||
|
|
||||||
$latestPack = ReviewPack::query()
|
$latestPack = ReviewPack::query()
|
||||||
->with(['tenantReview', 'operationRun'])
|
->with(['tenantReview', 'operationRun'])
|
||||||
@ -190,10 +146,6 @@ protected function getViewData(): array
|
|||||||
'pollingInterval' => null,
|
'pollingInterval' => null,
|
||||||
'canView' => $canView,
|
'canView' => $canView,
|
||||||
'canManage' => $canManage,
|
'canManage' => $canManage,
|
||||||
'generationBlocked' => $generationBlocked,
|
|
||||||
'generationBlockReason' => $generationBlockReason,
|
|
||||||
'generationWarningReason' => $generationWarningReason,
|
|
||||||
'customerWorkspaceUrl' => $canView ? CustomerReviewWorkspace::tenantPrefilterUrl($tenant) : null,
|
|
||||||
'downloadUrl' => null,
|
'downloadUrl' => null,
|
||||||
'failedReason' => null,
|
'failedReason' => null,
|
||||||
'reviewUrl' => null,
|
'reviewUrl' => null,
|
||||||
@ -242,10 +194,6 @@ protected function getViewData(): array
|
|||||||
'pollingInterval' => self::resolvePollingInterval($latestPack),
|
'pollingInterval' => self::resolvePollingInterval($latestPack),
|
||||||
'canView' => $canView,
|
'canView' => $canView,
|
||||||
'canManage' => $canManage,
|
'canManage' => $canManage,
|
||||||
'generationBlocked' => $generationBlocked,
|
|
||||||
'generationBlockReason' => $generationBlockReason,
|
|
||||||
'generationWarningReason' => $generationWarningReason,
|
|
||||||
'customerWorkspaceUrl' => $canView ? CustomerReviewWorkspace::tenantPrefilterUrl($tenant) : null,
|
|
||||||
'downloadUrl' => $downloadUrl,
|
'downloadUrl' => $downloadUrl,
|
||||||
'failedReason' => $failedReason,
|
'failedReason' => $failedReason,
|
||||||
'failedReasonDetail' => $failedReasonDetail,
|
'failedReasonDetail' => $failedReasonDetail,
|
||||||
@ -276,10 +224,6 @@ private function emptyState(): array
|
|||||||
'pollingInterval' => null,
|
'pollingInterval' => null,
|
||||||
'canView' => false,
|
'canView' => false,
|
||||||
'canManage' => false,
|
'canManage' => false,
|
||||||
'generationBlocked' => false,
|
|
||||||
'generationBlockReason' => null,
|
|
||||||
'generationWarningReason' => null,
|
|
||||||
'customerWorkspaceUrl' => null,
|
|
||||||
'downloadUrl' => null,
|
'downloadUrl' => null,
|
||||||
'failedReason' => null,
|
'failedReason' => null,
|
||||||
'failedReasonDetail' => null,
|
'failedReasonDetail' => null,
|
||||||
|
|||||||
@ -4,12 +4,7 @@
|
|||||||
|
|
||||||
namespace App\Http\Controllers;
|
namespace App\Http\Controllers;
|
||||||
|
|
||||||
use App\Models\Tenant;
|
|
||||||
use App\Models\ReviewPack;
|
use App\Models\ReviewPack;
|
||||||
use App\Models\User;
|
|
||||||
use App\Services\Audit\WorkspaceAuditLogger;
|
|
||||||
use App\Support\Audit\AuditActionId;
|
|
||||||
use App\Support\Auth\Capabilities;
|
|
||||||
use App\Support\ReviewPackStatus;
|
use App\Support\ReviewPackStatus;
|
||||||
use Illuminate\Http\Request;
|
use Illuminate\Http\Request;
|
||||||
use Illuminate\Support\Facades\Storage;
|
use Illuminate\Support\Facades\Storage;
|
||||||
@ -20,21 +15,6 @@ class ReviewPackDownloadController extends Controller
|
|||||||
{
|
{
|
||||||
public function __invoke(Request $request, ReviewPack $reviewPack): StreamedResponse
|
public function __invoke(Request $request, ReviewPack $reviewPack): StreamedResponse
|
||||||
{
|
{
|
||||||
$user = $request->user();
|
|
||||||
$tenant = $reviewPack->tenant;
|
|
||||||
|
|
||||||
if (! $user instanceof User || ! $tenant instanceof Tenant) {
|
|
||||||
throw new NotFoundHttpException;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (! $user->canAccessTenant($tenant)) {
|
|
||||||
throw new NotFoundHttpException;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (! $user->can(Capabilities::REVIEW_PACK_VIEW, $tenant)) {
|
|
||||||
abort(403);
|
|
||||||
}
|
|
||||||
|
|
||||||
if ($reviewPack->status !== ReviewPackStatus::Ready->value) {
|
if ($reviewPack->status !== ReviewPackStatus::Ready->value) {
|
||||||
throw new NotFoundHttpException;
|
throw new NotFoundHttpException;
|
||||||
}
|
}
|
||||||
@ -49,26 +29,7 @@ public function __invoke(Request $request, ReviewPack $reviewPack): StreamedResp
|
|||||||
throw new NotFoundHttpException;
|
throw new NotFoundHttpException;
|
||||||
}
|
}
|
||||||
|
|
||||||
app(WorkspaceAuditLogger::class)->log(
|
$tenant = $reviewPack->tenant;
|
||||||
workspace: $tenant->workspace,
|
|
||||||
action: AuditActionId::ReviewPackDownloaded,
|
|
||||||
context: [
|
|
||||||
'metadata' => [
|
|
||||||
'review_pack_id' => (int) $reviewPack->getKey(),
|
|
||||||
'tenant_review_id' => $reviewPack->tenant_review_id !== null
|
|
||||||
? (int) $reviewPack->tenant_review_id
|
|
||||||
: null,
|
|
||||||
'source_surface' => (string) $request->query('source_surface', 'review_pack'),
|
|
||||||
],
|
|
||||||
],
|
|
||||||
actor: $user,
|
|
||||||
resourceType: 'review_pack',
|
|
||||||
resourceId: (string) $reviewPack->getKey(),
|
|
||||||
targetLabel: sprintf('Review pack #%d', (int) $reviewPack->getKey()),
|
|
||||||
tenant: $tenant,
|
|
||||||
operationRunId: $reviewPack->operation_run_id,
|
|
||||||
);
|
|
||||||
|
|
||||||
$filename = sprintf(
|
$filename = sprintf(
|
||||||
'review-pack-%s-%s.zip',
|
'review-pack-%s-%s.zip',
|
||||||
$tenant?->external_id ?? 'unknown',
|
$tenant?->external_id ?? 'unknown',
|
||||||
|
|||||||
@ -7,13 +7,11 @@
|
|||||||
use App\Filament\Pages\ChooseWorkspace;
|
use App\Filament\Pages\ChooseWorkspace;
|
||||||
use App\Filament\Pages\Findings\FindingsHygieneReport;
|
use App\Filament\Pages\Findings\FindingsHygieneReport;
|
||||||
use App\Filament\Pages\Findings\FindingsIntakeQueue;
|
use App\Filament\Pages\Findings\FindingsIntakeQueue;
|
||||||
use App\Filament\Pages\Governance\GovernanceInbox;
|
|
||||||
use App\Filament\Pages\Findings\MyFindingsInbox;
|
use App\Filament\Pages\Findings\MyFindingsInbox;
|
||||||
use App\Filament\Pages\InventoryCoverage;
|
use App\Filament\Pages\InventoryCoverage;
|
||||||
use App\Filament\Pages\Monitoring\FindingExceptionsQueue;
|
use App\Filament\Pages\Monitoring\FindingExceptionsQueue;
|
||||||
use App\Filament\Pages\NoAccess;
|
use App\Filament\Pages\NoAccess;
|
||||||
use App\Filament\Pages\Reviews\ReviewRegister;
|
use App\Filament\Pages\Reviews\ReviewRegister;
|
||||||
use App\Filament\Pages\Reviews\CustomerReviewWorkspace;
|
|
||||||
use App\Filament\Pages\Settings\WorkspaceSettings;
|
use App\Filament\Pages\Settings\WorkspaceSettings;
|
||||||
use App\Filament\Pages\TenantRequiredPermissions;
|
use App\Filament\Pages\TenantRequiredPermissions;
|
||||||
use App\Filament\Pages\WorkspaceOverview;
|
use App\Filament\Pages\WorkspaceOverview;
|
||||||
@ -181,12 +179,10 @@ public function panel(Panel $panel): Panel
|
|||||||
InventoryCoverage::class,
|
InventoryCoverage::class,
|
||||||
TenantRequiredPermissions::class,
|
TenantRequiredPermissions::class,
|
||||||
WorkspaceSettings::class,
|
WorkspaceSettings::class,
|
||||||
GovernanceInbox::class,
|
|
||||||
FindingsHygieneReport::class,
|
FindingsHygieneReport::class,
|
||||||
FindingsIntakeQueue::class,
|
FindingsIntakeQueue::class,
|
||||||
MyFindingsInbox::class,
|
MyFindingsInbox::class,
|
||||||
FindingExceptionsQueue::class,
|
FindingExceptionsQueue::class,
|
||||||
CustomerReviewWorkspace::class,
|
|
||||||
ReviewRegister::class,
|
ReviewRegister::class,
|
||||||
])
|
])
|
||||||
->widgets([
|
->widgets([
|
||||||
|
|||||||
@ -1,410 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
namespace App\Services\Entitlements;
|
|
||||||
|
|
||||||
use App\Models\AuditLog;
|
|
||||||
use App\Models\Tenant;
|
|
||||||
use App\Models\Workspace;
|
|
||||||
use App\Models\WorkspaceSetting;
|
|
||||||
use App\Services\Settings\SettingsResolver;
|
|
||||||
use App\Support\Audit\AuditActionId;
|
|
||||||
use Carbon\CarbonInterface;
|
|
||||||
|
|
||||||
final class WorkspaceCommercialLifecycleResolver
|
|
||||||
{
|
|
||||||
public const SETTING_DOMAIN = WorkspaceEntitlementResolver::SETTING_DOMAIN;
|
|
||||||
|
|
||||||
public const SETTING_COMMERCIAL_LIFECYCLE_STATE = 'commercial_lifecycle_state';
|
|
||||||
|
|
||||||
public const SETTING_COMMERCIAL_LIFECYCLE_REASON = 'commercial_lifecycle_reason';
|
|
||||||
|
|
||||||
public const STATE_TRIAL = 'trial';
|
|
||||||
|
|
||||||
public const STATE_GRACE = 'grace';
|
|
||||||
|
|
||||||
public const STATE_ACTIVE_PAID = 'active_paid';
|
|
||||||
|
|
||||||
public const STATE_SUSPENDED_READ_ONLY = 'suspended_read_only';
|
|
||||||
|
|
||||||
public const SOURCE_DEFAULT_ACTIVE_PAID = 'default_active_paid';
|
|
||||||
|
|
||||||
public const SOURCE_WORKSPACE_SETTING = 'workspace_setting';
|
|
||||||
|
|
||||||
public const ACTION_MANAGED_TENANT_ACTIVATION = 'managed_tenant_activation';
|
|
||||||
|
|
||||||
public const ACTION_REVIEW_PACK_START = 'review_pack_start';
|
|
||||||
|
|
||||||
public const ACTION_REVIEW_HISTORY_READ = 'review_history_read';
|
|
||||||
|
|
||||||
public const ACTION_EVIDENCE_READ = 'evidence_read';
|
|
||||||
|
|
||||||
public const ACTION_GENERATED_PACK_READ = 'generated_pack_read';
|
|
||||||
|
|
||||||
public const OUTCOME_ALLOW = 'allow';
|
|
||||||
|
|
||||||
public const OUTCOME_WARN = 'warn';
|
|
||||||
|
|
||||||
public const OUTCOME_BLOCK = 'block';
|
|
||||||
|
|
||||||
public const OUTCOME_ALLOW_READ_ONLY = 'allow_read_only';
|
|
||||||
|
|
||||||
public const REASON_FAMILY_ENTITLEMENT_SUBSTRATE = 'entitlement_substrate';
|
|
||||||
|
|
||||||
public const REASON_FAMILY_COMMERCIAL_LIFECYCLE = 'commercial_lifecycle';
|
|
||||||
|
|
||||||
public function __construct(
|
|
||||||
private readonly SettingsResolver $settingsResolver,
|
|
||||||
private readonly WorkspaceEntitlementResolver $workspaceEntitlementResolver,
|
|
||||||
) {}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @return list<string>
|
|
||||||
*/
|
|
||||||
public static function stateIds(): array
|
|
||||||
{
|
|
||||||
return [
|
|
||||||
self::STATE_TRIAL,
|
|
||||||
self::STATE_GRACE,
|
|
||||||
self::STATE_ACTIVE_PAID,
|
|
||||||
self::STATE_SUSPENDED_READ_ONLY,
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @return array<string, string>
|
|
||||||
*/
|
|
||||||
public static function stateLabels(): array
|
|
||||||
{
|
|
||||||
return [
|
|
||||||
self::STATE_TRIAL => 'Trial',
|
|
||||||
self::STATE_GRACE => 'Grace',
|
|
||||||
self::STATE_ACTIVE_PAID => 'Active paid',
|
|
||||||
self::STATE_SUSPENDED_READ_ONLY => 'Suspended / read-only',
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @return array<string, string>
|
|
||||||
*/
|
|
||||||
public static function stateDescriptions(): array
|
|
||||||
{
|
|
||||||
return [
|
|
||||||
self::STATE_TRIAL => 'Expansion and review-pack starts are available while the workspace is in trial.',
|
|
||||||
self::STATE_GRACE => 'New managed-tenant activation is frozen, but review-pack starts remain available with a warning.',
|
|
||||||
self::STATE_ACTIVE_PAID => 'Expansion and review-pack starts are available for the active paid workspace.',
|
|
||||||
self::STATE_SUSPENDED_READ_ONLY => 'New activation and review-pack starts are blocked while existing review, evidence, and generated-pack access remains read-only.',
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @return array<string, mixed>
|
|
||||||
*/
|
|
||||||
public function summary(Workspace $workspace): array
|
|
||||||
{
|
|
||||||
$lifecycle = $this->resolve($workspace);
|
|
||||||
|
|
||||||
return $lifecycle + [
|
|
||||||
'entitlement_summary' => $this->workspaceEntitlementResolver->summary($workspace),
|
|
||||||
'action_decisions' => [
|
|
||||||
self::ACTION_MANAGED_TENANT_ACTIVATION => $this->actionDecision($workspace, self::ACTION_MANAGED_TENANT_ACTIVATION, $lifecycle),
|
|
||||||
self::ACTION_REVIEW_PACK_START => $this->actionDecision($workspace, self::ACTION_REVIEW_PACK_START, $lifecycle),
|
|
||||||
self::ACTION_REVIEW_HISTORY_READ => $this->actionDecision($workspace, self::ACTION_REVIEW_HISTORY_READ, $lifecycle),
|
|
||||||
self::ACTION_EVIDENCE_READ => $this->actionDecision($workspace, self::ACTION_EVIDENCE_READ, $lifecycle),
|
|
||||||
self::ACTION_GENERATED_PACK_READ => $this->actionDecision($workspace, self::ACTION_GENERATED_PACK_READ, $lifecycle),
|
|
||||||
],
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @return array{
|
|
||||||
* workspace_id: int,
|
|
||||||
* state: string,
|
|
||||||
* state_label: string,
|
|
||||||
* source: string,
|
|
||||||
* source_label: string,
|
|
||||||
* rationale: string|null,
|
|
||||||
* description: string,
|
|
||||||
* last_changed_at: CarbonInterface|null,
|
|
||||||
* last_changed_by: string|null
|
|
||||||
* }
|
|
||||||
*/
|
|
||||||
public function resolve(Workspace $workspace): array
|
|
||||||
{
|
|
||||||
$stateSetting = $this->settingsResolver->resolveDetailed(
|
|
||||||
workspace: $workspace,
|
|
||||||
domain: self::SETTING_DOMAIN,
|
|
||||||
key: self::SETTING_COMMERCIAL_LIFECYCLE_STATE,
|
|
||||||
);
|
|
||||||
|
|
||||||
$rawState = is_string($stateSetting['value'] ?? null)
|
|
||||||
? strtolower(trim((string) $stateSetting['value']))
|
|
||||||
: null;
|
|
||||||
|
|
||||||
$state = in_array($rawState, self::stateIds(), true)
|
|
||||||
? $rawState
|
|
||||||
: self::STATE_ACTIVE_PAID;
|
|
||||||
|
|
||||||
$source = ($stateSetting['source'] ?? null) === 'workspace_override' && $rawState !== null
|
|
||||||
? self::SOURCE_WORKSPACE_SETTING
|
|
||||||
: self::SOURCE_DEFAULT_ACTIVE_PAID;
|
|
||||||
|
|
||||||
$rationale = $this->settingsResolver->resolveValue(
|
|
||||||
workspace: $workspace,
|
|
||||||
domain: self::SETTING_DOMAIN,
|
|
||||||
key: self::SETTING_COMMERCIAL_LIFECYCLE_REASON,
|
|
||||||
);
|
|
||||||
|
|
||||||
$labels = self::stateLabels();
|
|
||||||
$descriptions = self::stateDescriptions();
|
|
||||||
$lastChanged = $this->lastChangedMetadata($workspace);
|
|
||||||
|
|
||||||
return [
|
|
||||||
'workspace_id' => (int) $workspace->getKey(),
|
|
||||||
'state' => $state,
|
|
||||||
'state_label' => $labels[$state],
|
|
||||||
'source' => $source,
|
|
||||||
'source_label' => $source === self::SOURCE_WORKSPACE_SETTING
|
|
||||||
? 'workspace setting'
|
|
||||||
: 'default active paid',
|
|
||||||
'rationale' => is_string($rationale) && trim($rationale) !== '' ? trim($rationale) : null,
|
|
||||||
'description' => $descriptions[$state],
|
|
||||||
'last_changed_at' => $lastChanged['last_changed_at'],
|
|
||||||
'last_changed_by' => $lastChanged['last_changed_by'],
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @param array<string, mixed>|null $lifecycle
|
|
||||||
* @return array<string, mixed>
|
|
||||||
*/
|
|
||||||
public function actionDecision(Workspace $workspace, string $actionKey, ?array $lifecycle = null): array
|
|
||||||
{
|
|
||||||
$lifecycle ??= $this->resolve($workspace);
|
|
||||||
|
|
||||||
return match ($actionKey) {
|
|
||||||
self::ACTION_MANAGED_TENANT_ACTIVATION => $this->managedTenantActivationDecision($workspace, $lifecycle),
|
|
||||||
self::ACTION_REVIEW_PACK_START => $this->reviewPackStartDecision($workspace, $lifecycle),
|
|
||||||
self::ACTION_REVIEW_HISTORY_READ,
|
|
||||||
self::ACTION_EVIDENCE_READ,
|
|
||||||
self::ACTION_GENERATED_PACK_READ => $this->readOnlyDecision($actionKey, $lifecycle),
|
|
||||||
default => throw new \InvalidArgumentException(sprintf('Unknown commercial lifecycle action key: %s', $actionKey)),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @return array<string, mixed>
|
|
||||||
*/
|
|
||||||
public function reviewPackStartDecisionForTenant(Tenant $tenant): array
|
|
||||||
{
|
|
||||||
$tenant->loadMissing('workspace');
|
|
||||||
|
|
||||||
return $this->actionDecision($tenant->workspace, self::ACTION_REVIEW_PACK_START);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @param array<string, mixed> $lifecycle
|
|
||||||
* @return array<string, mixed>
|
|
||||||
*/
|
|
||||||
private function managedTenantActivationDecision(Workspace $workspace, array $lifecycle): array
|
|
||||||
{
|
|
||||||
$substrateDecision = $this->workspaceEntitlementResolver->resolve(
|
|
||||||
$workspace,
|
|
||||||
WorkspaceEntitlementResolver::KEY_MANAGED_TENANT_ACTIVATION_LIMIT,
|
|
||||||
);
|
|
||||||
|
|
||||||
if ((bool) ($substrateDecision['is_blocked'] ?? false)) {
|
|
||||||
return $this->decision(
|
|
||||||
lifecycle: $lifecycle,
|
|
||||||
actionKey: self::ACTION_MANAGED_TENANT_ACTIVATION,
|
|
||||||
outcome: self::OUTCOME_BLOCK,
|
|
||||||
reasonFamily: self::REASON_FAMILY_ENTITLEMENT_SUBSTRATE,
|
|
||||||
message: (string) ($substrateDecision['block_reason'] ?? 'Workspace entitlement currently blocks managed tenant activation.'),
|
|
||||||
substrateDecision: $substrateDecision,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return match ($lifecycle['state']) {
|
|
||||||
self::STATE_GRACE => $this->decision(
|
|
||||||
lifecycle: $lifecycle,
|
|
||||||
actionKey: self::ACTION_MANAGED_TENANT_ACTIVATION,
|
|
||||||
outcome: self::OUTCOME_BLOCK,
|
|
||||||
reasonFamily: self::REASON_FAMILY_COMMERCIAL_LIFECYCLE,
|
|
||||||
message: 'New managed-tenant activation is frozen while this workspace is in grace.',
|
|
||||||
substrateDecision: $substrateDecision,
|
|
||||||
),
|
|
||||||
self::STATE_SUSPENDED_READ_ONLY => $this->decision(
|
|
||||||
lifecycle: $lifecycle,
|
|
||||||
actionKey: self::ACTION_MANAGED_TENANT_ACTIVATION,
|
|
||||||
outcome: self::OUTCOME_BLOCK,
|
|
||||||
reasonFamily: self::REASON_FAMILY_COMMERCIAL_LIFECYCLE,
|
|
||||||
message: 'This workspace is suspended / read-only. New managed-tenant activation is blocked, but existing review and evidence history remains available.',
|
|
||||||
substrateDecision: $substrateDecision,
|
|
||||||
),
|
|
||||||
default => $this->decision(
|
|
||||||
lifecycle: $lifecycle,
|
|
||||||
actionKey: self::ACTION_MANAGED_TENANT_ACTIVATION,
|
|
||||||
outcome: self::OUTCOME_ALLOW,
|
|
||||||
reasonFamily: null,
|
|
||||||
message: 'Managed-tenant activation is available for this workspace commercial state.',
|
|
||||||
substrateDecision: $substrateDecision,
|
|
||||||
),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @param array<string, mixed> $lifecycle
|
|
||||||
* @return array<string, mixed>
|
|
||||||
*/
|
|
||||||
private function reviewPackStartDecision(Workspace $workspace, array $lifecycle): array
|
|
||||||
{
|
|
||||||
$substrateDecision = $this->workspaceEntitlementResolver->resolve(
|
|
||||||
$workspace,
|
|
||||||
WorkspaceEntitlementResolver::KEY_REVIEW_PACK_GENERATION_ENABLED,
|
|
||||||
);
|
|
||||||
|
|
||||||
if ((bool) ($substrateDecision['is_blocked'] ?? false)) {
|
|
||||||
return $this->decision(
|
|
||||||
lifecycle: $lifecycle,
|
|
||||||
actionKey: self::ACTION_REVIEW_PACK_START,
|
|
||||||
outcome: self::OUTCOME_BLOCK,
|
|
||||||
reasonFamily: self::REASON_FAMILY_ENTITLEMENT_SUBSTRATE,
|
|
||||||
message: (string) ($substrateDecision['block_reason'] ?? 'Workspace entitlement currently blocks review pack generation.'),
|
|
||||||
substrateDecision: $substrateDecision,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return match ($lifecycle['state']) {
|
|
||||||
self::STATE_GRACE => $this->decision(
|
|
||||||
lifecycle: $lifecycle,
|
|
||||||
actionKey: self::ACTION_REVIEW_PACK_START,
|
|
||||||
outcome: self::OUTCOME_WARN,
|
|
||||||
reasonFamily: self::REASON_FAMILY_COMMERCIAL_LIFECYCLE,
|
|
||||||
message: 'Workspace is in grace. Review-pack starts remain available, but managed-tenant expansion is frozen.',
|
|
||||||
substrateDecision: $substrateDecision,
|
|
||||||
),
|
|
||||||
self::STATE_SUSPENDED_READ_ONLY => $this->decision(
|
|
||||||
lifecycle: $lifecycle,
|
|
||||||
actionKey: self::ACTION_REVIEW_PACK_START,
|
|
||||||
outcome: self::OUTCOME_BLOCK,
|
|
||||||
reasonFamily: self::REASON_FAMILY_COMMERCIAL_LIFECYCLE,
|
|
||||||
message: 'This workspace is suspended / read-only. New review-pack starts are blocked, but existing review packs, evidence, and review history remain available.',
|
|
||||||
substrateDecision: $substrateDecision,
|
|
||||||
),
|
|
||||||
default => $this->decision(
|
|
||||||
lifecycle: $lifecycle,
|
|
||||||
actionKey: self::ACTION_REVIEW_PACK_START,
|
|
||||||
outcome: self::OUTCOME_ALLOW,
|
|
||||||
reasonFamily: null,
|
|
||||||
message: 'Review-pack starts are available for this workspace commercial state.',
|
|
||||||
substrateDecision: $substrateDecision,
|
|
||||||
),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @param array<string, mixed> $lifecycle
|
|
||||||
* @return array<string, mixed>
|
|
||||||
*/
|
|
||||||
private function readOnlyDecision(string $actionKey, array $lifecycle): array
|
|
||||||
{
|
|
||||||
if (($lifecycle['state'] ?? null) === self::STATE_SUSPENDED_READ_ONLY) {
|
|
||||||
return $this->decision(
|
|
||||||
lifecycle: $lifecycle,
|
|
||||||
actionKey: $actionKey,
|
|
||||||
outcome: self::OUTCOME_ALLOW_READ_ONLY,
|
|
||||||
reasonFamily: self::REASON_FAMILY_COMMERCIAL_LIFECYCLE,
|
|
||||||
message: 'Suspended / read-only workspaces keep existing review, evidence, and generated-pack consumption available under current RBAC.',
|
|
||||||
substrateDecision: null,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return $this->decision(
|
|
||||||
lifecycle: $lifecycle,
|
|
||||||
actionKey: $actionKey,
|
|
||||||
outcome: self::OUTCOME_ALLOW,
|
|
||||||
reasonFamily: null,
|
|
||||||
message: 'Read-only history remains available under current RBAC.',
|
|
||||||
substrateDecision: null,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @param array<string, mixed> $lifecycle
|
|
||||||
* @param array<string, mixed>|null $substrateDecision
|
|
||||||
* @return array<string, mixed>
|
|
||||||
*/
|
|
||||||
private function decision(
|
|
||||||
array $lifecycle,
|
|
||||||
string $actionKey,
|
|
||||||
string $outcome,
|
|
||||||
?string $reasonFamily,
|
|
||||||
string $message,
|
|
||||||
?array $substrateDecision,
|
|
||||||
): array {
|
|
||||||
return [
|
|
||||||
'workspace_id' => (int) ($lifecycle['workspace_id'] ?? 0),
|
|
||||||
'action_key' => $actionKey,
|
|
||||||
'outcome' => $outcome,
|
|
||||||
'is_blocked' => $outcome === self::OUTCOME_BLOCK,
|
|
||||||
'is_warning' => $outcome === self::OUTCOME_WARN,
|
|
||||||
'block_reason' => $outcome === self::OUTCOME_BLOCK ? $message : null,
|
|
||||||
'warning_reason' => $outcome === self::OUTCOME_WARN ? $message : null,
|
|
||||||
'message' => $message,
|
|
||||||
'reason_family' => $reasonFamily,
|
|
||||||
'state' => (string) $lifecycle['state'],
|
|
||||||
'state_label' => (string) $lifecycle['state_label'],
|
|
||||||
'source' => (string) $lifecycle['source'],
|
|
||||||
'source_label' => (string) $lifecycle['source_label'],
|
|
||||||
'rationale' => $lifecycle['rationale'] ?? null,
|
|
||||||
'entitlement_decision' => $substrateDecision,
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @return array{last_changed_at: CarbonInterface|null, last_changed_by: string|null}
|
|
||||||
*/
|
|
||||||
private function lastChangedMetadata(Workspace $workspace): array
|
|
||||||
{
|
|
||||||
$audit = AuditLog::query()
|
|
||||||
->where('workspace_id', (int) $workspace->getKey())
|
|
||||||
->where('action', AuditActionId::WorkspaceSettingUpdated->value)
|
|
||||||
->where('resource_type', 'workspace_setting')
|
|
||||||
->where('resource_id', self::SETTING_DOMAIN.'.'.self::SETTING_COMMERCIAL_LIFECYCLE_STATE)
|
|
||||||
->latest('recorded_at')
|
|
||||||
->latest('id')
|
|
||||||
->first();
|
|
||||||
|
|
||||||
if ($audit instanceof AuditLog) {
|
|
||||||
return [
|
|
||||||
'last_changed_at' => $audit->recorded_at,
|
|
||||||
'last_changed_by' => $audit->actorDisplayLabel(),
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
$record = WorkspaceSetting::query()
|
|
||||||
->where('workspace_id', (int) $workspace->getKey())
|
|
||||||
->where('domain', self::SETTING_DOMAIN)
|
|
||||||
->whereIn('key', [
|
|
||||||
self::SETTING_COMMERCIAL_LIFECYCLE_STATE,
|
|
||||||
self::SETTING_COMMERCIAL_LIFECYCLE_REASON,
|
|
||||||
])
|
|
||||||
->with('updatedByUser:id,name')
|
|
||||||
->latest('updated_at')
|
|
||||||
->latest('id')
|
|
||||||
->first();
|
|
||||||
|
|
||||||
if (! $record instanceof WorkspaceSetting) {
|
|
||||||
return [
|
|
||||||
'last_changed_at' => null,
|
|
||||||
'last_changed_by' => null,
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
return [
|
|
||||||
'last_changed_at' => $record->updated_at,
|
|
||||||
'last_changed_by' => $record->updatedByUser?->name,
|
|
||||||
];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,327 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
namespace App\Services\Entitlements;
|
|
||||||
|
|
||||||
use App\Models\Tenant;
|
|
||||||
use App\Models\Workspace;
|
|
||||||
use App\Models\WorkspaceSetting;
|
|
||||||
use App\Services\Settings\SettingsResolver;
|
|
||||||
use Carbon\CarbonInterface;
|
|
||||||
|
|
||||||
final class WorkspaceEntitlementResolver
|
|
||||||
{
|
|
||||||
public const SETTING_DOMAIN = 'entitlements';
|
|
||||||
|
|
||||||
public const SETTING_PLAN_PROFILE = 'plan_profile';
|
|
||||||
|
|
||||||
public const SETTING_MANAGED_TENANT_LIMIT_OVERRIDE_VALUE = 'managed_tenant_limit_override_value';
|
|
||||||
|
|
||||||
public const SETTING_MANAGED_TENANT_LIMIT_OVERRIDE_REASON = 'managed_tenant_limit_override_reason';
|
|
||||||
|
|
||||||
public const SETTING_REVIEW_PACK_GENERATION_OVERRIDE_VALUE = 'review_pack_generation_override_value';
|
|
||||||
|
|
||||||
public const SETTING_REVIEW_PACK_GENERATION_OVERRIDE_REASON = 'review_pack_generation_override_reason';
|
|
||||||
|
|
||||||
public const KEY_MANAGED_TENANT_ACTIVATION_LIMIT = 'managed_tenant_activation_limit';
|
|
||||||
|
|
||||||
public const KEY_REVIEW_PACK_GENERATION_ENABLED = 'review_pack_generation_enabled';
|
|
||||||
|
|
||||||
public function __construct(
|
|
||||||
private SettingsResolver $settingsResolver,
|
|
||||||
private WorkspacePlanProfileCatalog $planProfileCatalog,
|
|
||||||
) {}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @return array{
|
|
||||||
* plan_profile: array{id: string, label: string, description: string, managed_tenant_limit_default: int, review_pack_generation_default: bool, is_default: bool},
|
|
||||||
* decisions: array<string, array{
|
|
||||||
* workspace_id: int,
|
|
||||||
* plan_profile_id: string,
|
|
||||||
* plan_profile_label: string,
|
|
||||||
* plan_profile_description: string,
|
|
||||||
* key: string,
|
|
||||||
* effective_value: int|bool,
|
|
||||||
* source: 'plan_profile_default'|'workspace_override',
|
|
||||||
* rationale: string|null,
|
|
||||||
* current_usage: int|null,
|
|
||||||
* remaining_capacity: int|null,
|
|
||||||
* is_blocked: bool,
|
|
||||||
* block_reason: string|null,
|
|
||||||
* last_changed_at: CarbonInterface|null,
|
|
||||||
* last_changed_by: string|null
|
|
||||||
* }>
|
|
||||||
* }
|
|
||||||
*/
|
|
||||||
public function summary(Workspace $workspace): array
|
|
||||||
{
|
|
||||||
$planProfile = $this->resolvePlanProfile($workspace);
|
|
||||||
|
|
||||||
return [
|
|
||||||
'plan_profile' => $planProfile,
|
|
||||||
'decisions' => [
|
|
||||||
self::KEY_MANAGED_TENANT_ACTIVATION_LIMIT => $this->resolve($workspace, self::KEY_MANAGED_TENANT_ACTIVATION_LIMIT, $planProfile),
|
|
||||||
self::KEY_REVIEW_PACK_GENERATION_ENABLED => $this->resolve($workspace, self::KEY_REVIEW_PACK_GENERATION_ENABLED, $planProfile),
|
|
||||||
],
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @return array{id: string, label: string, description: string, managed_tenant_limit_default: int, review_pack_generation_default: bool, is_default: bool}
|
|
||||||
*/
|
|
||||||
public function resolvePlanProfile(Workspace $workspace): array
|
|
||||||
{
|
|
||||||
$planProfileId = $this->settingsResolver->resolveValue(
|
|
||||||
workspace: $workspace,
|
|
||||||
domain: self::SETTING_DOMAIN,
|
|
||||||
key: self::SETTING_PLAN_PROFILE,
|
|
||||||
);
|
|
||||||
|
|
||||||
return $this->planProfileCatalog->resolve(is_string($planProfileId) ? $planProfileId : null);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @param array{id: string, label: string, description: string, managed_tenant_limit_default: int, review_pack_generation_default: bool, is_default: bool}|null $planProfile
|
|
||||||
* @return array{
|
|
||||||
* workspace_id: int,
|
|
||||||
* plan_profile_id: string,
|
|
||||||
* plan_profile_label: string,
|
|
||||||
* plan_profile_description: string,
|
|
||||||
* key: string,
|
|
||||||
* effective_value: int|bool,
|
|
||||||
* source: 'plan_profile_default'|'workspace_override',
|
|
||||||
* rationale: string|null,
|
|
||||||
* current_usage: int|null,
|
|
||||||
* remaining_capacity: int|null,
|
|
||||||
* is_blocked: bool,
|
|
||||||
* block_reason: string|null,
|
|
||||||
* last_changed_at: CarbonInterface|null,
|
|
||||||
* last_changed_by: string|null
|
|
||||||
* }
|
|
||||||
*/
|
|
||||||
public function resolve(Workspace $workspace, string $key, ?array $planProfile = null): array
|
|
||||||
{
|
|
||||||
$planProfile ??= $this->resolvePlanProfile($workspace);
|
|
||||||
$lastChanged = $this->lastChangedMetadata($workspace);
|
|
||||||
|
|
||||||
return match ($key) {
|
|
||||||
self::KEY_MANAGED_TENANT_ACTIVATION_LIMIT => $this->resolveManagedTenantActivationLimitDecision($workspace, $planProfile, $lastChanged),
|
|
||||||
self::KEY_REVIEW_PACK_GENERATION_ENABLED => $this->resolveReviewPackGenerationDecision($workspace, $planProfile, $lastChanged),
|
|
||||||
default => throw new \InvalidArgumentException(sprintf('Unknown workspace entitlement key: %s', $key)),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @param array{id: string, label: string, description: string, managed_tenant_limit_default: int, review_pack_generation_default: bool, is_default: bool} $planProfile
|
|
||||||
* @param array{last_changed_at: CarbonInterface|null, last_changed_by: string|null} $lastChanged
|
|
||||||
* @return array{
|
|
||||||
* workspace_id: int,
|
|
||||||
* plan_profile_id: string,
|
|
||||||
* plan_profile_label: string,
|
|
||||||
* plan_profile_description: string,
|
|
||||||
* key: string,
|
|
||||||
* effective_value: int,
|
|
||||||
* source: 'plan_profile_default'|'workspace_override',
|
|
||||||
* rationale: string|null,
|
|
||||||
* current_usage: int,
|
|
||||||
* remaining_capacity: int,
|
|
||||||
* is_blocked: bool,
|
|
||||||
* block_reason: string|null,
|
|
||||||
* last_changed_at: CarbonInterface|null,
|
|
||||||
* last_changed_by: string|null
|
|
||||||
* }
|
|
||||||
*/
|
|
||||||
private function resolveManagedTenantActivationLimitDecision(Workspace $workspace, array $planProfile, array $lastChanged): array
|
|
||||||
{
|
|
||||||
$overrideValue = $this->settingsResolver->resolveDetailed(
|
|
||||||
workspace: $workspace,
|
|
||||||
domain: self::SETTING_DOMAIN,
|
|
||||||
key: self::SETTING_MANAGED_TENANT_LIMIT_OVERRIDE_VALUE,
|
|
||||||
);
|
|
||||||
|
|
||||||
$overrideReason = $this->settingsResolver->resolveValue(
|
|
||||||
workspace: $workspace,
|
|
||||||
domain: self::SETTING_DOMAIN,
|
|
||||||
key: self::SETTING_MANAGED_TENANT_LIMIT_OVERRIDE_REASON,
|
|
||||||
);
|
|
||||||
|
|
||||||
$effectiveValue = is_int($overrideValue['value'])
|
|
||||||
? $overrideValue['value']
|
|
||||||
: (int) $planProfile['managed_tenant_limit_default'];
|
|
||||||
|
|
||||||
$source = $overrideValue['source'] === 'workspace_override'
|
|
||||||
? 'workspace_override'
|
|
||||||
: 'plan_profile_default';
|
|
||||||
|
|
||||||
$currentUsage = Tenant::activeQuery()
|
|
||||||
->where('workspace_id', (int) $workspace->getKey())
|
|
||||||
->count();
|
|
||||||
|
|
||||||
$remainingCapacity = $effectiveValue - $currentUsage;
|
|
||||||
$isBlocked = $currentUsage >= $effectiveValue;
|
|
||||||
$rationale = $source === 'workspace_override'
|
|
||||||
? (is_string($overrideReason) && $overrideReason !== '' ? $overrideReason : null)
|
|
||||||
: (string) $planProfile['description'];
|
|
||||||
|
|
||||||
return [
|
|
||||||
'workspace_id' => (int) $workspace->getKey(),
|
|
||||||
'plan_profile_id' => (string) $planProfile['id'],
|
|
||||||
'plan_profile_label' => (string) $planProfile['label'],
|
|
||||||
'plan_profile_description' => (string) $planProfile['description'],
|
|
||||||
'key' => self::KEY_MANAGED_TENANT_ACTIVATION_LIMIT,
|
|
||||||
'effective_value' => $effectiveValue,
|
|
||||||
'source' => $source,
|
|
||||||
'rationale' => $rationale,
|
|
||||||
'current_usage' => $currentUsage,
|
|
||||||
'remaining_capacity' => $remainingCapacity,
|
|
||||||
'is_blocked' => $isBlocked,
|
|
||||||
'block_reason' => $isBlocked
|
|
||||||
? $this->managedTenantLimitBlockReason($currentUsage, $effectiveValue, $source, $planProfile, $rationale)
|
|
||||||
: null,
|
|
||||||
'last_changed_at' => $lastChanged['last_changed_at'],
|
|
||||||
'last_changed_by' => $lastChanged['last_changed_by'],
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @param array{id: string, label: string, description: string, managed_tenant_limit_default: int, review_pack_generation_default: bool, is_default: bool} $planProfile
|
|
||||||
* @param array{last_changed_at: CarbonInterface|null, last_changed_by: string|null} $lastChanged
|
|
||||||
* @return array{
|
|
||||||
* workspace_id: int,
|
|
||||||
* plan_profile_id: string,
|
|
||||||
* plan_profile_label: string,
|
|
||||||
* plan_profile_description: string,
|
|
||||||
* key: string,
|
|
||||||
* effective_value: bool,
|
|
||||||
* source: 'plan_profile_default'|'workspace_override',
|
|
||||||
* rationale: string|null,
|
|
||||||
* current_usage: null,
|
|
||||||
* remaining_capacity: null,
|
|
||||||
* is_blocked: bool,
|
|
||||||
* block_reason: string|null,
|
|
||||||
* last_changed_at: CarbonInterface|null,
|
|
||||||
* last_changed_by: string|null
|
|
||||||
* }
|
|
||||||
*/
|
|
||||||
private function resolveReviewPackGenerationDecision(Workspace $workspace, array $planProfile, array $lastChanged): array
|
|
||||||
{
|
|
||||||
$overrideValue = $this->settingsResolver->resolveDetailed(
|
|
||||||
workspace: $workspace,
|
|
||||||
domain: self::SETTING_DOMAIN,
|
|
||||||
key: self::SETTING_REVIEW_PACK_GENERATION_OVERRIDE_VALUE,
|
|
||||||
);
|
|
||||||
|
|
||||||
$overrideReason = $this->settingsResolver->resolveValue(
|
|
||||||
workspace: $workspace,
|
|
||||||
domain: self::SETTING_DOMAIN,
|
|
||||||
key: self::SETTING_REVIEW_PACK_GENERATION_OVERRIDE_REASON,
|
|
||||||
);
|
|
||||||
|
|
||||||
$effectiveValue = is_bool($overrideValue['value'])
|
|
||||||
? $overrideValue['value']
|
|
||||||
: (bool) $planProfile['review_pack_generation_default'];
|
|
||||||
|
|
||||||
$source = $overrideValue['source'] === 'workspace_override'
|
|
||||||
? 'workspace_override'
|
|
||||||
: 'plan_profile_default';
|
|
||||||
|
|
||||||
$rationale = $source === 'workspace_override'
|
|
||||||
? (is_string($overrideReason) && $overrideReason !== '' ? $overrideReason : null)
|
|
||||||
: (string) $planProfile['description'];
|
|
||||||
|
|
||||||
return [
|
|
||||||
'workspace_id' => (int) $workspace->getKey(),
|
|
||||||
'plan_profile_id' => (string) $planProfile['id'],
|
|
||||||
'plan_profile_label' => (string) $planProfile['label'],
|
|
||||||
'plan_profile_description' => (string) $planProfile['description'],
|
|
||||||
'key' => self::KEY_REVIEW_PACK_GENERATION_ENABLED,
|
|
||||||
'effective_value' => $effectiveValue,
|
|
||||||
'source' => $source,
|
|
||||||
'rationale' => $rationale,
|
|
||||||
'current_usage' => null,
|
|
||||||
'remaining_capacity' => null,
|
|
||||||
'is_blocked' => ! $effectiveValue,
|
|
||||||
'block_reason' => $effectiveValue
|
|
||||||
? null
|
|
||||||
: $this->reviewPackGenerationBlockReason($source, $planProfile, $rationale),
|
|
||||||
'last_changed_at' => $lastChanged['last_changed_at'],
|
|
||||||
'last_changed_by' => $lastChanged['last_changed_by'],
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @return array{last_changed_at: CarbonInterface|null, last_changed_by: string|null}
|
|
||||||
*/
|
|
||||||
private function lastChangedMetadata(Workspace $workspace): array
|
|
||||||
{
|
|
||||||
$record = WorkspaceSetting::query()
|
|
||||||
->where('workspace_id', (int) $workspace->getKey())
|
|
||||||
->where('domain', self::SETTING_DOMAIN)
|
|
||||||
->whereIn('key', [
|
|
||||||
self::SETTING_PLAN_PROFILE,
|
|
||||||
self::SETTING_MANAGED_TENANT_LIMIT_OVERRIDE_VALUE,
|
|
||||||
self::SETTING_MANAGED_TENANT_LIMIT_OVERRIDE_REASON,
|
|
||||||
self::SETTING_REVIEW_PACK_GENERATION_OVERRIDE_VALUE,
|
|
||||||
self::SETTING_REVIEW_PACK_GENERATION_OVERRIDE_REASON,
|
|
||||||
])
|
|
||||||
->whereNotNull('updated_by_user_id')
|
|
||||||
->with('updatedByUser:id,name')
|
|
||||||
->latest('updated_at')
|
|
||||||
->latest('id')
|
|
||||||
->first();
|
|
||||||
|
|
||||||
if (! $record instanceof WorkspaceSetting) {
|
|
||||||
return [
|
|
||||||
'last_changed_at' => null,
|
|
||||||
'last_changed_by' => null,
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
return [
|
|
||||||
'last_changed_at' => $record->updated_at,
|
|
||||||
'last_changed_by' => $record->updatedByUser?->name,
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @param array{id: string, label: string, description: string, managed_tenant_limit_default: int, review_pack_generation_default: bool, is_default: bool} $planProfile
|
|
||||||
*/
|
|
||||||
private function managedTenantLimitBlockReason(int $currentUsage, int $effectiveValue, string $source, array $planProfile, ?string $rationale): string
|
|
||||||
{
|
|
||||||
$prefix = $source === 'workspace_override'
|
|
||||||
? 'This workspace override currently allows'
|
|
||||||
: sprintf('The %s plan profile currently allows', $planProfile['label']);
|
|
||||||
|
|
||||||
$message = sprintf(
|
|
||||||
'%s %d active managed tenant%s, and this workspace already has %d active managed tenant%s.',
|
|
||||||
$prefix,
|
|
||||||
$effectiveValue,
|
|
||||||
$effectiveValue === 1 ? '' : 's',
|
|
||||||
$currentUsage,
|
|
||||||
$currentUsage === 1 ? '' : 's',
|
|
||||||
);
|
|
||||||
|
|
||||||
if ($source === 'workspace_override' && $rationale !== null) {
|
|
||||||
$message .= ' Reason: '.$rationale;
|
|
||||||
}
|
|
||||||
|
|
||||||
return $message;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @param array{id: string, label: string, description: string, managed_tenant_limit_default: int, review_pack_generation_default: bool, is_default: bool} $planProfile
|
|
||||||
*/
|
|
||||||
private function reviewPackGenerationBlockReason(string $source, array $planProfile, ?string $rationale): string
|
|
||||||
{
|
|
||||||
$message = $source === 'workspace_override'
|
|
||||||
? 'Review pack generation is disabled by workspace override.'
|
|
||||||
: sprintf('Review pack generation is disabled by the %s plan profile.', $planProfile['label']);
|
|
||||||
|
|
||||||
if ($source === 'workspace_override' && $rationale !== null) {
|
|
||||||
$message .= ' Reason: '.$rationale;
|
|
||||||
}
|
|
||||||
|
|
||||||
return $message;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,104 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
namespace App\Services\Entitlements;
|
|
||||||
|
|
||||||
final class WorkspacePlanProfileCatalog
|
|
||||||
{
|
|
||||||
/**
|
|
||||||
* @var array<string, array{id: string, label: string, description: string, managed_tenant_limit_default: int, review_pack_generation_default: bool, is_default: bool}>
|
|
||||||
*/
|
|
||||||
private const PROFILES = [
|
|
||||||
'starter' => [
|
|
||||||
'id' => 'starter',
|
|
||||||
'label' => 'Starter',
|
|
||||||
'description' => 'Minimal allowance for early workspace access and low-volume operations.',
|
|
||||||
'managed_tenant_limit_default' => 1,
|
|
||||||
'review_pack_generation_default' => false,
|
|
||||||
'is_default' => false,
|
|
||||||
],
|
|
||||||
'standard' => [
|
|
||||||
'id' => 'standard',
|
|
||||||
'label' => 'Standard',
|
|
||||||
'description' => 'Balanced defaults for most managed workspaces.',
|
|
||||||
'managed_tenant_limit_default' => 25,
|
|
||||||
'review_pack_generation_default' => true,
|
|
||||||
'is_default' => true,
|
|
||||||
],
|
|
||||||
'scale' => [
|
|
||||||
'id' => 'scale',
|
|
||||||
'label' => 'Scale',
|
|
||||||
'description' => 'Higher managed-tenant capacity for larger workspace portfolios.',
|
|
||||||
'managed_tenant_limit_default' => 100,
|
|
||||||
'review_pack_generation_default' => true,
|
|
||||||
'is_default' => false,
|
|
||||||
],
|
|
||||||
];
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @return list<array{id: string, label: string, description: string, managed_tenant_limit_default: int, review_pack_generation_default: bool, is_default: bool}>
|
|
||||||
*/
|
|
||||||
public function all(): array
|
|
||||||
{
|
|
||||||
return array_values(self::PROFILES);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @return array{id: string, label: string, description: string, managed_tenant_limit_default: int, review_pack_generation_default: bool, is_default: bool}
|
|
||||||
*/
|
|
||||||
public function default(): array
|
|
||||||
{
|
|
||||||
return self::PROFILES[self::defaultProfileId()];
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @return array{id: string, label: string, description: string, managed_tenant_limit_default: int, review_pack_generation_default: bool, is_default: bool}|null
|
|
||||||
*/
|
|
||||||
public function find(?string $id): ?array
|
|
||||||
{
|
|
||||||
if ($id === null) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return self::PROFILES[$id] ?? null;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @return array{id: string, label: string, description: string, managed_tenant_limit_default: int, review_pack_generation_default: bool, is_default: bool}
|
|
||||||
*/
|
|
||||||
public function resolve(?string $id): array
|
|
||||||
{
|
|
||||||
return $this->find($id) ?? $this->default();
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @return array<string, string>
|
|
||||||
*/
|
|
||||||
public function optionLabels(): array
|
|
||||||
{
|
|
||||||
return array_map(
|
|
||||||
static fn (array $profile): string => $profile['label'],
|
|
||||||
self::PROFILES,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @return list<string>
|
|
||||||
*/
|
|
||||||
public static function profileIds(): array
|
|
||||||
{
|
|
||||||
return array_keys(self::PROFILES);
|
|
||||||
}
|
|
||||||
|
|
||||||
public static function defaultProfileId(): string
|
|
||||||
{
|
|
||||||
foreach (self::PROFILES as $id => $profile) {
|
|
||||||
if ($profile['is_default']) {
|
|
||||||
return $id;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
throw new \RuntimeException('Workspace plan profile catalog is missing a default profile.');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -4,7 +4,6 @@
|
|||||||
|
|
||||||
namespace App\Services;
|
namespace App\Services;
|
||||||
|
|
||||||
use App\Exceptions\Entitlements\WorkspaceEntitlementBlockedException;
|
|
||||||
use App\Exceptions\ReviewPackEvidenceResolutionException;
|
use App\Exceptions\ReviewPackEvidenceResolutionException;
|
||||||
use App\Jobs\GenerateReviewPackJob;
|
use App\Jobs\GenerateReviewPackJob;
|
||||||
use App\Models\EvidenceSnapshot;
|
use App\Models\EvidenceSnapshot;
|
||||||
@ -14,7 +13,6 @@
|
|||||||
use App\Models\TenantReview;
|
use App\Models\TenantReview;
|
||||||
use App\Models\User;
|
use App\Models\User;
|
||||||
use App\Services\Audit\WorkspaceAuditLogger;
|
use App\Services\Audit\WorkspaceAuditLogger;
|
||||||
use App\Services\Entitlements\WorkspaceCommercialLifecycleResolver;
|
|
||||||
use App\Services\Evidence\EvidenceResolutionRequest;
|
use App\Services\Evidence\EvidenceResolutionRequest;
|
||||||
use App\Services\Evidence\EvidenceSnapshotResolver;
|
use App\Services\Evidence\EvidenceSnapshotResolver;
|
||||||
use App\Support\Audit\AuditActionId;
|
use App\Support\Audit\AuditActionId;
|
||||||
@ -30,7 +28,6 @@ public function __construct(
|
|||||||
private OperationRunService $operationRunService,
|
private OperationRunService $operationRunService,
|
||||||
private EvidenceSnapshotResolver $snapshotResolver,
|
private EvidenceSnapshotResolver $snapshotResolver,
|
||||||
private WorkspaceAuditLogger $auditLogger,
|
private WorkspaceAuditLogger $auditLogger,
|
||||||
private WorkspaceCommercialLifecycleResolver $workspaceCommercialLifecycleResolver,
|
|
||||||
private ProductTelemetryRecorder $productTelemetryRecorder,
|
private ProductTelemetryRecorder $productTelemetryRecorder,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
@ -52,8 +49,6 @@ public function __construct(
|
|||||||
*/
|
*/
|
||||||
public function generate(Tenant $tenant, User $user, array $options = []): ReviewPack
|
public function generate(Tenant $tenant, User $user, array $options = []): ReviewPack
|
||||||
{
|
{
|
||||||
$this->assertReviewPackGenerationAllowed($tenant);
|
|
||||||
|
|
||||||
$options = $this->normalizeOptions($options);
|
$options = $this->normalizeOptions($options);
|
||||||
$snapshot = $this->resolveSnapshot($tenant);
|
$snapshot = $this->resolveSnapshot($tenant);
|
||||||
$fingerprint = $this->computeFingerprintForSnapshot($snapshot, $options);
|
$fingerprint = $this->computeFingerprintForSnapshot($snapshot, $options);
|
||||||
@ -143,8 +138,6 @@ public function generateFromReview(TenantReview $review, User $user, array $opti
|
|||||||
throw new \InvalidArgumentException('Review exports require an anchored evidence snapshot.');
|
throw new \InvalidArgumentException('Review exports require an anchored evidence snapshot.');
|
||||||
}
|
}
|
||||||
|
|
||||||
$this->assertReviewPackGenerationAllowed($tenant);
|
|
||||||
|
|
||||||
$options = $this->normalizeOptions($options);
|
$options = $this->normalizeOptions($options);
|
||||||
$fingerprint = $this->computeFingerprintForReview($review, $options);
|
$fingerprint = $this->computeFingerprintForReview($review, $options);
|
||||||
$existing = $this->findExistingPackForReview($review, $fingerprint);
|
$existing = $this->findExistingPackForReview($review, $fingerprint);
|
||||||
@ -234,43 +227,18 @@ public function computeFingerprint(Tenant $tenant, array $options): string
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Generate a signed download URL for a review pack.
|
* Generate a signed download URL for a review pack.
|
||||||
*
|
|
||||||
* @param array<string, scalar|null> $parameters
|
|
||||||
*/
|
*/
|
||||||
public function generateDownloadUrl(ReviewPack $pack, array $parameters = []): string
|
public function generateDownloadUrl(ReviewPack $pack): string
|
||||||
{
|
{
|
||||||
$ttlMinutes = (int) config('tenantpilot.review_pack.download_url_ttl_minutes', 60);
|
$ttlMinutes = (int) config('tenantpilot.review_pack.download_url_ttl_minutes', 60);
|
||||||
|
|
||||||
return URL::signedRoute(
|
return URL::signedRoute(
|
||||||
'admin.review-packs.download',
|
'admin.review-packs.download',
|
||||||
array_merge(['reviewPack' => $pack->getKey()], $parameters),
|
['reviewPack' => $pack->getKey()],
|
||||||
now()->addMinutes($ttlMinutes),
|
now()->addMinutes($ttlMinutes),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* @return array<string, mixed>
|
|
||||||
*/
|
|
||||||
public function reviewPackGenerationDecisionForTenant(Tenant $tenant): array
|
|
||||||
{
|
|
||||||
$tenant->loadMissing('workspace');
|
|
||||||
$decision = $this->workspaceCommercialLifecycleResolver->actionDecision(
|
|
||||||
$tenant->workspace,
|
|
||||||
WorkspaceCommercialLifecycleResolver::ACTION_REVIEW_PACK_START,
|
|
||||||
);
|
|
||||||
|
|
||||||
$entitlementDecision = is_array($decision['entitlement_decision'] ?? null)
|
|
||||||
? $decision['entitlement_decision']
|
|
||||||
: [];
|
|
||||||
|
|
||||||
return $decision + [
|
|
||||||
'effective_value' => $entitlementDecision['effective_value'] ?? null,
|
|
||||||
'source' => $decision['source'] ?? null,
|
|
||||||
'current_usage' => $entitlementDecision['current_usage'] ?? null,
|
|
||||||
'remaining_capacity' => $entitlementDecision['remaining_capacity'] ?? null,
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
private function recordReviewPackRequestTelemetry(ReviewPack $reviewPack, User $user, string $sourceSurface): void
|
private function recordReviewPackRequestTelemetry(ReviewPack $reviewPack, User $user, string $sourceSurface): void
|
||||||
{
|
{
|
||||||
$this->productTelemetryRecorder->record(
|
$this->productTelemetryRecorder->record(
|
||||||
@ -346,17 +314,6 @@ private function normalizeOptions(array $options): array
|
|||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
private function assertReviewPackGenerationAllowed(Tenant $tenant): void
|
|
||||||
{
|
|
||||||
$decision = $this->reviewPackGenerationDecisionForTenant($tenant);
|
|
||||||
|
|
||||||
if (! (bool) ($decision['is_blocked'] ?? false)) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
throw new WorkspaceEntitlementBlockedException($decision);
|
|
||||||
}
|
|
||||||
|
|
||||||
private function computeFingerprintForSnapshot(EvidenceSnapshot $snapshot, array $options): string
|
private function computeFingerprintForSnapshot(EvidenceSnapshot $snapshot, array $options): string
|
||||||
{
|
{
|
||||||
$data = [
|
$data = [
|
||||||
|
|||||||
@ -4,7 +4,6 @@
|
|||||||
|
|
||||||
namespace App\Services\Settings;
|
namespace App\Services\Settings;
|
||||||
|
|
||||||
use App\Models\PlatformUser;
|
|
||||||
use App\Models\Tenant;
|
use App\Models\Tenant;
|
||||||
use App\Models\TenantSetting;
|
use App\Models\TenantSetting;
|
||||||
use App\Models\User;
|
use App\Models\User;
|
||||||
@ -12,14 +11,11 @@
|
|||||||
use App\Models\WorkspaceSetting;
|
use App\Models\WorkspaceSetting;
|
||||||
use App\Services\Audit\WorkspaceAuditLogger;
|
use App\Services\Audit\WorkspaceAuditLogger;
|
||||||
use App\Services\Auth\WorkspaceCapabilityResolver;
|
use App\Services\Auth\WorkspaceCapabilityResolver;
|
||||||
use App\Services\Entitlements\WorkspaceCommercialLifecycleResolver;
|
|
||||||
use App\Support\Audit\AuditActionId;
|
use App\Support\Audit\AuditActionId;
|
||||||
use App\Support\Auth\Capabilities;
|
use App\Support\Auth\Capabilities;
|
||||||
use App\Support\Auth\PlatformCapabilities;
|
|
||||||
use App\Support\Settings\SettingDefinition;
|
use App\Support\Settings\SettingDefinition;
|
||||||
use App\Support\Settings\SettingsRegistry;
|
use App\Support\Settings\SettingsRegistry;
|
||||||
use Illuminate\Auth\Access\AuthorizationException;
|
use Illuminate\Auth\Access\AuthorizationException;
|
||||||
use Illuminate\Support\Facades\DB;
|
|
||||||
use Illuminate\Support\Facades\Validator;
|
use Illuminate\Support\Facades\Validator;
|
||||||
use Illuminate\Validation\ValidationException;
|
use Illuminate\Validation\ValidationException;
|
||||||
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
|
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
|
||||||
@ -37,7 +33,27 @@ public function updateWorkspaceSetting(User $actor, Workspace $workspace, string
|
|||||||
{
|
{
|
||||||
$this->authorizeManage($actor, $workspace);
|
$this->authorizeManage($actor, $workspace);
|
||||||
|
|
||||||
$result = $this->persistWorkspaceSetting($workspace, $domain, $key, $value, (int) $actor->getKey());
|
$definition = $this->requireDefinition($domain, $key);
|
||||||
|
$normalizedValue = $this->validatedValue($definition, $value);
|
||||||
|
|
||||||
|
$existing = WorkspaceSetting::query()
|
||||||
|
->where('workspace_id', (int) $workspace->getKey())
|
||||||
|
->where('domain', $domain)
|
||||||
|
->where('key', $key)
|
||||||
|
->first();
|
||||||
|
|
||||||
|
$beforeValue = $existing instanceof WorkspaceSetting
|
||||||
|
? $this->decodeStoredValue($existing->getAttribute('value'))
|
||||||
|
: null;
|
||||||
|
|
||||||
|
$setting = WorkspaceSetting::query()->updateOrCreate([
|
||||||
|
'workspace_id' => (int) $workspace->getKey(),
|
||||||
|
'domain' => $domain,
|
||||||
|
'key' => $key,
|
||||||
|
], [
|
||||||
|
'value' => $normalizedValue,
|
||||||
|
'updated_by_user_id' => (int) $actor->getKey(),
|
||||||
|
]);
|
||||||
|
|
||||||
$this->resolver->clearCache();
|
$this->resolver->clearCache();
|
||||||
|
|
||||||
@ -51,7 +67,7 @@ public function updateWorkspaceSetting(User $actor, Workspace $workspace, string
|
|||||||
'scope' => 'workspace',
|
'scope' => 'workspace',
|
||||||
'domain' => $domain,
|
'domain' => $domain,
|
||||||
'key' => $key,
|
'key' => $key,
|
||||||
'before_value' => $result['before_value'],
|
'before_value' => $beforeValue,
|
||||||
'after_value' => $afterValue,
|
'after_value' => $afterValue,
|
||||||
],
|
],
|
||||||
],
|
],
|
||||||
@ -60,79 +76,7 @@ public function updateWorkspaceSetting(User $actor, Workspace $workspace, string
|
|||||||
resourceId: $domain.'.'.$key,
|
resourceId: $domain.'.'.$key,
|
||||||
);
|
);
|
||||||
|
|
||||||
return $result['setting'];
|
return $setting;
|
||||||
}
|
|
||||||
|
|
||||||
public function updateWorkspaceCommercialLifecycle(
|
|
||||||
PlatformUser $actor,
|
|
||||||
Workspace $workspace,
|
|
||||||
string $state,
|
|
||||||
string $reason,
|
|
||||||
): void {
|
|
||||||
$state = strtolower(trim($state));
|
|
||||||
$reason = trim($reason);
|
|
||||||
|
|
||||||
if (! $actor->hasCapability(PlatformCapabilities::COMMERCIAL_LIFECYCLE_MANAGE)) {
|
|
||||||
throw new AuthorizationException('Missing commercial lifecycle manage capability.');
|
|
||||||
}
|
|
||||||
|
|
||||||
if ($reason === '') {
|
|
||||||
throw ValidationException::withMessages([
|
|
||||||
'reason' => ['A rationale is required when changing commercial lifecycle state.'],
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
|
|
||||||
DB::transaction(function () use ($actor, $workspace, $state, $reason): void {
|
|
||||||
$stateResult = $this->persistWorkspaceSetting(
|
|
||||||
workspace: $workspace,
|
|
||||||
domain: WorkspaceCommercialLifecycleResolver::SETTING_DOMAIN,
|
|
||||||
key: WorkspaceCommercialLifecycleResolver::SETTING_COMMERCIAL_LIFECYCLE_STATE,
|
|
||||||
value: $state,
|
|
||||||
updatedByUserId: null,
|
|
||||||
);
|
|
||||||
|
|
||||||
$reasonResult = $this->persistWorkspaceSetting(
|
|
||||||
workspace: $workspace,
|
|
||||||
domain: WorkspaceCommercialLifecycleResolver::SETTING_DOMAIN,
|
|
||||||
key: WorkspaceCommercialLifecycleResolver::SETTING_COMMERCIAL_LIFECYCLE_REASON,
|
|
||||||
value: $reason,
|
|
||||||
updatedByUserId: null,
|
|
||||||
);
|
|
||||||
|
|
||||||
$this->resolver->clearCache();
|
|
||||||
|
|
||||||
$afterState = $this->resolver->resolveValue(
|
|
||||||
$workspace,
|
|
||||||
WorkspaceCommercialLifecycleResolver::SETTING_DOMAIN,
|
|
||||||
WorkspaceCommercialLifecycleResolver::SETTING_COMMERCIAL_LIFECYCLE_STATE,
|
|
||||||
);
|
|
||||||
|
|
||||||
$afterReason = $this->resolver->resolveValue(
|
|
||||||
$workspace,
|
|
||||||
WorkspaceCommercialLifecycleResolver::SETTING_DOMAIN,
|
|
||||||
WorkspaceCommercialLifecycleResolver::SETTING_COMMERCIAL_LIFECYCLE_REASON,
|
|
||||||
);
|
|
||||||
|
|
||||||
$this->auditLogger->log(
|
|
||||||
workspace: $workspace,
|
|
||||||
action: AuditActionId::WorkspaceSettingUpdated->value,
|
|
||||||
context: [
|
|
||||||
'metadata' => [
|
|
||||||
'scope' => 'workspace',
|
|
||||||
'domain' => WorkspaceCommercialLifecycleResolver::SETTING_DOMAIN,
|
|
||||||
'key' => WorkspaceCommercialLifecycleResolver::SETTING_COMMERCIAL_LIFECYCLE_STATE,
|
|
||||||
'before_state' => $stateResult['before_value'],
|
|
||||||
'after_state' => $afterState,
|
|
||||||
'before_reason' => $reasonResult['before_value'],
|
|
||||||
'after_reason' => $afterReason,
|
|
||||||
],
|
|
||||||
],
|
|
||||||
actor: $actor,
|
|
||||||
resourceType: 'workspace_setting',
|
|
||||||
resourceId: WorkspaceCommercialLifecycleResolver::SETTING_DOMAIN.'.'.WorkspaceCommercialLifecycleResolver::SETTING_COMMERCIAL_LIFECYCLE_STATE,
|
|
||||||
targetLabel: 'Commercial lifecycle state',
|
|
||||||
);
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public function resetWorkspaceSetting(User $actor, Workspace $workspace, string $domain, string $key): void
|
public function resetWorkspaceSetting(User $actor, Workspace $workspace, string $domain, string $key): void
|
||||||
@ -230,39 +174,6 @@ private function requireDefinition(string $domain, string $key): SettingDefiniti
|
|||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* @return array{setting: WorkspaceSetting, before_value: mixed}
|
|
||||||
*/
|
|
||||||
private function persistWorkspaceSetting(Workspace $workspace, string $domain, string $key, mixed $value, ?int $updatedByUserId): array
|
|
||||||
{
|
|
||||||
$definition = $this->requireDefinition($domain, $key);
|
|
||||||
$normalizedValue = $this->validatedValue($definition, $value);
|
|
||||||
|
|
||||||
$existing = WorkspaceSetting::query()
|
|
||||||
->where('workspace_id', (int) $workspace->getKey())
|
|
||||||
->where('domain', $domain)
|
|
||||||
->where('key', $key)
|
|
||||||
->first();
|
|
||||||
|
|
||||||
$beforeValue = $existing instanceof WorkspaceSetting
|
|
||||||
? $this->decodeStoredValue($existing->getAttribute('value'))
|
|
||||||
: null;
|
|
||||||
|
|
||||||
$setting = WorkspaceSetting::query()->updateOrCreate([
|
|
||||||
'workspace_id' => (int) $workspace->getKey(),
|
|
||||||
'domain' => $domain,
|
|
||||||
'key' => $key,
|
|
||||||
], [
|
|
||||||
'value' => $normalizedValue,
|
|
||||||
'updated_by_user_id' => $updatedByUserId,
|
|
||||||
]);
|
|
||||||
|
|
||||||
return [
|
|
||||||
'setting' => $setting,
|
|
||||||
'before_value' => $beforeValue,
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
private function validatedValue(SettingDefinition $definition, mixed $value): mixed
|
private function validatedValue(SettingDefinition $definition, mixed $value): mixed
|
||||||
{
|
{
|
||||||
$validator = Validator::make(
|
$validator = Validator::make(
|
||||||
|
|||||||
@ -12,7 +12,6 @@
|
|||||||
use App\Services\Auth\RoleCapabilityMap;
|
use App\Services\Auth\RoleCapabilityMap;
|
||||||
use App\Support\Auth\Capabilities;
|
use App\Support\Auth\Capabilities;
|
||||||
use Illuminate\Database\Eloquent\Builder;
|
use Illuminate\Database\Eloquent\Builder;
|
||||||
use Illuminate\Support\Facades\DB;
|
|
||||||
|
|
||||||
final class TenantReviewRegisterService
|
final class TenantReviewRegisterService
|
||||||
{
|
{
|
||||||
@ -44,55 +43,6 @@ public function query(User $user, Workspace $workspace): Builder
|
|||||||
->latest('id');
|
->latest('id');
|
||||||
}
|
}
|
||||||
|
|
||||||
public function latestPublishedQuery(User $user, Workspace $workspace): Builder
|
|
||||||
{
|
|
||||||
$tenantIds = array_keys($this->authorizedTenants($user, $workspace));
|
|
||||||
|
|
||||||
$rankedReviews = TenantReview::query()
|
|
||||||
->select([
|
|
||||||
'tenant_reviews.id',
|
|
||||||
'tenant_reviews.tenant_id',
|
|
||||||
'tenant_reviews.published_at',
|
|
||||||
'tenant_reviews.generated_at',
|
|
||||||
])
|
|
||||||
->selectRaw('ROW_NUMBER() OVER (PARTITION BY tenant_id ORDER BY published_at DESC, generated_at DESC, id DESC) as rn')
|
|
||||||
->forWorkspace((int) $workspace->getKey())
|
|
||||||
->whereIn('tenant_id', $tenantIds === [] ? [-1] : $tenantIds)
|
|
||||||
->published();
|
|
||||||
|
|
||||||
$latestPublishedIds = DB::query()
|
|
||||||
->fromSub($rankedReviews, 'ranked_tenant_reviews')
|
|
||||||
->where('rn', 1)
|
|
||||||
->select('id');
|
|
||||||
|
|
||||||
return TenantReview::query()
|
|
||||||
->with(['tenant', 'evidenceSnapshot', 'currentExportReviewPack'])
|
|
||||||
->forWorkspace((int) $workspace->getKey())
|
|
||||||
->whereIn('tenant_reviews.id', $latestPublishedIds)
|
|
||||||
->orderByDesc('published_at')
|
|
||||||
->orderByDesc('generated_at')
|
|
||||||
->orderByDesc('id');
|
|
||||||
}
|
|
||||||
|
|
||||||
public function customerWorkspaceTenantQuery(User $user, Workspace $workspace): Builder
|
|
||||||
{
|
|
||||||
$tenantIds = array_keys($this->authorizedTenants($user, $workspace));
|
|
||||||
|
|
||||||
return Tenant::query()
|
|
||||||
->where('workspace_id', (int) $workspace->getKey())
|
|
||||||
->whereIn('id', $tenantIds === [] ? [-1] : $tenantIds)
|
|
||||||
->with([
|
|
||||||
'tenantReviews' => fn ($query) => $query
|
|
||||||
->with(['tenant', 'evidenceSnapshot', 'currentExportReviewPack'])
|
|
||||||
->published()
|
|
||||||
->orderByDesc('published_at')
|
|
||||||
->orderByDesc('generated_at')
|
|
||||||
->orderByDesc('id')
|
|
||||||
->limit(1),
|
|
||||||
])
|
|
||||||
->orderBy('name');
|
|
||||||
}
|
|
||||||
|
|
||||||
public function canAccessWorkspace(User $user, Workspace $workspace): bool
|
public function canAccessWorkspace(User $user, Workspace $workspace): bool
|
||||||
{
|
{
|
||||||
return WorkspaceMembership::query()
|
return WorkspaceMembership::query()
|
||||||
|
|||||||
@ -1,27 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
namespace App\Support\Ai;
|
|
||||||
|
|
||||||
enum AiDataClassification: string
|
|
||||||
{
|
|
||||||
case ProductKnowledge = 'product_knowledge';
|
|
||||||
case OperationalMetadata = 'operational_metadata';
|
|
||||||
case RedactedSupportSummary = 'redacted_support_summary';
|
|
||||||
case PersonalData = 'personal_data';
|
|
||||||
case CustomerConfidential = 'customer_confidential';
|
|
||||||
case RawProviderPayload = 'raw_provider_payload';
|
|
||||||
|
|
||||||
public function label(): string
|
|
||||||
{
|
|
||||||
return match ($this) {
|
|
||||||
self::ProductKnowledge => 'Product knowledge',
|
|
||||||
self::OperationalMetadata => 'Operational metadata',
|
|
||||||
self::RedactedSupportSummary => 'Redacted support summary',
|
|
||||||
self::PersonalData => 'Personal data',
|
|
||||||
self::CustomerConfidential => 'Customer confidential',
|
|
||||||
self::RawProviderPayload => 'Raw provider payload',
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,39 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
namespace App\Support\Ai;
|
|
||||||
|
|
||||||
final class AiDecisionAuditMetadataFactory
|
|
||||||
{
|
|
||||||
/**
|
|
||||||
* @return array<string, mixed>
|
|
||||||
*/
|
|
||||||
public function make(AiExecutionRequest $request, AiExecutionDecision $decision): array
|
|
||||||
{
|
|
||||||
return array_filter([
|
|
||||||
'use_case_key' => $decision->useCaseKey,
|
|
||||||
'decision_outcome' => $decision->outcome,
|
|
||||||
'decision_reason' => $decision->reasonCode->value,
|
|
||||||
'workspace_ai_policy_mode' => $decision->workspaceAiPolicyMode,
|
|
||||||
'requested_provider_class' => $decision->requestedProviderClass,
|
|
||||||
'data_classifications' => $decision->dataClassifications,
|
|
||||||
'source_family' => $decision->sourceFamily,
|
|
||||||
'workspace_id' => $request->workspace?->getKey(),
|
|
||||||
'tenant_id' => $request->tenant?->getKey(),
|
|
||||||
'context_fingerprint' => $this->normalizedFingerprint($request->contextFingerprint),
|
|
||||||
'matched_operational_control_scope' => $decision->matchedOperationalControlScope,
|
|
||||||
], static fn (mixed $value): bool => $value !== null);
|
|
||||||
}
|
|
||||||
|
|
||||||
private function normalizedFingerprint(?string $contextFingerprint): ?string
|
|
||||||
{
|
|
||||||
if (! is_string($contextFingerprint)) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
$normalized = trim($contextFingerprint);
|
|
||||||
|
|
||||||
return $normalized === '' ? null : $normalized;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,18 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
namespace App\Support\Ai;
|
|
||||||
|
|
||||||
enum AiDecisionReasonCode: string
|
|
||||||
{
|
|
||||||
case Allowed = 'allowed';
|
|
||||||
case MissingWorkspaceContext = 'missing_workspace_context';
|
|
||||||
case TenantOutsideWorkspace = 'tenant_outside_workspace';
|
|
||||||
case OperationalControlPaused = 'operational_control_paused';
|
|
||||||
case WorkspacePolicyDisabled = 'workspace_policy_disabled';
|
|
||||||
case UnregisteredUseCase = 'unregistered_use_case';
|
|
||||||
case ProviderClassBlocked = 'provider_class_blocked';
|
|
||||||
case DataClassificationBlocked = 'data_classification_blocked';
|
|
||||||
case SourceFamilyMismatch = 'source_family_mismatch';
|
|
||||||
}
|
|
||||||
@ -1,37 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
namespace App\Support\Ai;
|
|
||||||
|
|
||||||
use App\Support\Audit\AuditActionId;
|
|
||||||
|
|
||||||
final readonly class AiExecutionDecision
|
|
||||||
{
|
|
||||||
/**
|
|
||||||
* @param list<string> $dataClassifications
|
|
||||||
* @param array<string, mixed> $auditMetadata
|
|
||||||
*/
|
|
||||||
public function __construct(
|
|
||||||
public string $outcome,
|
|
||||||
public AiDecisionReasonCode $reasonCode,
|
|
||||||
public string $workspaceAiPolicyMode,
|
|
||||||
public ?string $matchedOperationalControlScope,
|
|
||||||
public string $useCaseKey,
|
|
||||||
public string $requestedProviderClass,
|
|
||||||
public array $dataClassifications,
|
|
||||||
public string $sourceFamily,
|
|
||||||
public AuditActionId $auditAction,
|
|
||||||
public array $auditMetadata,
|
|
||||||
) {}
|
|
||||||
|
|
||||||
public function isAllowed(): bool
|
|
||||||
{
|
|
||||||
return $this->outcome === 'allowed';
|
|
||||||
}
|
|
||||||
|
|
||||||
public function isBlocked(): bool
|
|
||||||
{
|
|
||||||
return $this->outcome === 'blocked';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,28 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
namespace App\Support\Ai;
|
|
||||||
|
|
||||||
use App\Models\PlatformUser;
|
|
||||||
use App\Models\Tenant;
|
|
||||||
use App\Models\User;
|
|
||||||
use App\Models\Workspace;
|
|
||||||
|
|
||||||
final readonly class AiExecutionRequest
|
|
||||||
{
|
|
||||||
/**
|
|
||||||
* @param list<string> $dataClassifications
|
|
||||||
*/
|
|
||||||
public function __construct(
|
|
||||||
public ?Workspace $workspace,
|
|
||||||
public ?Tenant $tenant,
|
|
||||||
public User|PlatformUser|null $actor,
|
|
||||||
public string $useCaseKey,
|
|
||||||
public string $requestedProviderClass,
|
|
||||||
public array $dataClassifications,
|
|
||||||
public string $sourceFamily,
|
|
||||||
public ?string $callerSurface = null,
|
|
||||||
public ?string $contextFingerprint = null,
|
|
||||||
) {}
|
|
||||||
}
|
|
||||||
@ -1,43 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
namespace App\Support\Ai;
|
|
||||||
|
|
||||||
enum AiPolicyMode: string
|
|
||||||
{
|
|
||||||
case Disabled = 'disabled';
|
|
||||||
case PrivateOnly = 'private_only';
|
|
||||||
|
|
||||||
public function label(): string
|
|
||||||
{
|
|
||||||
return match ($this) {
|
|
||||||
self::Disabled => 'Disabled',
|
|
||||||
self::PrivateOnly => 'Private only',
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
public function summary(): string
|
|
||||||
{
|
|
||||||
return match ($this) {
|
|
||||||
self::Disabled => 'No AI execution is allowed for this workspace.',
|
|
||||||
self::PrivateOnly => 'Only approved internal drafts may use private-only AI for approved use cases.',
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @return array<string, string>
|
|
||||||
*/
|
|
||||||
public static function optionLabels(): array
|
|
||||||
{
|
|
||||||
return array_reduce(
|
|
||||||
self::cases(),
|
|
||||||
static function (array $labels, self $mode): array {
|
|
||||||
$labels[$mode->value] = $mode->label();
|
|
||||||
|
|
||||||
return $labels;
|
|
||||||
},
|
|
||||||
[],
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,19 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
namespace App\Support\Ai;
|
|
||||||
|
|
||||||
enum AiProviderClass: string
|
|
||||||
{
|
|
||||||
case LocalPrivate = 'local_private';
|
|
||||||
case ExternalPublic = 'external_public';
|
|
||||||
|
|
||||||
public function label(): string
|
|
||||||
{
|
|
||||||
return match ($this) {
|
|
||||||
self::LocalPrivate => 'Local private',
|
|
||||||
self::ExternalPublic => 'External public',
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,126 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
namespace App\Support\Ai;
|
|
||||||
|
|
||||||
final class AiUseCaseCatalog
|
|
||||||
{
|
|
||||||
/**
|
|
||||||
* @var array<string, array{
|
|
||||||
* key: string,
|
|
||||||
* label: string,
|
|
||||||
* future_consumer: string,
|
|
||||||
* visibility: string,
|
|
||||||
* allowed_provider_classes: list<string>,
|
|
||||||
* allowed_data_classifications: list<string>,
|
|
||||||
* source_family: string,
|
|
||||||
* tenant_context_permitted: bool
|
|
||||||
* }>
|
|
||||||
*/
|
|
||||||
private const USE_CASES = [
|
|
||||||
'product_knowledge.answer_draft' => [
|
|
||||||
'key' => 'product_knowledge.answer_draft',
|
|
||||||
'label' => 'Product knowledge answer draft',
|
|
||||||
'future_consumer' => 'ContextualHelpResolver',
|
|
||||||
'visibility' => 'internal_only_draft',
|
|
||||||
'allowed_provider_classes' => [AiProviderClass::LocalPrivate->value],
|
|
||||||
'allowed_data_classifications' => [
|
|
||||||
AiDataClassification::ProductKnowledge->value,
|
|
||||||
AiDataClassification::OperationalMetadata->value,
|
|
||||||
],
|
|
||||||
'source_family' => 'product_knowledge',
|
|
||||||
'tenant_context_permitted' => false,
|
|
||||||
],
|
|
||||||
'support_diagnostics.summary_draft' => [
|
|
||||||
'key' => 'support_diagnostics.summary_draft',
|
|
||||||
'label' => 'Support diagnostics summary draft',
|
|
||||||
'future_consumer' => 'SupportDiagnosticBundleBuilder',
|
|
||||||
'visibility' => 'internal_only_draft',
|
|
||||||
'allowed_provider_classes' => [AiProviderClass::LocalPrivate->value],
|
|
||||||
'allowed_data_classifications' => [AiDataClassification::RedactedSupportSummary->value],
|
|
||||||
'source_family' => 'support_diagnostics',
|
|
||||||
'tenant_context_permitted' => true,
|
|
||||||
],
|
|
||||||
];
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @return list<array{
|
|
||||||
* key: string,
|
|
||||||
* label: string,
|
|
||||||
* future_consumer: string,
|
|
||||||
* visibility: string,
|
|
||||||
* allowed_provider_classes: list<string>,
|
|
||||||
* allowed_data_classifications: list<string>,
|
|
||||||
* source_family: string,
|
|
||||||
* tenant_context_permitted: bool
|
|
||||||
* }>
|
|
||||||
*/
|
|
||||||
public function all(): array
|
|
||||||
{
|
|
||||||
return array_values(self::USE_CASES);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @return array{
|
|
||||||
* key: string,
|
|
||||||
* label: string,
|
|
||||||
* future_consumer: string,
|
|
||||||
* visibility: string,
|
|
||||||
* allowed_provider_classes: list<string>,
|
|
||||||
* allowed_data_classifications: list<string>,
|
|
||||||
* source_family: string,
|
|
||||||
* tenant_context_permitted: bool
|
|
||||||
* }|null
|
|
||||||
*/
|
|
||||||
public function find(string $key): ?array
|
|
||||||
{
|
|
||||||
return self::USE_CASES[$key] ?? null;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @return list<string>
|
|
||||||
*/
|
|
||||||
public function labels(): array
|
|
||||||
{
|
|
||||||
return array_map(
|
|
||||||
static fn (array $definition): string => $definition['label'],
|
|
||||||
$this->all(),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @return list<string>
|
|
||||||
*/
|
|
||||||
public function allowedProviderClassLabelsForMode(AiPolicyMode $mode): array
|
|
||||||
{
|
|
||||||
if ($mode === AiPolicyMode::Disabled) {
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
|
|
||||||
$labels = [];
|
|
||||||
|
|
||||||
foreach ($this->all() as $definition) {
|
|
||||||
foreach ($definition['allowed_provider_classes'] as $providerClass) {
|
|
||||||
$labels[$providerClass] = AiProviderClass::from($providerClass)->label();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return array_values($labels);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @return list<string>
|
|
||||||
*/
|
|
||||||
public function blockedDataClassificationLabels(): array
|
|
||||||
{
|
|
||||||
return array_map(
|
|
||||||
static fn (AiDataClassification $classification): string => $classification->label(),
|
|
||||||
[
|
|
||||||
AiDataClassification::PersonalData,
|
|
||||||
AiDataClassification::CustomerConfidential,
|
|
||||||
AiDataClassification::RawProviderPayload,
|
|
||||||
],
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,181 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
namespace App\Support\Ai;
|
|
||||||
|
|
||||||
use App\Services\Audit\WorkspaceAuditLogger;
|
|
||||||
use App\Services\Settings\SettingsResolver;
|
|
||||||
use App\Support\Audit\AuditActionId;
|
|
||||||
use App\Support\OperationalControls\OperationalControlEvaluator;
|
|
||||||
|
|
||||||
final class GovernedAiExecutionBoundary
|
|
||||||
{
|
|
||||||
public function __construct(
|
|
||||||
private readonly AiUseCaseCatalog $useCaseCatalog,
|
|
||||||
private readonly SettingsResolver $settingsResolver,
|
|
||||||
private readonly OperationalControlEvaluator $operationalControls,
|
|
||||||
private readonly AiDecisionAuditMetadataFactory $auditMetadataFactory,
|
|
||||||
private readonly WorkspaceAuditLogger $workspaceAuditLogger,
|
|
||||||
) {}
|
|
||||||
|
|
||||||
public function evaluate(AiExecutionRequest $request): AiExecutionDecision
|
|
||||||
{
|
|
||||||
$decision = $this->decisionFor($request);
|
|
||||||
$metadata = $this->auditMetadataFactory->make($request, $decision);
|
|
||||||
|
|
||||||
$decision = new AiExecutionDecision(
|
|
||||||
outcome: $decision->outcome,
|
|
||||||
reasonCode: $decision->reasonCode,
|
|
||||||
workspaceAiPolicyMode: $decision->workspaceAiPolicyMode,
|
|
||||||
matchedOperationalControlScope: $decision->matchedOperationalControlScope,
|
|
||||||
useCaseKey: $decision->useCaseKey,
|
|
||||||
requestedProviderClass: $decision->requestedProviderClass,
|
|
||||||
dataClassifications: $decision->dataClassifications,
|
|
||||||
sourceFamily: $decision->sourceFamily,
|
|
||||||
auditAction: $decision->auditAction,
|
|
||||||
auditMetadata: $metadata,
|
|
||||||
);
|
|
||||||
|
|
||||||
if ($request->workspace !== null) {
|
|
||||||
$definition = $this->useCaseCatalog->find($request->useCaseKey);
|
|
||||||
|
|
||||||
$this->workspaceAuditLogger->log(
|
|
||||||
workspace: $request->workspace,
|
|
||||||
action: $decision->auditAction,
|
|
||||||
context: ['metadata' => $decision->auditMetadata],
|
|
||||||
actor: $request->actor,
|
|
||||||
status: $decision->isAllowed() ? 'success' : 'blocked',
|
|
||||||
resourceType: 'ai_use_case',
|
|
||||||
resourceId: $request->useCaseKey,
|
|
||||||
targetLabel: $definition['label'] ?? $request->useCaseKey,
|
|
||||||
summary: 'AI execution decision evaluated',
|
|
||||||
tenant: $request->tenant,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return $decision;
|
|
||||||
}
|
|
||||||
|
|
||||||
private function decisionFor(AiExecutionRequest $request): AiExecutionDecision
|
|
||||||
{
|
|
||||||
if ($request->workspace === null) {
|
|
||||||
return $this->blockedDecision(
|
|
||||||
request: $request,
|
|
||||||
reasonCode: AiDecisionReasonCode::MissingWorkspaceContext,
|
|
||||||
workspaceAiPolicyMode: AiPolicyMode::Disabled->value,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if ($request->tenant !== null && (int) $request->tenant->workspace_id !== (int) $request->workspace->getKey()) {
|
|
||||||
return $this->blockedDecision(
|
|
||||||
request: $request,
|
|
||||||
reasonCode: AiDecisionReasonCode::TenantOutsideWorkspace,
|
|
||||||
workspaceAiPolicyMode: AiPolicyMode::Disabled->value,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
$controlDecision = $this->operationalControls->evaluate('ai.execution', $request->workspace);
|
|
||||||
|
|
||||||
if ($controlDecision->isPaused()) {
|
|
||||||
return $this->blockedDecision(
|
|
||||||
request: $request,
|
|
||||||
reasonCode: AiDecisionReasonCode::OperationalControlPaused,
|
|
||||||
workspaceAiPolicyMode: $this->resolvedPolicyMode($request),
|
|
||||||
matchedOperationalControlScope: $controlDecision->matchedScopeType,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
$policyMode = $this->resolvedPolicyMode($request);
|
|
||||||
|
|
||||||
if ($policyMode === AiPolicyMode::Disabled->value) {
|
|
||||||
return $this->blockedDecision(
|
|
||||||
request: $request,
|
|
||||||
reasonCode: AiDecisionReasonCode::WorkspacePolicyDisabled,
|
|
||||||
workspaceAiPolicyMode: $policyMode,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
$definition = $this->useCaseCatalog->find($request->useCaseKey);
|
|
||||||
|
|
||||||
if ($definition === null) {
|
|
||||||
return $this->blockedDecision(
|
|
||||||
request: $request,
|
|
||||||
reasonCode: AiDecisionReasonCode::UnregisteredUseCase,
|
|
||||||
workspaceAiPolicyMode: $policyMode,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if ($definition['source_family'] !== $request->sourceFamily) {
|
|
||||||
return $this->blockedDecision(
|
|
||||||
request: $request,
|
|
||||||
reasonCode: AiDecisionReasonCode::SourceFamilyMismatch,
|
|
||||||
workspaceAiPolicyMode: $policyMode,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (! in_array($request->requestedProviderClass, $definition['allowed_provider_classes'], true)) {
|
|
||||||
return $this->blockedDecision(
|
|
||||||
request: $request,
|
|
||||||
reasonCode: AiDecisionReasonCode::ProviderClassBlocked,
|
|
||||||
workspaceAiPolicyMode: $policyMode,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
foreach ($request->dataClassifications as $classification) {
|
|
||||||
if (! in_array($classification, $definition['allowed_data_classifications'], true)) {
|
|
||||||
return $this->blockedDecision(
|
|
||||||
request: $request,
|
|
||||||
reasonCode: AiDecisionReasonCode::DataClassificationBlocked,
|
|
||||||
workspaceAiPolicyMode: $policyMode,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return new AiExecutionDecision(
|
|
||||||
outcome: 'allowed',
|
|
||||||
reasonCode: AiDecisionReasonCode::Allowed,
|
|
||||||
workspaceAiPolicyMode: $policyMode,
|
|
||||||
matchedOperationalControlScope: null,
|
|
||||||
useCaseKey: $request->useCaseKey,
|
|
||||||
requestedProviderClass: $request->requestedProviderClass,
|
|
||||||
dataClassifications: $request->dataClassifications,
|
|
||||||
sourceFamily: $request->sourceFamily,
|
|
||||||
auditAction: AuditActionId::AiExecutionDecisionEvaluated,
|
|
||||||
auditMetadata: [],
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
private function resolvedPolicyMode(AiExecutionRequest $request): string
|
|
||||||
{
|
|
||||||
if ($request->workspace === null) {
|
|
||||||
return AiPolicyMode::Disabled->value;
|
|
||||||
}
|
|
||||||
|
|
||||||
$resolved = $this->settingsResolver->resolveValue($request->workspace, 'ai', 'policy_mode');
|
|
||||||
|
|
||||||
return is_string($resolved) && $resolved !== ''
|
|
||||||
? $resolved
|
|
||||||
: AiPolicyMode::Disabled->value;
|
|
||||||
}
|
|
||||||
|
|
||||||
private function blockedDecision(
|
|
||||||
AiExecutionRequest $request,
|
|
||||||
AiDecisionReasonCode $reasonCode,
|
|
||||||
string $workspaceAiPolicyMode,
|
|
||||||
?string $matchedOperationalControlScope = null,
|
|
||||||
): AiExecutionDecision {
|
|
||||||
return new AiExecutionDecision(
|
|
||||||
outcome: 'blocked',
|
|
||||||
reasonCode: $reasonCode,
|
|
||||||
workspaceAiPolicyMode: $workspaceAiPolicyMode,
|
|
||||||
matchedOperationalControlScope: $matchedOperationalControlScope,
|
|
||||||
useCaseKey: $request->useCaseKey,
|
|
||||||
requestedProviderClass: $request->requestedProviderClass,
|
|
||||||
dataClassifications: $request->dataClassifications,
|
|
||||||
sourceFamily: $request->sourceFamily,
|
|
||||||
auditAction: AuditActionId::AiExecutionDecisionEvaluated,
|
|
||||||
auditMetadata: [],
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -94,16 +94,13 @@ enum AuditActionId: string
|
|||||||
case TenantReviewRefreshed = 'tenant_review.refreshed';
|
case TenantReviewRefreshed = 'tenant_review.refreshed';
|
||||||
case TenantReviewPublished = 'tenant_review.published';
|
case TenantReviewPublished = 'tenant_review.published';
|
||||||
case TenantReviewArchived = 'tenant_review.archived';
|
case TenantReviewArchived = 'tenant_review.archived';
|
||||||
case TenantReviewOpened = 'tenant_review.opened';
|
|
||||||
case TenantReviewExported = 'tenant_review.exported';
|
case TenantReviewExported = 'tenant_review.exported';
|
||||||
case TenantReviewSuccessorCreated = 'tenant_review.successor_created';
|
case TenantReviewSuccessorCreated = 'tenant_review.successor_created';
|
||||||
case ReviewPackDownloaded = 'review_pack.downloaded';
|
|
||||||
case TenantTriageReviewMarkedReviewed = 'tenant_triage_review.marked_reviewed';
|
case TenantTriageReviewMarkedReviewed = 'tenant_triage_review.marked_reviewed';
|
||||||
case TenantTriageReviewMarkedFollowUpNeeded = 'tenant_triage_review.marked_follow_up_needed';
|
case TenantTriageReviewMarkedFollowUpNeeded = 'tenant_triage_review.marked_follow_up_needed';
|
||||||
|
|
||||||
case SupportDiagnosticsOpened = 'support_diagnostics.opened';
|
case SupportDiagnosticsOpened = 'support_diagnostics.opened';
|
||||||
case SupportRequestCreated = 'support_request.created';
|
case SupportRequestCreated = 'support_request.created';
|
||||||
case AiExecutionDecisionEvaluated = 'ai_execution.decision_evaluated';
|
|
||||||
case OperationalControlPaused = 'operational_control.paused';
|
case OperationalControlPaused = 'operational_control.paused';
|
||||||
case OperationalControlUpdated = 'operational_control.updated';
|
case OperationalControlUpdated = 'operational_control.updated';
|
||||||
case OperationalControlResumed = 'operational_control.resumed';
|
case OperationalControlResumed = 'operational_control.resumed';
|
||||||
@ -240,15 +237,12 @@ private static function labels(): array
|
|||||||
self::TenantReviewRefreshed->value => 'Tenant review refreshed',
|
self::TenantReviewRefreshed->value => 'Tenant review refreshed',
|
||||||
self::TenantReviewPublished->value => 'Tenant review published',
|
self::TenantReviewPublished->value => 'Tenant review published',
|
||||||
self::TenantReviewArchived->value => 'Tenant review archived',
|
self::TenantReviewArchived->value => 'Tenant review archived',
|
||||||
self::TenantReviewOpened->value => 'Tenant review opened',
|
|
||||||
self::TenantReviewExported->value => 'Tenant review exported',
|
self::TenantReviewExported->value => 'Tenant review exported',
|
||||||
self::TenantReviewSuccessorCreated->value => 'Tenant review next cycle created',
|
self::TenantReviewSuccessorCreated->value => 'Tenant review next cycle created',
|
||||||
self::ReviewPackDownloaded->value => 'Review pack downloaded',
|
|
||||||
self::TenantTriageReviewMarkedReviewed->value => 'Triage review marked reviewed',
|
self::TenantTriageReviewMarkedReviewed->value => 'Triage review marked reviewed',
|
||||||
self::TenantTriageReviewMarkedFollowUpNeeded->value => 'Triage review marked follow-up needed',
|
self::TenantTriageReviewMarkedFollowUpNeeded->value => 'Triage review marked follow-up needed',
|
||||||
self::SupportDiagnosticsOpened->value => 'Support diagnostics opened',
|
self::SupportDiagnosticsOpened->value => 'Support diagnostics opened',
|
||||||
self::SupportRequestCreated->value => 'Support request created',
|
self::SupportRequestCreated->value => 'Support request created',
|
||||||
self::AiExecutionDecisionEvaluated->value => 'AI execution decision evaluated',
|
|
||||||
self::OperationalControlPaused->value => 'Operational control paused',
|
self::OperationalControlPaused->value => 'Operational control paused',
|
||||||
self::OperationalControlUpdated->value => 'Operational control updated',
|
self::OperationalControlUpdated->value => 'Operational control updated',
|
||||||
self::OperationalControlResumed->value => 'Operational control resumed',
|
self::OperationalControlResumed->value => 'Operational control resumed',
|
||||||
@ -332,13 +326,10 @@ private static function summaries(): array
|
|||||||
self::TenantReviewRefreshed->value => 'Tenant review refreshed',
|
self::TenantReviewRefreshed->value => 'Tenant review refreshed',
|
||||||
self::TenantReviewPublished->value => 'Tenant review published',
|
self::TenantReviewPublished->value => 'Tenant review published',
|
||||||
self::TenantReviewArchived->value => 'Tenant review archived',
|
self::TenantReviewArchived->value => 'Tenant review archived',
|
||||||
self::TenantReviewOpened->value => 'Tenant review opened',
|
|
||||||
self::TenantReviewExported->value => 'Tenant review exported',
|
self::TenantReviewExported->value => 'Tenant review exported',
|
||||||
self::TenantReviewSuccessorCreated->value => 'Tenant review next cycle created',
|
self::TenantReviewSuccessorCreated->value => 'Tenant review next cycle created',
|
||||||
self::ReviewPackDownloaded->value => 'Review pack downloaded',
|
|
||||||
self::SupportDiagnosticsOpened->value => 'Support diagnostics opened',
|
self::SupportDiagnosticsOpened->value => 'Support diagnostics opened',
|
||||||
self::SupportRequestCreated->value => 'Support request created',
|
self::SupportRequestCreated->value => 'Support request created',
|
||||||
self::AiExecutionDecisionEvaluated->value => 'AI execution decision evaluated',
|
|
||||||
self::OperationalControlPaused->value => 'Operational control paused',
|
self::OperationalControlPaused->value => 'Operational control paused',
|
||||||
self::OperationalControlUpdated->value => 'Operational control updated',
|
self::OperationalControlUpdated->value => 'Operational control updated',
|
||||||
self::OperationalControlResumed->value => 'Operational control resumed',
|
self::OperationalControlResumed->value => 'Operational control resumed',
|
||||||
|
|||||||
@ -18,8 +18,6 @@ class PlatformCapabilities
|
|||||||
|
|
||||||
public const DIRECTORY_VIEW = 'platform.directory.view';
|
public const DIRECTORY_VIEW = 'platform.directory.view';
|
||||||
|
|
||||||
public const COMMERCIAL_LIFECYCLE_MANAGE = 'platform.commercial_lifecycle.manage';
|
|
||||||
|
|
||||||
public const OPERATIONS_VIEW = 'platform.operations.view';
|
public const OPERATIONS_VIEW = 'platform.operations.view';
|
||||||
|
|
||||||
public const OPERATIONS_MANAGE = 'platform.operations.manage';
|
public const OPERATIONS_MANAGE = 'platform.operations.manage';
|
||||||
|
|||||||
@ -57,7 +57,6 @@ final class BadgeCatalog
|
|||||||
BadgeDomain::BaselineProfileStatus->value => Domains\BaselineProfileStatusBadge::class,
|
BadgeDomain::BaselineProfileStatus->value => Domains\BaselineProfileStatusBadge::class,
|
||||||
BadgeDomain::FindingType->value => Domains\FindingTypeBadge::class,
|
BadgeDomain::FindingType->value => Domains\FindingTypeBadge::class,
|
||||||
BadgeDomain::ReviewPackStatus->value => Domains\ReviewPackStatusBadge::class,
|
BadgeDomain::ReviewPackStatus->value => Domains\ReviewPackStatusBadge::class,
|
||||||
BadgeDomain::CommercialLifecycleState->value => Domains\CommercialLifecycleStateBadge::class,
|
|
||||||
BadgeDomain::EvidenceSnapshotStatus->value => Domains\EvidenceSnapshotStatusBadge::class,
|
BadgeDomain::EvidenceSnapshotStatus->value => Domains\EvidenceSnapshotStatusBadge::class,
|
||||||
BadgeDomain::EvidenceCompleteness->value => Domains\EvidenceCompletenessBadge::class,
|
BadgeDomain::EvidenceCompleteness->value => Domains\EvidenceCompletenessBadge::class,
|
||||||
BadgeDomain::TenantReviewStatus->value => Domains\TenantReviewStatusBadge::class,
|
BadgeDomain::TenantReviewStatus->value => Domains\TenantReviewStatusBadge::class,
|
||||||
|
|||||||
@ -48,7 +48,6 @@ enum BadgeDomain: string
|
|||||||
case BaselineProfileStatus = 'baseline_profile_status';
|
case BaselineProfileStatus = 'baseline_profile_status';
|
||||||
case FindingType = 'finding_type';
|
case FindingType = 'finding_type';
|
||||||
case ReviewPackStatus = 'review_pack_status';
|
case ReviewPackStatus = 'review_pack_status';
|
||||||
case CommercialLifecycleState = 'commercial_lifecycle_state';
|
|
||||||
case EvidenceSnapshotStatus = 'evidence_snapshot_status';
|
case EvidenceSnapshotStatus = 'evidence_snapshot_status';
|
||||||
case EvidenceCompleteness = 'evidence_completeness';
|
case EvidenceCompleteness = 'evidence_completeness';
|
||||||
case TenantReviewStatus = 'tenant_review_status';
|
case TenantReviewStatus = 'tenant_review_status';
|
||||||
|
|||||||
@ -1,26 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
namespace App\Support\Badges\Domains;
|
|
||||||
|
|
||||||
use App\Services\Entitlements\WorkspaceCommercialLifecycleResolver;
|
|
||||||
use App\Support\Badges\BadgeCatalog;
|
|
||||||
use App\Support\Badges\BadgeMapper;
|
|
||||||
use App\Support\Badges\BadgeSpec;
|
|
||||||
|
|
||||||
final class CommercialLifecycleStateBadge implements BadgeMapper
|
|
||||||
{
|
|
||||||
public function spec(mixed $value): BadgeSpec
|
|
||||||
{
|
|
||||||
$state = BadgeCatalog::normalizeState($value);
|
|
||||||
|
|
||||||
return match ($state) {
|
|
||||||
WorkspaceCommercialLifecycleResolver::STATE_TRIAL => new BadgeSpec('Trial', 'info', 'heroicon-m-clock'),
|
|
||||||
WorkspaceCommercialLifecycleResolver::STATE_GRACE => new BadgeSpec('Grace', 'warning', 'heroicon-m-exclamation-triangle'),
|
|
||||||
WorkspaceCommercialLifecycleResolver::STATE_ACTIVE_PAID => new BadgeSpec('Active paid', 'success', 'heroicon-m-check-circle'),
|
|
||||||
WorkspaceCommercialLifecycleResolver::STATE_SUSPENDED_READ_ONLY => new BadgeSpec('Suspended / read-only', 'danger', 'heroicon-m-lock-closed'),
|
|
||||||
default => BadgeSpec::unknown(),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,888 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
namespace App\Support\GovernanceInbox;
|
|
||||||
|
|
||||||
use App\Filament\Pages\Findings\FindingsIntakeQueue;
|
|
||||||
use App\Filament\Pages\Findings\MyFindingsInbox;
|
|
||||||
use App\Filament\Pages\Reviews\CustomerReviewWorkspace;
|
|
||||||
use App\Filament\Resources\AlertDeliveryResource;
|
|
||||||
use App\Filament\Resources\FindingExceptionResource;
|
|
||||||
use App\Filament\Resources\FindingResource;
|
|
||||||
use App\Filament\Resources\TenantResource;
|
|
||||||
use App\Filament\Resources\TenantReviewResource;
|
|
||||||
use App\Models\AlertDelivery;
|
|
||||||
use App\Models\Finding;
|
|
||||||
use App\Models\OperationRun;
|
|
||||||
use App\Models\Tenant;
|
|
||||||
use App\Models\TenantTriageReview;
|
|
||||||
use App\Models\User;
|
|
||||||
use App\Models\Workspace;
|
|
||||||
use App\Services\TenantReviews\TenantReviewRegisterService;
|
|
||||||
use App\Support\Auth\Capabilities;
|
|
||||||
use App\Support\BackupHealth\TenantBackupHealthAssessment;
|
|
||||||
use App\Support\BackupHealth\TenantBackupHealthResolver;
|
|
||||||
use App\Support\Navigation\CanonicalNavigationContext;
|
|
||||||
use App\Support\OperationRunLinks;
|
|
||||||
use App\Support\PortfolioTriage\PortfolioArrivalContextToken;
|
|
||||||
use App\Support\PortfolioTriage\TenantTriageReviewStateResolver;
|
|
||||||
use App\Support\RestoreSafety\RestoreSafetyResolver;
|
|
||||||
use App\Support\Tenants\TenantRecoveryTriagePresentation;
|
|
||||||
use Illuminate\Support\Str;
|
|
||||||
|
|
||||||
final readonly class GovernanceInboxSectionBuilder
|
|
||||||
{
|
|
||||||
private const PREVIEW_LIMIT = 3;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @var list<string>
|
|
||||||
*/
|
|
||||||
private const FAMILY_ORDER = [
|
|
||||||
'assigned_findings',
|
|
||||||
'intake_findings',
|
|
||||||
'stale_operations',
|
|
||||||
'alert_delivery_failures',
|
|
||||||
'review_follow_up',
|
|
||||||
];
|
|
||||||
|
|
||||||
public function __construct(
|
|
||||||
private TenantBackupHealthResolver $backupHealthResolver,
|
|
||||||
private RestoreSafetyResolver $restoreSafetyResolver,
|
|
||||||
private TenantTriageReviewStateResolver $tenantTriageReviewStateResolver,
|
|
||||||
private TenantReviewRegisterService $tenantReviewRegisterService,
|
|
||||||
) {}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @param array<int, Tenant> $authorizedTenants
|
|
||||||
* @param array<int, Tenant> $visibleFindingTenants
|
|
||||||
* @param array<int, Tenant> $reviewTenants
|
|
||||||
* @return array{
|
|
||||||
* sections: list<array<string, mixed>>,
|
|
||||||
* available_families: list<array{key: string, label: string, count: int}>,
|
|
||||||
* family_counts: array<string, int>,
|
|
||||||
* total_count: int,
|
|
||||||
* }
|
|
||||||
*/
|
|
||||||
public function build(
|
|
||||||
User $user,
|
|
||||||
Workspace $workspace,
|
|
||||||
array $authorizedTenants,
|
|
||||||
array $visibleFindingTenants,
|
|
||||||
array $reviewTenants,
|
|
||||||
bool $canViewAlerts,
|
|
||||||
?Tenant $selectedTenant = null,
|
|
||||||
?string $selectedFamily = null,
|
|
||||||
?CanonicalNavigationContext $navigationContext = null,
|
|
||||||
): array {
|
|
||||||
$authorizedTenantsById = $this->indexTenants($authorizedTenants);
|
|
||||||
$visibleFindingTenantsById = $this->indexTenants($visibleFindingTenants);
|
|
||||||
$reviewTenantsById = $this->indexTenants($reviewTenants);
|
|
||||||
|
|
||||||
$allSections = [];
|
|
||||||
$availableFamilies = [];
|
|
||||||
$familyCounts = [];
|
|
||||||
|
|
||||||
if ($visibleFindingTenantsById !== []) {
|
|
||||||
$assignedSection = $this->assignedFindingsSection(
|
|
||||||
user: $user,
|
|
||||||
visibleFindingTenants: $visibleFindingTenantsById,
|
|
||||||
selectedTenant: $selectedTenant,
|
|
||||||
navigationContext: $navigationContext,
|
|
||||||
);
|
|
||||||
$allSections[$assignedSection['key']] = $assignedSection;
|
|
||||||
$availableFamilies[] = [
|
|
||||||
'key' => $assignedSection['key'],
|
|
||||||
'label' => $assignedSection['label'],
|
|
||||||
'count' => $assignedSection['count'],
|
|
||||||
];
|
|
||||||
$familyCounts[$assignedSection['key']] = $assignedSection['count'];
|
|
||||||
|
|
||||||
$intakeSection = $this->intakeFindingsSection(
|
|
||||||
visibleFindingTenants: $visibleFindingTenantsById,
|
|
||||||
selectedTenant: $selectedTenant,
|
|
||||||
navigationContext: $navigationContext,
|
|
||||||
);
|
|
||||||
$allSections[$intakeSection['key']] = $intakeSection;
|
|
||||||
$availableFamilies[] = [
|
|
||||||
'key' => $intakeSection['key'],
|
|
||||||
'label' => $intakeSection['label'],
|
|
||||||
'count' => $intakeSection['count'],
|
|
||||||
];
|
|
||||||
$familyCounts[$intakeSection['key']] = $intakeSection['count'];
|
|
||||||
}
|
|
||||||
|
|
||||||
if ($authorizedTenantsById !== []) {
|
|
||||||
$operationsSection = $this->operationsSection(
|
|
||||||
workspace: $workspace,
|
|
||||||
authorizedTenants: $authorizedTenantsById,
|
|
||||||
selectedTenant: $selectedTenant,
|
|
||||||
navigationContext: $navigationContext,
|
|
||||||
);
|
|
||||||
$allSections[$operationsSection['key']] = $operationsSection;
|
|
||||||
$availableFamilies[] = [
|
|
||||||
'key' => $operationsSection['key'],
|
|
||||||
'label' => $operationsSection['label'],
|
|
||||||
'count' => $operationsSection['count'],
|
|
||||||
];
|
|
||||||
$familyCounts[$operationsSection['key']] = $operationsSection['count'];
|
|
||||||
}
|
|
||||||
|
|
||||||
if ($canViewAlerts) {
|
|
||||||
$alertsSection = $this->alertsSection(
|
|
||||||
workspace: $workspace,
|
|
||||||
authorizedTenants: $authorizedTenantsById,
|
|
||||||
selectedTenant: $selectedTenant,
|
|
||||||
navigationContext: $navigationContext,
|
|
||||||
);
|
|
||||||
$allSections[$alertsSection['key']] = $alertsSection;
|
|
||||||
$availableFamilies[] = [
|
|
||||||
'key' => $alertsSection['key'],
|
|
||||||
'label' => $alertsSection['label'],
|
|
||||||
'count' => $alertsSection['count'],
|
|
||||||
];
|
|
||||||
$familyCounts[$alertsSection['key']] = $alertsSection['count'];
|
|
||||||
}
|
|
||||||
|
|
||||||
if ($reviewTenantsById !== []) {
|
|
||||||
$reviewSection = $this->reviewFollowUpSection(
|
|
||||||
user: $user,
|
|
||||||
workspace: $workspace,
|
|
||||||
reviewTenants: $reviewTenantsById,
|
|
||||||
selectedTenant: $selectedTenant,
|
|
||||||
navigationContext: $navigationContext,
|
|
||||||
);
|
|
||||||
$allSections[$reviewSection['key']] = $reviewSection;
|
|
||||||
$availableFamilies[] = [
|
|
||||||
'key' => $reviewSection['key'],
|
|
||||||
'label' => $reviewSection['label'],
|
|
||||||
'count' => $reviewSection['count'],
|
|
||||||
];
|
|
||||||
$familyCounts[$reviewSection['key']] = $reviewSection['count'];
|
|
||||||
}
|
|
||||||
|
|
||||||
$sections = [];
|
|
||||||
|
|
||||||
foreach (self::FAMILY_ORDER as $familyKey) {
|
|
||||||
$section = $allSections[$familyKey] ?? null;
|
|
||||||
|
|
||||||
if (! is_array($section)) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
if ($selectedFamily !== null) {
|
|
||||||
if ($familyKey === $selectedFamily) {
|
|
||||||
$sections[] = $section;
|
|
||||||
}
|
|
||||||
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
if ((int) ($section['count'] ?? 0) > 0) {
|
|
||||||
$sections[] = $section;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return [
|
|
||||||
'sections' => $sections,
|
|
||||||
'available_families' => $availableFamilies,
|
|
||||||
'family_counts' => $familyCounts,
|
|
||||||
'total_count' => array_sum($familyCounts),
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @param array<int, Tenant> $tenants
|
|
||||||
* @return array<int, Tenant>
|
|
||||||
*/
|
|
||||||
private function indexTenants(array $tenants): array
|
|
||||||
{
|
|
||||||
$indexed = [];
|
|
||||||
|
|
||||||
foreach ($tenants as $tenant) {
|
|
||||||
$indexed[(int) $tenant->getKey()] = $tenant;
|
|
||||||
}
|
|
||||||
|
|
||||||
return $indexed;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @param array<int, Tenant> $visibleFindingTenants
|
|
||||||
* @return array<string, mixed>
|
|
||||||
*/
|
|
||||||
private function assignedFindingsSection(
|
|
||||||
User $user,
|
|
||||||
array $visibleFindingTenants,
|
|
||||||
?Tenant $selectedTenant,
|
|
||||||
?CanonicalNavigationContext $navigationContext,
|
|
||||||
): array {
|
|
||||||
$baseQuery = $this->assignedFindingsQuery($user, $visibleFindingTenants, $selectedTenant);
|
|
||||||
$count = (clone $baseQuery)->count();
|
|
||||||
$overdueCount = (clone $baseQuery)
|
|
||||||
->whereNotNull('due_at')
|
|
||||||
->where('due_at', '<', now())
|
|
||||||
->count();
|
|
||||||
$entries = $this->orderedAssignedFindingsQuery(clone $baseQuery)
|
|
||||||
->limit(self::PREVIEW_LIMIT)
|
|
||||||
->get()
|
|
||||||
->map(fn (Finding $finding): array => $this->findingEntry($finding, 'assigned_findings', $navigationContext, 10))
|
|
||||||
->all();
|
|
||||||
|
|
||||||
return [
|
|
||||||
'key' => 'assigned_findings',
|
|
||||||
'label' => 'Assigned findings',
|
|
||||||
'count' => $count,
|
|
||||||
'summary' => $this->assignedFindingsSummary($count, $overdueCount),
|
|
||||||
'dominant_action_label' => 'Open my findings',
|
|
||||||
'dominant_action_url' => $this->appendQuery(
|
|
||||||
MyFindingsInbox::getUrl(
|
|
||||||
panel: 'admin',
|
|
||||||
parameters: array_filter([
|
|
||||||
'tenant' => $selectedTenant?->external_id,
|
|
||||||
], static fn (mixed $value): bool => is_string($value) && $value !== ''),
|
|
||||||
),
|
|
||||||
$navigationContext?->toQuery() ?? [],
|
|
||||||
),
|
|
||||||
'entries' => $entries,
|
|
||||||
'empty_state' => $selectedTenant instanceof Tenant
|
|
||||||
? 'No assigned findings match this tenant filter right now.'
|
|
||||||
: 'No assigned findings are visible right now.',
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @param array<int, Tenant> $visibleFindingTenants
|
|
||||||
* @return array<string, mixed>
|
|
||||||
*/
|
|
||||||
private function intakeFindingsSection(
|
|
||||||
array $visibleFindingTenants,
|
|
||||||
?Tenant $selectedTenant,
|
|
||||||
?CanonicalNavigationContext $navigationContext,
|
|
||||||
): array {
|
|
||||||
$baseQuery = $this->intakeFindingsQuery($visibleFindingTenants, $selectedTenant);
|
|
||||||
$count = (clone $baseQuery)->count();
|
|
||||||
$needsTriageCount = (clone $baseQuery)
|
|
||||||
->whereIn('status', [Finding::STATUS_NEW, Finding::STATUS_REOPENED])
|
|
||||||
->count();
|
|
||||||
$entries = $this->orderedIntakeFindingsQuery(clone $baseQuery)
|
|
||||||
->limit(self::PREVIEW_LIMIT)
|
|
||||||
->get()
|
|
||||||
->map(fn (Finding $finding): array => $this->findingEntry($finding, 'intake_findings', $navigationContext, 20))
|
|
||||||
->all();
|
|
||||||
|
|
||||||
return [
|
|
||||||
'key' => 'intake_findings',
|
|
||||||
'label' => 'Findings intake',
|
|
||||||
'count' => $count,
|
|
||||||
'summary' => $this->intakeFindingsSummary($count, $needsTriageCount),
|
|
||||||
'dominant_action_label' => 'Open findings intake',
|
|
||||||
'dominant_action_url' => $this->appendQuery(
|
|
||||||
FindingsIntakeQueue::getUrl(
|
|
||||||
panel: 'admin',
|
|
||||||
parameters: array_filter([
|
|
||||||
'tenant' => $selectedTenant?->external_id,
|
|
||||||
'view' => $needsTriageCount > 0 ? 'needs_triage' : null,
|
|
||||||
], static fn (mixed $value): bool => $value !== null && $value !== ''),
|
|
||||||
),
|
|
||||||
$navigationContext?->toQuery() ?? [],
|
|
||||||
),
|
|
||||||
'entries' => $entries,
|
|
||||||
'empty_state' => $selectedTenant instanceof Tenant
|
|
||||||
? 'No intake findings match this tenant filter right now.'
|
|
||||||
: 'No intake findings are visible right now.',
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @param array<int, Tenant> $authorizedTenants
|
|
||||||
* @return array<string, mixed>
|
|
||||||
*/
|
|
||||||
private function operationsSection(
|
|
||||||
Workspace $workspace,
|
|
||||||
array $authorizedTenants,
|
|
||||||
?Tenant $selectedTenant,
|
|
||||||
?CanonicalNavigationContext $navigationContext,
|
|
||||||
): array {
|
|
||||||
$terminalQuery = $this->terminalOperationsQuery($workspace, $authorizedTenants, $selectedTenant);
|
|
||||||
$staleQuery = $this->staleOperationsQuery($workspace, $authorizedTenants, $selectedTenant);
|
|
||||||
$terminalCount = (clone $terminalQuery)->count();
|
|
||||||
$staleCount = (clone $staleQuery)->count();
|
|
||||||
$entries = array_merge(
|
|
||||||
(clone $terminalQuery)->latest('completed_at')->latest('id')->limit(self::PREVIEW_LIMIT)->get()->all(),
|
|
||||||
(clone $staleQuery)->latest('created_at')->latest('id')->limit(self::PREVIEW_LIMIT)->get()->all(),
|
|
||||||
);
|
|
||||||
$entries = collect($entries)
|
|
||||||
->unique(fn (OperationRun $run): int => (int) $run->getKey())
|
|
||||||
->sortBy([
|
|
||||||
fn (OperationRun $run): int => $run->problemClass() === OperationRun::PROBLEM_CLASS_TERMINAL_FOLLOW_UP ? 0 : 1,
|
|
||||||
fn (OperationRun $run): int => -1 * (int) $run->getKey(),
|
|
||||||
])
|
|
||||||
->take(self::PREVIEW_LIMIT)
|
|
||||||
->map(fn (OperationRun $run): array => $this->operationEntry($run, $navigationContext))
|
|
||||||
->values()
|
|
||||||
->all();
|
|
||||||
$dominantProblemClass = $terminalCount > 0
|
|
||||||
? OperationRun::PROBLEM_CLASS_TERMINAL_FOLLOW_UP
|
|
||||||
: OperationRun::PROBLEM_CLASS_ACTIVE_STALE_ATTENTION;
|
|
||||||
|
|
||||||
return [
|
|
||||||
'key' => 'stale_operations',
|
|
||||||
'label' => 'Operations follow-up',
|
|
||||||
'count' => $terminalCount + $staleCount,
|
|
||||||
'summary' => $this->operationsSummary($terminalCount, $staleCount),
|
|
||||||
'dominant_action_label' => $terminalCount > 0 ? 'Open terminal follow-up' : 'Open stale operations',
|
|
||||||
'dominant_action_url' => OperationRunLinks::index(
|
|
||||||
tenant: $selectedTenant,
|
|
||||||
context: $navigationContext,
|
|
||||||
problemClass: $dominantProblemClass,
|
|
||||||
),
|
|
||||||
'entries' => $entries,
|
|
||||||
'empty_state' => $selectedTenant instanceof Tenant
|
|
||||||
? 'No stale or terminal follow-up operations match this tenant filter right now.'
|
|
||||||
: 'No stale or terminal follow-up operations are visible right now.',
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @param array<int, Tenant> $authorizedTenants
|
|
||||||
* @return array<string, mixed>
|
|
||||||
*/
|
|
||||||
private function alertsSection(
|
|
||||||
Workspace $workspace,
|
|
||||||
array $authorizedTenants,
|
|
||||||
?Tenant $selectedTenant,
|
|
||||||
?CanonicalNavigationContext $navigationContext,
|
|
||||||
): array {
|
|
||||||
$baseQuery = $this->alertsQuery($workspace, $authorizedTenants, $selectedTenant);
|
|
||||||
$count = (clone $baseQuery)->count();
|
|
||||||
$entries = (clone $baseQuery)
|
|
||||||
->latest('created_at')
|
|
||||||
->latest('id')
|
|
||||||
->limit(self::PREVIEW_LIMIT)
|
|
||||||
->get()
|
|
||||||
->map(fn (AlertDelivery $delivery): array => $this->alertEntry($delivery, $navigationContext))
|
|
||||||
->all();
|
|
||||||
|
|
||||||
return [
|
|
||||||
'key' => 'alert_delivery_failures',
|
|
||||||
'label' => 'Alert delivery failures',
|
|
||||||
'count' => $count,
|
|
||||||
'summary' => $this->alertsSummary($count),
|
|
||||||
'dominant_action_label' => 'Open alert deliveries',
|
|
||||||
'dominant_action_url' => $this->appendQuery(
|
|
||||||
AlertDeliveryResource::getUrl(panel: 'admin'),
|
|
||||||
array_replace_recursive(
|
|
||||||
$navigationContext?->toQuery() ?? [],
|
|
||||||
[
|
|
||||||
'tableFilters' => array_filter([
|
|
||||||
'status' => ['value' => AlertDelivery::STATUS_FAILED],
|
|
||||||
'tenant_id' => $selectedTenant instanceof Tenant
|
|
||||||
? ['value' => (string) $selectedTenant->getKey()]
|
|
||||||
: null,
|
|
||||||
], static fn (mixed $value): bool => $value !== null),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
'entries' => $entries,
|
|
||||||
'empty_state' => $selectedTenant instanceof Tenant
|
|
||||||
? 'No failed alert deliveries match this tenant filter right now.'
|
|
||||||
: 'No failed alert deliveries are visible right now.',
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @param array<int, Tenant> $reviewTenants
|
|
||||||
* @return array<string, mixed>
|
|
||||||
*/
|
|
||||||
private function reviewFollowUpSection(
|
|
||||||
User $user,
|
|
||||||
Workspace $workspace,
|
|
||||||
array $reviewTenants,
|
|
||||||
?Tenant $selectedTenant,
|
|
||||||
?CanonicalNavigationContext $navigationContext,
|
|
||||||
): array {
|
|
||||||
$tenantIds = $selectedTenant instanceof Tenant
|
|
||||||
? [(int) $selectedTenant->getKey()]
|
|
||||||
: array_keys($reviewTenants);
|
|
||||||
$backupHealthByTenant = $this->backupHealthResolver->assessMany($tenantIds);
|
|
||||||
$recoveryEvidenceByTenant = $this->restoreSafetyResolver->dashboardRecoveryEvidenceForTenants($tenantIds, $backupHealthByTenant);
|
|
||||||
$resolved = $this->tenantTriageReviewStateResolver->resolveMany(
|
|
||||||
workspaceId: (int) $workspace->getKey(),
|
|
||||||
tenantIds: $tenantIds,
|
|
||||||
backupHealthByTenant: $backupHealthByTenant,
|
|
||||||
recoveryEvidenceByTenant: $recoveryEvidenceByTenant,
|
|
||||||
);
|
|
||||||
$latestPublishedReviews = $this->tenantReviewRegisterService
|
|
||||||
->latestPublishedQuery($user, $workspace)
|
|
||||||
->get()
|
|
||||||
->keyBy('tenant_id')
|
|
||||||
->all();
|
|
||||||
|
|
||||||
$rawEntries = [];
|
|
||||||
|
|
||||||
foreach ($tenantIds as $tenantId) {
|
|
||||||
$tenant = $reviewTenants[$tenantId] ?? null;
|
|
||||||
$rows = $resolved['rows'][$tenantId] ?? null;
|
|
||||||
|
|
||||||
if (! $tenant instanceof Tenant || ! is_array($rows)) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
foreach ([PortfolioArrivalContextToken::FAMILY_BACKUP_HEALTH, PortfolioArrivalContextToken::FAMILY_RECOVERY_EVIDENCE] as $family) {
|
|
||||||
$row = $rows[$family] ?? null;
|
|
||||||
|
|
||||||
if (! is_array($row) || ($row['current_concern_present'] ?? false) !== true) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
$derivedState = $row['derived_state'] ?? null;
|
|
||||||
|
|
||||||
if (! in_array($derivedState, [
|
|
||||||
TenantTriageReview::STATE_FOLLOW_UP_NEEDED,
|
|
||||||
TenantTriageReview::DERIVED_STATE_CHANGED_SINCE_REVIEW,
|
|
||||||
], true)) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
$rawEntries[] = $this->reviewEntry(
|
|
||||||
tenant: $tenant,
|
|
||||||
family: $family,
|
|
||||||
row: $row,
|
|
||||||
latestPublishedReview: $latestPublishedReviews[$tenantId] ?? null,
|
|
||||||
navigationContext: $navigationContext,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
usort($rawEntries, function (array $left, array $right): int {
|
|
||||||
$leftRank = (int) ($left['urgency_rank'] ?? 0);
|
|
||||||
$rightRank = (int) ($right['urgency_rank'] ?? 0);
|
|
||||||
|
|
||||||
if ($leftRank !== $rightRank) {
|
|
||||||
return $leftRank <=> $rightRank;
|
|
||||||
}
|
|
||||||
|
|
||||||
return strcmp((string) ($left['headline'] ?? ''), (string) ($right['headline'] ?? ''));
|
|
||||||
});
|
|
||||||
|
|
||||||
$followUpCount = collect($rawEntries)
|
|
||||||
->where('status_label', 'Follow-up needed')
|
|
||||||
->count();
|
|
||||||
$changedCount = collect($rawEntries)
|
|
||||||
->where('status_label', 'Changed since review')
|
|
||||||
->count();
|
|
||||||
|
|
||||||
return [
|
|
||||||
'key' => 'review_follow_up',
|
|
||||||
'label' => 'Review follow-up',
|
|
||||||
'count' => count($rawEntries),
|
|
||||||
'summary' => $this->reviewSummary($followUpCount, $changedCount),
|
|
||||||
'dominant_action_label' => 'Open review follow-up',
|
|
||||||
'dominant_action_url' => $selectedTenant instanceof Tenant
|
|
||||||
? $this->appendQuery(CustomerReviewWorkspace::tenantPrefilterUrl($selectedTenant), $navigationContext?->toQuery() ?? [])
|
|
||||||
: $this->appendQuery(TenantResource::getUrl(panel: 'admin'), array_replace_recursive(
|
|
||||||
$navigationContext?->toQuery() ?? [],
|
|
||||||
[
|
|
||||||
'backup_posture' => [
|
|
||||||
TenantBackupHealthAssessment::POSTURE_ABSENT,
|
|
||||||
TenantBackupHealthAssessment::POSTURE_STALE,
|
|
||||||
TenantBackupHealthAssessment::POSTURE_DEGRADED,
|
|
||||||
],
|
|
||||||
'recovery_evidence' => [
|
|
||||||
TenantRecoveryTriagePresentation::RECOVERY_EVIDENCE_WEAKENED,
|
|
||||||
TenantRecoveryTriagePresentation::RECOVERY_EVIDENCE_UNVALIDATED,
|
|
||||||
],
|
|
||||||
'review_state' => [
|
|
||||||
TenantTriageReview::STATE_FOLLOW_UP_NEEDED,
|
|
||||||
TenantTriageReview::DERIVED_STATE_CHANGED_SINCE_REVIEW,
|
|
||||||
],
|
|
||||||
'triage_sort' => TenantRecoveryTriagePresentation::TRIAGE_SORT_WORST_FIRST,
|
|
||||||
],
|
|
||||||
)),
|
|
||||||
'entries' => array_slice($rawEntries, 0, self::PREVIEW_LIMIT),
|
|
||||||
'empty_state' => $selectedTenant instanceof Tenant
|
|
||||||
? 'No review follow-up is visible for this tenant filter right now.'
|
|
||||||
: 'No review follow-up is visible right now.',
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @param array<int, Tenant> $visibleFindingTenants
|
|
||||||
*/
|
|
||||||
private function assignedFindingsQuery(User $user, array $visibleFindingTenants, ?Tenant $selectedTenant): \Illuminate\Database\Eloquent\Builder
|
|
||||||
{
|
|
||||||
$tenantIds = $selectedTenant instanceof Tenant
|
|
||||||
? [(int) $selectedTenant->getKey()]
|
|
||||||
: array_keys($visibleFindingTenants);
|
|
||||||
|
|
||||||
return Finding::query()
|
|
||||||
->with(['tenant', 'ownerUser:id,name', 'assigneeUser:id,name'])
|
|
||||||
->withSubjectDisplayName()
|
|
||||||
->whereIn('tenant_id', $tenantIds === [] ? [-1] : $tenantIds)
|
|
||||||
->where('assignee_user_id', (int) $user->getKey())
|
|
||||||
->whereIn('status', Finding::openStatusesForQuery());
|
|
||||||
}
|
|
||||||
|
|
||||||
private function orderedAssignedFindingsQuery(\Illuminate\Database\Eloquent\Builder $query): \Illuminate\Database\Eloquent\Builder
|
|
||||||
{
|
|
||||||
return $query
|
|
||||||
->orderByRaw(
|
|
||||||
'case when due_at is not null and due_at < ? then 0 when reopened_at is not null then 1 else 2 end asc',
|
|
||||||
[now()],
|
|
||||||
)
|
|
||||||
->orderByRaw('case when due_at is null then 1 else 0 end asc')
|
|
||||||
->orderBy('due_at')
|
|
||||||
->orderByDesc('id');
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @param array<int, Tenant> $visibleFindingTenants
|
|
||||||
*/
|
|
||||||
private function intakeFindingsQuery(array $visibleFindingTenants, ?Tenant $selectedTenant): \Illuminate\Database\Eloquent\Builder
|
|
||||||
{
|
|
||||||
$tenantIds = $selectedTenant instanceof Tenant
|
|
||||||
? [(int) $selectedTenant->getKey()]
|
|
||||||
: array_keys($visibleFindingTenants);
|
|
||||||
|
|
||||||
return Finding::query()
|
|
||||||
->with(['tenant', 'ownerUser:id,name', 'assigneeUser:id,name'])
|
|
||||||
->withSubjectDisplayName()
|
|
||||||
->whereIn('tenant_id', $tenantIds === [] ? [-1] : $tenantIds)
|
|
||||||
->whereNull('assignee_user_id')
|
|
||||||
->whereIn('status', Finding::openStatusesForQuery());
|
|
||||||
}
|
|
||||||
|
|
||||||
private function orderedIntakeFindingsQuery(\Illuminate\Database\Eloquent\Builder $query): \Illuminate\Database\Eloquent\Builder
|
|
||||||
{
|
|
||||||
return $query
|
|
||||||
->orderByRaw(
|
|
||||||
"case
|
|
||||||
when due_at is not null and due_at < ? then 0
|
|
||||||
when status = ? then 1
|
|
||||||
when status = ? then 2
|
|
||||||
else 3
|
|
||||||
end asc",
|
|
||||||
[now(), Finding::STATUS_REOPENED, Finding::STATUS_NEW],
|
|
||||||
)
|
|
||||||
->orderByRaw('case when due_at is null then 1 else 0 end asc')
|
|
||||||
->orderBy('due_at')
|
|
||||||
->orderByDesc('id');
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @param array<int, Tenant> $authorizedTenants
|
|
||||||
*/
|
|
||||||
private function terminalOperationsQuery(Workspace $workspace, array $authorizedTenants, ?Tenant $selectedTenant): \Illuminate\Database\Eloquent\Builder
|
|
||||||
{
|
|
||||||
return $this->operationsBaseQuery($workspace, $authorizedTenants, $selectedTenant)
|
|
||||||
->terminalFollowUp();
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @param array<int, Tenant> $authorizedTenants
|
|
||||||
*/
|
|
||||||
private function staleOperationsQuery(Workspace $workspace, array $authorizedTenants, ?Tenant $selectedTenant): \Illuminate\Database\Eloquent\Builder
|
|
||||||
{
|
|
||||||
return $this->operationsBaseQuery($workspace, $authorizedTenants, $selectedTenant)
|
|
||||||
->activeStaleAttention();
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @param array<int, Tenant> $authorizedTenants
|
|
||||||
*/
|
|
||||||
private function operationsBaseQuery(Workspace $workspace, array $authorizedTenants, ?Tenant $selectedTenant): \Illuminate\Database\Eloquent\Builder
|
|
||||||
{
|
|
||||||
$tenantIds = array_keys($authorizedTenants);
|
|
||||||
|
|
||||||
return OperationRun::query()
|
|
||||||
->with('tenant')
|
|
||||||
->where('workspace_id', (int) $workspace->getKey())
|
|
||||||
->where(function ($query) use ($selectedTenant, $tenantIds): void {
|
|
||||||
if ($selectedTenant instanceof Tenant) {
|
|
||||||
$query->where('tenant_id', (int) $selectedTenant->getKey());
|
|
||||||
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
$query
|
|
||||||
->whereIn('tenant_id', $tenantIds === [] ? [-1] : $tenantIds)
|
|
||||||
->orWhereNull('tenant_id');
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @param array<int, Tenant> $authorizedTenants
|
|
||||||
*/
|
|
||||||
private function alertsQuery(Workspace $workspace, array $authorizedTenants, ?Tenant $selectedTenant): \Illuminate\Database\Eloquent\Builder
|
|
||||||
{
|
|
||||||
$tenantIds = array_keys($authorizedTenants);
|
|
||||||
|
|
||||||
return AlertDelivery::query()
|
|
||||||
->with('tenant')
|
|
||||||
->where('workspace_id', (int) $workspace->getKey())
|
|
||||||
->where('status', AlertDelivery::STATUS_FAILED)
|
|
||||||
->where(function ($query) use ($selectedTenant, $tenantIds): void {
|
|
||||||
if ($selectedTenant instanceof Tenant) {
|
|
||||||
$query->where('tenant_id', (int) $selectedTenant->getKey());
|
|
||||||
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
$query
|
|
||||||
->whereIn('tenant_id', $tenantIds === [] ? [-1] : $tenantIds)
|
|
||||||
->orWhereNull('tenant_id');
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @return array<string, mixed>
|
|
||||||
*/
|
|
||||||
private function findingEntry(Finding $finding, string $familyKey, ?CanonicalNavigationContext $navigationContext, int $baseUrgencyRank): array
|
|
||||||
{
|
|
||||||
$sublineParts = array_values(array_filter([
|
|
||||||
$finding->owner_user_id !== null ? 'Owner: '.FindingResource::accountableOwnerDisplayFor($finding) : null,
|
|
||||||
FindingExceptionResource::relativeTimeDescription($finding->due_at) ?? FindingResource::dueAttentionLabelFor($finding),
|
|
||||||
$finding->reopened_at !== null ? 'Reopened' : null,
|
|
||||||
]));
|
|
||||||
|
|
||||||
return [
|
|
||||||
'family_key' => $familyKey,
|
|
||||||
'source_model' => Finding::class,
|
|
||||||
'source_key' => (string) $finding->getKey(),
|
|
||||||
'tenant_id' => $finding->tenant ? (int) $finding->tenant->getKey() : null,
|
|
||||||
'tenant_label' => $finding->tenant?->name,
|
|
||||||
'headline' => $finding->resolvedSubjectDisplayName() ?? 'Finding #'.$finding->getKey(),
|
|
||||||
'subline' => $sublineParts === [] ? null : implode(' • ', $sublineParts),
|
|
||||||
'urgency_rank' => $baseUrgencyRank
|
|
||||||
+ ($finding->due_at?->isPast() === true ? 0 : 1)
|
|
||||||
+ ($finding->reopened_at !== null ? 0 : 1),
|
|
||||||
'status_label' => Str::of((string) $finding->status)->replace('_', ' ')->title()->value(),
|
|
||||||
'destination_url' => $this->appendQuery(
|
|
||||||
FindingResource::getUrl('view', ['record' => $finding], panel: 'tenant', tenant: $finding->tenant),
|
|
||||||
$navigationContext?->toQuery() ?? [],
|
|
||||||
),
|
|
||||||
'back_label' => $navigationContext?->backLinkLabel ?? 'Back to governance inbox',
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @return array<string, mixed>
|
|
||||||
*/
|
|
||||||
private function operationEntry(OperationRun $run, ?CanonicalNavigationContext $navigationContext): array
|
|
||||||
{
|
|
||||||
$problemClass = $run->problemClass();
|
|
||||||
|
|
||||||
return [
|
|
||||||
'family_key' => 'stale_operations',
|
|
||||||
'source_model' => OperationRun::class,
|
|
||||||
'source_key' => (string) $run->getKey(),
|
|
||||||
'tenant_id' => $run->tenant ? (int) $run->tenant->getKey() : null,
|
|
||||||
'tenant_label' => $run->tenant?->name,
|
|
||||||
'headline' => $problemClass === OperationRun::PROBLEM_CLASS_TERMINAL_FOLLOW_UP
|
|
||||||
? 'Terminal follow-up operation'
|
|
||||||
: 'Stale active operation',
|
|
||||||
'subline' => OperationRunLinks::identifier($run),
|
|
||||||
'urgency_rank' => $problemClass === OperationRun::PROBLEM_CLASS_TERMINAL_FOLLOW_UP ? 0 : 1,
|
|
||||||
'status_label' => $problemClass === OperationRun::PROBLEM_CLASS_TERMINAL_FOLLOW_UP
|
|
||||||
? 'Terminal follow-up'
|
|
||||||
: 'Stale',
|
|
||||||
'destination_url' => OperationRunLinks::tenantlessView($run, $navigationContext),
|
|
||||||
'back_label' => $navigationContext?->backLinkLabel ?? 'Back to governance inbox',
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @return array<string, mixed>
|
|
||||||
*/
|
|
||||||
private function alertEntry(AlertDelivery $delivery, ?CanonicalNavigationContext $navigationContext): array
|
|
||||||
{
|
|
||||||
$payload = is_array($delivery->payload) ? $delivery->payload : [];
|
|
||||||
$headline = is_string($payload['title'] ?? null) && $payload['title'] !== ''
|
|
||||||
? (string) $payload['title']
|
|
||||||
: 'Failed alert delivery';
|
|
||||||
$sublineParts = array_values(array_filter([
|
|
||||||
is_string($delivery->last_error_message) && $delivery->last_error_message !== ''
|
|
||||||
? $delivery->last_error_message
|
|
||||||
: null,
|
|
||||||
is_string($delivery->event_type) && $delivery->event_type !== ''
|
|
||||||
? $delivery->event_type
|
|
||||||
: null,
|
|
||||||
]));
|
|
||||||
|
|
||||||
return [
|
|
||||||
'family_key' => 'alert_delivery_failures',
|
|
||||||
'source_model' => AlertDelivery::class,
|
|
||||||
'source_key' => (string) $delivery->getKey(),
|
|
||||||
'tenant_id' => $delivery->tenant ? (int) $delivery->tenant->getKey() : null,
|
|
||||||
'tenant_label' => $delivery->tenant?->name,
|
|
||||||
'headline' => $headline,
|
|
||||||
'subline' => $sublineParts === [] ? null : implode(' • ', $sublineParts),
|
|
||||||
'urgency_rank' => 0,
|
|
||||||
'status_label' => 'Failed',
|
|
||||||
'destination_url' => $this->appendQuery(
|
|
||||||
AlertDeliveryResource::getUrl('view', ['record' => $delivery], panel: 'admin'),
|
|
||||||
$navigationContext?->toQuery() ?? [],
|
|
||||||
),
|
|
||||||
'back_label' => $navigationContext?->backLinkLabel ?? 'Back to governance inbox',
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @param array<string, mixed> $row
|
|
||||||
* @return array<string, mixed>
|
|
||||||
*/
|
|
||||||
private function reviewEntry(
|
|
||||||
Tenant $tenant,
|
|
||||||
string $family,
|
|
||||||
array $row,
|
|
||||||
mixed $latestPublishedReview,
|
|
||||||
?CanonicalNavigationContext $navigationContext,
|
|
||||||
): array {
|
|
||||||
$state = (string) ($row['derived_state'] ?? TenantTriageReview::DERIVED_STATE_NOT_REVIEWED);
|
|
||||||
$familyLabel = $family === PortfolioArrivalContextToken::FAMILY_BACKUP_HEALTH
|
|
||||||
? 'Backup health'
|
|
||||||
: 'Recovery evidence';
|
|
||||||
$headline = $state === TenantTriageReview::STATE_FOLLOW_UP_NEEDED
|
|
||||||
? $familyLabel.' needs review follow-up'
|
|
||||||
: $familyLabel.' changed since review';
|
|
||||||
$sublineParts = array_values(array_filter([
|
|
||||||
is_string($row['reviewed_by_user_name'] ?? null) && $row['reviewed_by_user_name'] !== ''
|
|
||||||
? 'Last review: '.$row['reviewed_by_user_name']
|
|
||||||
: null,
|
|
||||||
isset($row['reviewed_at']) && $row['reviewed_at'] !== null
|
|
||||||
? 'Reviewed '.optional($row['reviewed_at'])->toDateTimeString()
|
|
||||||
: null,
|
|
||||||
]));
|
|
||||||
$destinationUrl = $latestPublishedReview !== null
|
|
||||||
? TenantReviewResource::tenantScopedUrl('view', ['record' => $latestPublishedReview], $tenant, 'tenant')
|
|
||||||
: CustomerReviewWorkspace::tenantPrefilterUrl($tenant);
|
|
||||||
|
|
||||||
return [
|
|
||||||
'family_key' => 'review_follow_up',
|
|
||||||
'source_model' => TenantTriageReview::class,
|
|
||||||
'source_key' => (string) $tenant->getKey().':'.$family,
|
|
||||||
'tenant_id' => (int) $tenant->getKey(),
|
|
||||||
'tenant_label' => $tenant->name,
|
|
||||||
'headline' => $headline,
|
|
||||||
'subline' => $sublineParts === [] ? null : implode(' • ', $sublineParts),
|
|
||||||
'urgency_rank' => $state === TenantTriageReview::STATE_FOLLOW_UP_NEEDED ? 0 : 1,
|
|
||||||
'status_label' => $state === TenantTriageReview::STATE_FOLLOW_UP_NEEDED
|
|
||||||
? 'Follow-up needed'
|
|
||||||
: 'Changed since review',
|
|
||||||
'destination_url' => $this->appendQuery($destinationUrl, $navigationContext?->toQuery() ?? []),
|
|
||||||
'back_label' => $navigationContext?->backLinkLabel ?? 'Back to governance inbox',
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
private function assignedFindingsSummary(int $count, int $overdueCount): string
|
|
||||||
{
|
|
||||||
if ($count === 0) {
|
|
||||||
return 'No assigned findings are visible in the current scope.';
|
|
||||||
}
|
|
||||||
|
|
||||||
if ($overdueCount > 0) {
|
|
||||||
return sprintf(
|
|
||||||
'%d assigned finding%s remain open. %d %s overdue.',
|
|
||||||
$count,
|
|
||||||
$count === 1 ? '' : 's',
|
|
||||||
$overdueCount,
|
|
||||||
$overdueCount === 1 ? 'is' : 'are',
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return sprintf(
|
|
||||||
'%d assigned finding%s remain open in the visible scope.',
|
|
||||||
$count,
|
|
||||||
$count === 1 ? '' : 's',
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
private function intakeFindingsSummary(int $count, int $needsTriageCount): string
|
|
||||||
{
|
|
||||||
if ($count === 0) {
|
|
||||||
return 'No intake findings are visible in the current scope.';
|
|
||||||
}
|
|
||||||
|
|
||||||
return sprintf(
|
|
||||||
'%d unassigned finding%s remain in intake. %d still need first triage.',
|
|
||||||
$count,
|
|
||||||
$count === 1 ? '' : 's',
|
|
||||||
$needsTriageCount,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
private function operationsSummary(int $terminalCount, int $staleCount): string
|
|
||||||
{
|
|
||||||
if ($terminalCount + $staleCount === 0) {
|
|
||||||
return 'No stale or terminal follow-up operations are visible in the current scope.';
|
|
||||||
}
|
|
||||||
|
|
||||||
if ($terminalCount > 0 && $staleCount > 0) {
|
|
||||||
return sprintf(
|
|
||||||
'%d terminal follow-up operation%s and %d stale active run%s need monitoring attention.',
|
|
||||||
$terminalCount,
|
|
||||||
$terminalCount === 1 ? '' : 's',
|
|
||||||
$staleCount,
|
|
||||||
$staleCount === 1 ? '' : 's',
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if ($terminalCount > 0) {
|
|
||||||
return sprintf(
|
|
||||||
'%d terminal follow-up operation%s need monitoring attention.',
|
|
||||||
$terminalCount,
|
|
||||||
$terminalCount === 1 ? '' : 's',
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return sprintf(
|
|
||||||
'%d stale active run%s need monitoring attention.',
|
|
||||||
$staleCount,
|
|
||||||
$staleCount === 1 ? '' : 's',
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
private function alertsSummary(int $count): string
|
|
||||||
{
|
|
||||||
if ($count === 0) {
|
|
||||||
return 'No failed alert deliveries are visible in the current scope.';
|
|
||||||
}
|
|
||||||
|
|
||||||
return sprintf(
|
|
||||||
'%d failed alert delivery attempt%s remain visible in this workspace.',
|
|
||||||
$count,
|
|
||||||
$count === 1 ? '' : 's',
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
private function reviewSummary(int $followUpCount, int $changedCount): string
|
|
||||||
{
|
|
||||||
$total = $followUpCount + $changedCount;
|
|
||||||
|
|
||||||
if ($total === 0) {
|
|
||||||
return 'No review follow-up is visible in the current scope.';
|
|
||||||
}
|
|
||||||
|
|
||||||
return sprintf(
|
|
||||||
'%d review concern%s need attention. %d marked follow-up needed and %d changed since review.',
|
|
||||||
$total,
|
|
||||||
$total === 1 ? '' : 's',
|
|
||||||
$followUpCount,
|
|
||||||
$changedCount,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @param array<string, mixed> $query
|
|
||||||
*/
|
|
||||||
private function appendQuery(string $url, array $query): string
|
|
||||||
{
|
|
||||||
if ($query === []) {
|
|
||||||
return $url;
|
|
||||||
}
|
|
||||||
|
|
||||||
$separator = str_contains($url, '?') ? '&' : '?';
|
|
||||||
|
|
||||||
return $url.$separator.http_build_query($query);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -17,13 +17,6 @@ final class OperationalControlCatalog
|
|||||||
'operation_types' => ['restore.execute'],
|
'operation_types' => ['restore.execute'],
|
||||||
'affected_surfaces' => ['tenant.restore_runs.create'],
|
'affected_surfaces' => ['tenant.restore_runs.create'],
|
||||||
],
|
],
|
||||||
'ai.execution' => [
|
|
||||||
'key' => 'ai.execution',
|
|
||||||
'label' => 'AI execution',
|
|
||||||
'supported_scopes' => ['global'],
|
|
||||||
'operation_types' => ['ai.execution'],
|
|
||||||
'affected_surfaces' => ['governed_ai.execution'],
|
|
||||||
],
|
|
||||||
];
|
];
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@ -6,7 +6,6 @@
|
|||||||
|
|
||||||
use App\Models\ProviderConnection;
|
use App\Models\ProviderConnection;
|
||||||
use App\Models\Tenant;
|
use App\Models\Tenant;
|
||||||
use App\Support\Ai\AiDataClassification;
|
|
||||||
use App\Support\Governance\PlatformVocabularyGlossary;
|
use App\Support\Governance\PlatformVocabularyGlossary;
|
||||||
use App\Support\Links\RequiredPermissionsLinks;
|
use App\Support\Links\RequiredPermissionsLinks;
|
||||||
use App\Support\ReasonTranslation\ReasonPresenter;
|
use App\Support\ReasonTranslation\ReasonPresenter;
|
||||||
@ -148,43 +147,6 @@ public function knowledgeSource(): array
|
|||||||
return $this->catalog->knowledgeSource();
|
return $this->catalog->knowledgeSource();
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* @return array{
|
|
||||||
* use_case_key: string,
|
|
||||||
* source_family: string,
|
|
||||||
* data_classifications: list<string>,
|
|
||||||
* operational_metadata: array{version: int, topic_count: int},
|
|
||||||
* topics: list<array{
|
|
||||||
* topic_key: string,
|
|
||||||
* surface_families: list<string>,
|
|
||||||
* headline: string,
|
|
||||||
* short_explanation: string,
|
|
||||||
* troubleshooting_steps: list<string>,
|
|
||||||
* safe_next_action: string,
|
|
||||||
* glossary_terms: list<string>,
|
|
||||||
* docs_links: list<array{label: string, kind: string, url: ?string, resolver: ?string}>
|
|
||||||
* }>
|
|
||||||
* }
|
|
||||||
*/
|
|
||||||
public function aiProductKnowledgeAnswerDraftSource(): array
|
|
||||||
{
|
|
||||||
$source = $this->knowledgeSource();
|
|
||||||
|
|
||||||
return [
|
|
||||||
'use_case_key' => 'product_knowledge.answer_draft',
|
|
||||||
'source_family' => 'product_knowledge',
|
|
||||||
'data_classifications' => [
|
|
||||||
AiDataClassification::ProductKnowledge->value,
|
|
||||||
AiDataClassification::OperationalMetadata->value,
|
|
||||||
],
|
|
||||||
'operational_metadata' => [
|
|
||||||
'version' => (int) $source['version'],
|
|
||||||
'topic_count' => (int) $source['topic_count'],
|
|
||||||
],
|
|
||||||
'topics' => $source['topics'],
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @param array<string, mixed>|null $verificationReport
|
* @param array<string, mixed>|null $verificationReport
|
||||||
*/
|
*/
|
||||||
|
|||||||
@ -4,10 +4,7 @@
|
|||||||
|
|
||||||
namespace App\Support\Settings;
|
namespace App\Support\Settings;
|
||||||
|
|
||||||
use App\Support\Ai\AiPolicyMode;
|
|
||||||
use App\Models\Finding;
|
use App\Models\Finding;
|
||||||
use App\Services\Entitlements\WorkspaceCommercialLifecycleResolver;
|
|
||||||
use App\Services\Entitlements\WorkspacePlanProfileCatalog;
|
|
||||||
|
|
||||||
final class SettingsRegistry
|
final class SettingsRegistry
|
||||||
{
|
{
|
||||||
@ -20,15 +17,6 @@ public function __construct()
|
|||||||
{
|
{
|
||||||
$this->definitions = [];
|
$this->definitions = [];
|
||||||
|
|
||||||
$this->register(new SettingDefinition(
|
|
||||||
domain: 'ai',
|
|
||||||
key: 'policy_mode',
|
|
||||||
type: 'string',
|
|
||||||
systemDefault: AiPolicyMode::Disabled->value,
|
|
||||||
rules: ['required', 'string', 'in:disabled,private_only'],
|
|
||||||
normalizer: static fn (mixed $value): string => strtolower(trim((string) $value)),
|
|
||||||
));
|
|
||||||
|
|
||||||
$this->register(new SettingDefinition(
|
$this->register(new SettingDefinition(
|
||||||
domain: 'backup',
|
domain: 'backup',
|
||||||
key: 'retention_keep_last_default',
|
key: 'retention_keep_last_default',
|
||||||
@ -230,129 +218,6 @@ static function (string $attribute, mixed $value, \Closure $fail): void {
|
|||||||
rules: ['required', 'integer', 'min:0', 'max:10080'],
|
rules: ['required', 'integer', 'min:0', 'max:10080'],
|
||||||
normalizer: static fn (mixed $value): int => (int) $value,
|
normalizer: static fn (mixed $value): int => (int) $value,
|
||||||
));
|
));
|
||||||
|
|
||||||
$this->register(new SettingDefinition(
|
|
||||||
domain: 'entitlements',
|
|
||||||
key: 'plan_profile',
|
|
||||||
type: 'string',
|
|
||||||
systemDefault: WorkspacePlanProfileCatalog::defaultProfileId(),
|
|
||||||
rules: [
|
|
||||||
'nullable',
|
|
||||||
'string',
|
|
||||||
'in:'.implode(',', WorkspacePlanProfileCatalog::profileIds()),
|
|
||||||
],
|
|
||||||
normalizer: static function (mixed $value): ?string {
|
|
||||||
if ($value === null) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
$normalized = trim((string) $value);
|
|
||||||
|
|
||||||
return $normalized === '' ? null : $normalized;
|
|
||||||
},
|
|
||||||
));
|
|
||||||
|
|
||||||
$this->register(new SettingDefinition(
|
|
||||||
domain: 'entitlements',
|
|
||||||
key: 'managed_tenant_limit_override_value',
|
|
||||||
type: 'int',
|
|
||||||
systemDefault: null,
|
|
||||||
rules: ['nullable', 'integer', 'min:0'],
|
|
||||||
normalizer: static function (mixed $value): ?int {
|
|
||||||
if ($value === null || $value === '') {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return (int) $value;
|
|
||||||
},
|
|
||||||
));
|
|
||||||
|
|
||||||
$this->register(new SettingDefinition(
|
|
||||||
domain: 'entitlements',
|
|
||||||
key: 'managed_tenant_limit_override_reason',
|
|
||||||
type: 'string',
|
|
||||||
systemDefault: null,
|
|
||||||
rules: ['nullable', 'string', 'max:500'],
|
|
||||||
normalizer: static function (mixed $value): ?string {
|
|
||||||
if ($value === null) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
$normalized = trim((string) $value);
|
|
||||||
|
|
||||||
return $normalized === '' ? null : $normalized;
|
|
||||||
},
|
|
||||||
));
|
|
||||||
|
|
||||||
$this->register(new SettingDefinition(
|
|
||||||
domain: 'entitlements',
|
|
||||||
key: 'review_pack_generation_override_value',
|
|
||||||
type: 'bool',
|
|
||||||
systemDefault: null,
|
|
||||||
rules: ['nullable', 'boolean'],
|
|
||||||
normalizer: static function (mixed $value): ?bool {
|
|
||||||
if ($value === null || $value === '') {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return filter_var($value, FILTER_VALIDATE_BOOL);
|
|
||||||
},
|
|
||||||
));
|
|
||||||
|
|
||||||
$this->register(new SettingDefinition(
|
|
||||||
domain: 'entitlements',
|
|
||||||
key: 'review_pack_generation_override_reason',
|
|
||||||
type: 'string',
|
|
||||||
systemDefault: null,
|
|
||||||
rules: ['nullable', 'string', 'max:500'],
|
|
||||||
normalizer: static function (mixed $value): ?string {
|
|
||||||
if ($value === null) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
$normalized = trim((string) $value);
|
|
||||||
|
|
||||||
return $normalized === '' ? null : $normalized;
|
|
||||||
},
|
|
||||||
));
|
|
||||||
|
|
||||||
$this->register(new SettingDefinition(
|
|
||||||
domain: 'entitlements',
|
|
||||||
key: WorkspaceCommercialLifecycleResolver::SETTING_COMMERCIAL_LIFECYCLE_STATE,
|
|
||||||
type: 'string',
|
|
||||||
systemDefault: null,
|
|
||||||
rules: [
|
|
||||||
'nullable',
|
|
||||||
'string',
|
|
||||||
'in:'.implode(',', WorkspaceCommercialLifecycleResolver::stateIds()),
|
|
||||||
],
|
|
||||||
normalizer: static function (mixed $value): ?string {
|
|
||||||
if ($value === null) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
$normalized = strtolower(trim((string) $value));
|
|
||||||
|
|
||||||
return $normalized === '' ? null : $normalized;
|
|
||||||
},
|
|
||||||
));
|
|
||||||
|
|
||||||
$this->register(new SettingDefinition(
|
|
||||||
domain: 'entitlements',
|
|
||||||
key: WorkspaceCommercialLifecycleResolver::SETTING_COMMERCIAL_LIFECYCLE_REASON,
|
|
||||||
type: 'string',
|
|
||||||
systemDefault: null,
|
|
||||||
rules: ['nullable', 'string', 'max:500'],
|
|
||||||
normalizer: static function (mixed $value): ?string {
|
|
||||||
if ($value === null) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
$normalized = trim((string) $value);
|
|
||||||
|
|
||||||
return $normalized === '' ? null : $normalized;
|
|
||||||
},
|
|
||||||
));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@ -19,7 +19,6 @@
|
|||||||
use App\Models\TenantReview;
|
use App\Models\TenantReview;
|
||||||
use App\Models\User;
|
use App\Models\User;
|
||||||
use App\Models\Workspace;
|
use App\Models\Workspace;
|
||||||
use App\Support\Ai\AiDataClassification;
|
|
||||||
use App\Support\Navigation\RelatedNavigationResolver;
|
use App\Support\Navigation\RelatedNavigationResolver;
|
||||||
use App\Support\OperationRunLinks;
|
use App\Support\OperationRunLinks;
|
||||||
use App\Support\OpsUx\GovernanceRunDiagnosticSummaryBuilder;
|
use App\Support\OpsUx\GovernanceRunDiagnosticSummaryBuilder;
|
||||||
@ -134,39 +133,6 @@ public function forOperationRun(OperationRun $run, ?User $actor = null): array
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* @return array{
|
|
||||||
* use_case_key: string,
|
|
||||||
* source_family: string,
|
|
||||||
* data_classifications: list<string>,
|
|
||||||
* summary: array{
|
|
||||||
* headline: string,
|
|
||||||
* dominant_issue: string,
|
|
||||||
* freshness_state: string,
|
|
||||||
* completeness_note: ?string,
|
|
||||||
* redaction_note: string,
|
|
||||||
* generated_from: string
|
|
||||||
* },
|
|
||||||
* redaction: array{mode: string, markers: list<string>},
|
|
||||||
* notes: list<string>
|
|
||||||
* }
|
|
||||||
*/
|
|
||||||
public function aiSupportDiagnosticsSummaryDraftSource(Tenant $tenant, ?User $actor = null): array
|
|
||||||
{
|
|
||||||
$bundle = $this->forTenant($tenant, $actor);
|
|
||||||
|
|
||||||
return [
|
|
||||||
'use_case_key' => 'support_diagnostics.summary_draft',
|
|
||||||
'source_family' => 'support_diagnostics',
|
|
||||||
'data_classifications' => [
|
|
||||||
AiDataClassification::RedactedSupportSummary->value,
|
|
||||||
],
|
|
||||||
'summary' => $bundle['summary'],
|
|
||||||
'redaction' => $bundle['redaction'],
|
|
||||||
'notes' => $bundle['notes'],
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @param list<array<string, mixed>> $sections
|
* @param list<array<string, mixed>> $sections
|
||||||
* @return array<string, mixed>
|
* @return array<string, mixed>
|
||||||
|
|||||||
@ -39,7 +39,6 @@
|
|||||||
use App\Filament\System\Pages\Dashboard as SystemDashboard;
|
use App\Filament\System\Pages\Dashboard as SystemDashboard;
|
||||||
use App\Filament\System\Pages\Directory\ViewTenant as SystemDirectoryViewTenant;
|
use App\Filament\System\Pages\Directory\ViewTenant as SystemDirectoryViewTenant;
|
||||||
use App\Filament\System\Pages\Directory\ViewWorkspace as SystemDirectoryViewWorkspace;
|
use App\Filament\System\Pages\Directory\ViewWorkspace as SystemDirectoryViewWorkspace;
|
||||||
use App\Filament\System\Pages\Ops\Controls;
|
|
||||||
use App\Filament\System\Pages\Ops\Runbooks;
|
use App\Filament\System\Pages\Ops\Runbooks;
|
||||||
use App\Filament\System\Pages\Ops\ViewRun;
|
use App\Filament\System\Pages\Ops\ViewRun;
|
||||||
use App\Filament\System\Pages\RepairWorkspaceOwners;
|
use App\Filament\System\Pages\RepairWorkspaceOwners;
|
||||||
@ -662,32 +661,6 @@ public static function spec195ResidualSurfaceInventory(): array
|
|||||||
'mustRemainBaselineExempt' => false,
|
'mustRemainBaselineExempt' => false,
|
||||||
'mustNotRemainBaselineExempt' => true,
|
'mustNotRemainBaselineExempt' => true,
|
||||||
],
|
],
|
||||||
Controls::class => [
|
|
||||||
'surfaceKey' => 'system_ops_controls',
|
|
||||||
'surfaceName' => 'System Ops Controls',
|
|
||||||
'pageClass' => Controls::class,
|
|
||||||
'panelPlane' => 'system',
|
|
||||||
'surfaceKind' => 'system_utility',
|
|
||||||
'discoveryState' => 'outside_primary_discovery',
|
|
||||||
'closureDecision' => 'separately_governed',
|
|
||||||
'reasonCategory' => 'workflow_specific_governance',
|
|
||||||
'explicitReason' => 'Operational controls is a dedicated system control workbench with confirmation-backed pause, resume, and history actions plus restore-gate coupling, so it remains governed by focused workflow tests instead of the generic declaration-backed contract.',
|
|
||||||
'evidence' => [
|
|
||||||
[
|
|
||||||
'kind' => 'feature_livewire_test',
|
|
||||||
'reference' => 'tests/Feature/System/OpsControls/OperationalControlManagementTest.php',
|
|
||||||
'proves' => 'The controls page keeps capability-gated operational-control actions, confirmation semantics, scope previews, and audited pause or resume behavior under dedicated coverage.',
|
|
||||||
],
|
|
||||||
[
|
|
||||||
'kind' => 'feature_livewire_test',
|
|
||||||
'reference' => 'tests/Feature/Restore/OperationalControlRestoreExecutionGateTest.php',
|
|
||||||
'proves' => 'Restore execution stays coupled to the shared operational-control workflow, including blocked execution and non-retroactive pause behavior after acceptance.',
|
|
||||||
],
|
|
||||||
],
|
|
||||||
'followUpAction' => 'add_guard_only',
|
|
||||||
'mustRemainBaselineExempt' => false,
|
|
||||||
'mustNotRemainBaselineExempt' => true,
|
|
||||||
],
|
|
||||||
RepairWorkspaceOwners::class => [
|
RepairWorkspaceOwners::class => [
|
||||||
'surfaceKey' => 'repair_workspace_owners',
|
'surfaceKey' => 'repair_workspace_owners',
|
||||||
'surfaceName' => 'Repair Workspace Owners',
|
'surfaceName' => 'Repair Workspace Owners',
|
||||||
@ -749,17 +722,12 @@ public static function spec195ResidualSurfaceInventory(): array
|
|||||||
'discoveryState' => 'outside_primary_discovery',
|
'discoveryState' => 'outside_primary_discovery',
|
||||||
'closureDecision' => 'harmless_special_case',
|
'closureDecision' => 'harmless_special_case',
|
||||||
'reasonCategory' => 'read_mostly_context_detail',
|
'reasonCategory' => 'read_mostly_context_detail',
|
||||||
'explicitReason' => 'The workspace directory detail page is a read-mostly drilldown with one bounded, capability-gated commercial lifecycle mutation added by spec 251; it is still not a declaration-backed mutable system workbench.',
|
'explicitReason' => 'The workspace directory detail page is a read-mostly drilldown that exposes context and links, not a declaration-backed mutable system workbench.',
|
||||||
'evidence' => [
|
'evidence' => [
|
||||||
[
|
[
|
||||||
'kind' => 'feature_livewire_test',
|
'kind' => 'feature_livewire_test',
|
||||||
'reference' => 'tests/Feature/System/Spec195/SystemDirectoryResidualSurfaceTest.php',
|
'reference' => 'tests/Feature/System/Spec195/SystemDirectoryResidualSurfaceTest.php',
|
||||||
'proves' => 'The workspace detail page stays capability-gated and renders contextual tenant and run links while remaining outside the primary declaration-backed table contract.',
|
'proves' => 'The workspace detail page stays capability-gated and renders contextual tenant and run links without mutating actions.',
|
||||||
],
|
|
||||||
[
|
|
||||||
'kind' => 'feature_livewire_test',
|
|
||||||
'reference' => 'tests/Feature/System/ViewWorkspaceEntitlementsTest.php',
|
|
||||||
'proves' => 'The commercial lifecycle mutation is separately capability-gated, confirmation-protected, rationale-required, and audited.',
|
|
||||||
],
|
],
|
||||||
[
|
[
|
||||||
'kind' => 'authorization_test',
|
'kind' => 'authorization_test',
|
||||||
|
|||||||
@ -8,8 +8,6 @@ final class WorkspaceResolver
|
|||||||
{
|
{
|
||||||
public function resolve(string $value): ?Workspace
|
public function resolve(string $value): ?Workspace
|
||||||
{
|
{
|
||||||
$value = $this->normalizeRouteValue($value);
|
|
||||||
|
|
||||||
$workspace = Workspace::query()
|
$workspace = Workspace::query()
|
||||||
->where('slug', $value)
|
->where('slug', $value)
|
||||||
->first();
|
->first();
|
||||||
@ -24,37 +22,4 @@ public function resolve(string $value): ?Workspace
|
|||||||
|
|
||||||
return Workspace::query()->whereKey((int) $value)->first();
|
return Workspace::query()->whereKey((int) $value)->first();
|
||||||
}
|
}
|
||||||
|
|
||||||
private function normalizeRouteValue(string $value): string
|
|
||||||
{
|
|
||||||
$value = trim($value);
|
|
||||||
|
|
||||||
if (! str_starts_with($value, '{')) {
|
|
||||||
return $value;
|
|
||||||
}
|
|
||||||
|
|
||||||
$decoded = json_decode($value, true);
|
|
||||||
|
|
||||||
if (! is_array($decoded)) {
|
|
||||||
return $value;
|
|
||||||
}
|
|
||||||
|
|
||||||
$slug = $decoded['slug'] ?? null;
|
|
||||||
|
|
||||||
if (is_string($slug) && $slug !== '') {
|
|
||||||
return $slug;
|
|
||||||
}
|
|
||||||
|
|
||||||
$id = $decoded['id'] ?? null;
|
|
||||||
|
|
||||||
if (is_int($id)) {
|
|
||||||
return (string) $id;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (is_string($id) && ctype_digit($id)) {
|
|
||||||
return $id;
|
|
||||||
}
|
|
||||||
|
|
||||||
return $value;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,164 +0,0 @@
|
|||||||
<x-filament-panels::page>
|
|
||||||
@php
|
|
||||||
$scope = $this->appliedScope();
|
|
||||||
$sections = $this->sections();
|
|
||||||
$emptyState = $this->calmEmptyState();
|
|
||||||
@endphp
|
|
||||||
|
|
||||||
<x-filament::section>
|
|
||||||
<div class="flex flex-col gap-3">
|
|
||||||
<div class="inline-flex w-fit items-center gap-2 rounded-full border border-primary-200 bg-primary-50 px-3 py-1 text-xs font-medium text-primary-700 dark:border-primary-700/60 dark:bg-primary-950/40 dark:text-primary-300">
|
|
||||||
<x-filament::icon icon="heroicon-o-inbox-stack" class="h-3.5 w-3.5" />
|
|
||||||
Governance inbox
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="space-y-1">
|
|
||||||
<h1 class="text-2xl font-semibold tracking-tight text-gray-950 dark:text-white">
|
|
||||||
Governance inbox
|
|
||||||
</h1>
|
|
||||||
|
|
||||||
<p class="max-w-3xl text-sm leading-6 text-gray-600 dark:text-gray-300">
|
|
||||||
This workspace decision surface routes you into the existing findings, operations, alerts, and review surfaces without introducing a second workflow state.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="flex flex-wrap gap-2 text-sm text-gray-600 dark:text-gray-300">
|
|
||||||
@if (filled($scope['workspace_label'] ?? null))
|
|
||||||
<span class="inline-flex items-center rounded-full bg-gray-100 px-3 py-1 text-xs font-medium text-gray-700 dark:bg-gray-800 dark:text-gray-200">
|
|
||||||
Workspace: {{ $scope['workspace_label'] }}
|
|
||||||
</span>
|
|
||||||
@endif
|
|
||||||
|
|
||||||
<span class="inline-flex items-center rounded-full bg-gray-100 px-3 py-1 text-xs font-medium text-gray-700 dark:bg-gray-800 dark:text-gray-200">
|
|
||||||
Scope: {{ $scope['family_label'] ?? 'All attention' }}
|
|
||||||
</span>
|
|
||||||
|
|
||||||
<span class="inline-flex items-center rounded-full bg-gray-100 px-3 py-1 text-xs font-medium text-gray-700 dark:bg-gray-800 dark:text-gray-200">
|
|
||||||
Visible items: {{ $scope['total_count'] ?? 0 }}
|
|
||||||
</span>
|
|
||||||
|
|
||||||
@if (filled($scope['tenant_label'] ?? null))
|
|
||||||
<span class="inline-flex items-center rounded-full bg-warning-50 px-3 py-1 text-xs font-medium text-warning-700 dark:bg-warning-500/10 dark:text-warning-300">
|
|
||||||
Tenant: {{ $scope['tenant_label'] }}
|
|
||||||
</span>
|
|
||||||
@endif
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="flex flex-wrap gap-2">
|
|
||||||
<a
|
|
||||||
href="{{ $this->pageUrl(['family' => null]) }}"
|
|
||||||
class="inline-flex items-center gap-2 rounded-full border px-3 py-1.5 text-sm font-medium transition {{ $this->family === null ? 'border-primary-300 bg-primary-50 text-primary-700 dark:border-primary-700/60 dark:bg-primary-950/40 dark:text-primary-300' : 'border-gray-200 text-gray-700 hover:border-gray-300 dark:border-gray-700 dark:text-gray-200 dark:hover:border-gray-600' }}"
|
|
||||||
>
|
|
||||||
All attention
|
|
||||||
<span class="rounded-full bg-black/5 px-2 py-0.5 text-xs dark:bg-white/10">{{ $scope['total_count'] ?? 0 }}</span>
|
|
||||||
</a>
|
|
||||||
|
|
||||||
@foreach ($this->availableFamilies() as $family)
|
|
||||||
<a
|
|
||||||
href="{{ $this->pageUrl(['family' => $family['key']]) }}"
|
|
||||||
class="inline-flex items-center gap-2 rounded-full border px-3 py-1.5 text-sm font-medium transition {{ $this->isActiveFamily($family['key']) ? 'border-primary-300 bg-primary-50 text-primary-700 dark:border-primary-700/60 dark:bg-primary-950/40 dark:text-primary-300' : 'border-gray-200 text-gray-700 hover:border-gray-300 dark:border-gray-700 dark:text-gray-200 dark:hover:border-gray-600' }}"
|
|
||||||
>
|
|
||||||
{{ $family['label'] }}
|
|
||||||
<span class="rounded-full bg-black/5 px-2 py-0.5 text-xs dark:bg-white/10">{{ $family['count'] }}</span>
|
|
||||||
</a>
|
|
||||||
@endforeach
|
|
||||||
</div>
|
|
||||||
|
|
||||||
@if ($this->hasTenantPrefilter())
|
|
||||||
<div class="flex flex-wrap items-center gap-3 text-sm text-gray-600 dark:text-gray-300">
|
|
||||||
<span>The inbox is currently filtered to one tenant.</span>
|
|
||||||
|
|
||||||
<a href="{{ $this->pageUrl(['tenant' => null]) }}" class="font-medium text-primary-600 hover:text-primary-500 dark:text-primary-400 dark:hover:text-primary-300">
|
|
||||||
Clear tenant filter
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
@endif
|
|
||||||
</div>
|
|
||||||
</x-filament::section>
|
|
||||||
|
|
||||||
@if ($sections === [])
|
|
||||||
<x-filament::section>
|
|
||||||
<div class="flex flex-col gap-4 rounded-2xl border border-dashed border-gray-300 bg-gray-50/60 p-6 dark:border-gray-700 dark:bg-gray-900/40">
|
|
||||||
<div class="space-y-1">
|
|
||||||
<h2 class="text-base font-semibold text-gray-950 dark:text-white">{{ $emptyState['title'] }}</h2>
|
|
||||||
<p class="max-w-2xl text-sm leading-6 text-gray-600 dark:text-gray-300">{{ $emptyState['body'] }}</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
@if (filled($emptyState['action_label'] ?? null) && filled($emptyState['action_url'] ?? null))
|
|
||||||
<div>
|
|
||||||
<x-filament::button tag="a" color="gray" href="{{ $emptyState['action_url'] }}">
|
|
||||||
{{ $emptyState['action_label'] }}
|
|
||||||
</x-filament::button>
|
|
||||||
</div>
|
|
||||||
@endif
|
|
||||||
</div>
|
|
||||||
</x-filament::section>
|
|
||||||
@else
|
|
||||||
@foreach ($sections as $section)
|
|
||||||
<x-filament::section>
|
|
||||||
<div class="flex flex-col gap-4">
|
|
||||||
<div class="flex flex-col gap-3 lg:flex-row lg:items-start lg:justify-between">
|
|
||||||
<div class="space-y-2">
|
|
||||||
<div class="flex flex-wrap items-center gap-2">
|
|
||||||
<h2 class="text-base font-semibold text-gray-950 dark:text-white">{{ $section['label'] }}</h2>
|
|
||||||
<span class="inline-flex items-center rounded-full bg-gray-100 px-2.5 py-1 text-xs font-medium text-gray-700 dark:bg-gray-800 dark:text-gray-200">
|
|
||||||
{{ $section['count'] }}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<p class="max-w-3xl text-sm leading-6 text-gray-600 dark:text-gray-300">{{ $section['summary'] }}</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<x-filament::button tag="a" color="gray" href="{{ $section['dominant_action_url'] }}">
|
|
||||||
{{ $section['dominant_action_label'] }}
|
|
||||||
</x-filament::button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
@if ($section['count'] === 0)
|
|
||||||
<div class="rounded-2xl border border-dashed border-gray-300 bg-gray-50/60 p-5 text-sm leading-6 text-gray-600 dark:border-gray-700 dark:bg-gray-900/40 dark:text-gray-300">
|
|
||||||
{{ $section['empty_state'] }}
|
|
||||||
</div>
|
|
||||||
@else
|
|
||||||
<ul class="grid gap-3">
|
|
||||||
@foreach ($section['entries'] as $entry)
|
|
||||||
<li class="rounded-2xl border border-gray-200 bg-white p-4 shadow-sm dark:border-gray-800 dark:bg-gray-900/60">
|
|
||||||
<div class="flex flex-col gap-3 lg:flex-row lg:items-start lg:justify-between">
|
|
||||||
<div class="space-y-1.5">
|
|
||||||
@if (filled($entry['tenant_label'] ?? null))
|
|
||||||
<div class="text-xs font-medium uppercase tracking-[0.16em] text-gray-500 dark:text-gray-400">
|
|
||||||
{{ $entry['tenant_label'] }}
|
|
||||||
</div>
|
|
||||||
@endif
|
|
||||||
|
|
||||||
<div class="flex flex-wrap items-center gap-2">
|
|
||||||
<a href="{{ $entry['destination_url'] }}" class="text-sm font-semibold text-gray-950 hover:text-primary-600 dark:text-white dark:hover:text-primary-300">
|
|
||||||
{{ $entry['headline'] }}
|
|
||||||
</a>
|
|
||||||
|
|
||||||
<span class="inline-flex items-center rounded-full bg-gray-100 px-2.5 py-1 text-xs font-medium text-gray-700 dark:bg-gray-800 dark:text-gray-200">
|
|
||||||
{{ $entry['status_label'] }}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
@if (filled($entry['subline'] ?? null))
|
|
||||||
<p class="text-sm leading-6 text-gray-600 dark:text-gray-300">{{ $entry['subline'] }}</p>
|
|
||||||
@endif
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<x-filament::button tag="a" color="gray" size="sm" href="{{ $entry['destination_url'] }}">
|
|
||||||
Open source
|
|
||||||
</x-filament::button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</li>
|
|
||||||
@endforeach
|
|
||||||
</ul>
|
|
||||||
@endif
|
|
||||||
</div>
|
|
||||||
</x-filament::section>
|
|
||||||
@endforeach
|
|
||||||
@endif
|
|
||||||
</x-filament-panels::page>
|
|
||||||
@ -1,19 +0,0 @@
|
|||||||
<x-filament-panels::page>
|
|
||||||
<x-filament::section>
|
|
||||||
<div class="flex flex-col gap-3">
|
|
||||||
<div class="text-lg font-semibold text-gray-900 dark:text-gray-100">
|
|
||||||
Customer-safe review workspace
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="text-sm text-gray-600 dark:text-gray-300">
|
|
||||||
Review the latest published customer-safe posture for each entitled tenant without leaving the current workspace context.
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="text-sm text-gray-600 dark:text-gray-300">
|
|
||||||
Opening a row returns to the existing tenant review detail so evidence, review packs, and audit-aware proof remain on their canonical tenant-scoped surfaces.
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</x-filament::section>
|
|
||||||
|
|
||||||
{{ $this->table }}
|
|
||||||
</x-filament-panels::page>
|
|
||||||
@ -1,23 +1,9 @@
|
|||||||
@php
|
@php
|
||||||
use App\Support\Badges\BadgeCatalog;
|
|
||||||
use App\Support\Badges\BadgeDomain;
|
|
||||||
|
|
||||||
/** @var \App\Models\Workspace $workspace */
|
/** @var \App\Models\Workspace $workspace */
|
||||||
$workspace = $this->workspace;
|
$workspace = $this->workspace;
|
||||||
$customerHealthDecision = $this->customerHealthDecision();
|
$customerHealthDecision = $this->customerHealthDecision();
|
||||||
$tenants = $this->workspaceTenants();
|
$tenants = $this->workspaceTenants();
|
||||||
$runs = $this->recentRuns();
|
$runs = $this->recentRuns();
|
||||||
$commercialLifecycle = $this->workspaceCommercialLifecycleSummary();
|
|
||||||
$commercialBadge = BadgeCatalog::spec(BadgeDomain::CommercialLifecycleState, $commercialLifecycle['state'] ?? null);
|
|
||||||
$commercialActionDecisions = is_array($commercialLifecycle['action_decisions'] ?? null) ? $commercialLifecycle['action_decisions'] : [];
|
|
||||||
$activationLifecycleDecision = $commercialActionDecisions['managed_tenant_activation'] ?? null;
|
|
||||||
$reviewPackLifecycleDecision = $commercialActionDecisions['review_pack_start'] ?? null;
|
|
||||||
$readOnlyLifecycleDecision = $commercialActionDecisions['generated_pack_read'] ?? null;
|
|
||||||
$workspaceEntitlementSummary = $this->workspaceEntitlementSummary();
|
|
||||||
$planProfile = $workspaceEntitlementSummary['plan_profile'] ?? null;
|
|
||||||
$entitlementDecisions = $workspaceEntitlementSummary['decisions'] ?? [];
|
|
||||||
$managedTenantDecision = $entitlementDecisions['managed_tenant_activation_limit'] ?? null;
|
|
||||||
$reviewPackDecision = $entitlementDecisions['review_pack_generation_enabled'] ?? null;
|
|
||||||
@endphp
|
@endphp
|
||||||
|
|
||||||
<x-filament-panels::page>
|
<x-filament-panels::page>
|
||||||
@ -49,115 +35,6 @@
|
|||||||
@include('filament.system.pages.directory.partials.customer-health-decision-card', ['decision' => $customerHealthDecision])
|
@include('filament.system.pages.directory.partials.customer-health-decision-card', ['decision' => $customerHealthDecision])
|
||||||
@endif
|
@endif
|
||||||
|
|
||||||
<x-filament::section>
|
|
||||||
<x-slot name="heading">
|
|
||||||
Commercial lifecycle
|
|
||||||
</x-slot>
|
|
||||||
|
|
||||||
<div class="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
|
||||||
<div class="rounded-lg bg-gray-50 px-4 py-3 dark:bg-white/5">
|
|
||||||
<p class="text-xs font-semibold uppercase tracking-wider text-gray-500 dark:text-gray-400">Current state</p>
|
|
||||||
<div class="mt-2 flex items-center gap-2">
|
|
||||||
<x-filament::badge :color="$commercialBadge->color" :icon="$commercialBadge->icon">
|
|
||||||
{{ $commercialBadge->label }}
|
|
||||||
</x-filament::badge>
|
|
||||||
<span class="text-sm text-gray-500 dark:text-gray-400">{{ $commercialLifecycle['source_label'] ?? 'default active paid' }}</span>
|
|
||||||
</div>
|
|
||||||
<p class="mt-2 text-sm text-gray-500 dark:text-gray-400">{{ $commercialLifecycle['description'] ?? 'Commercial lifecycle state controls expansion and review-pack starts.' }}</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="rounded-lg bg-gray-50 px-4 py-3 dark:bg-white/5">
|
|
||||||
<p class="text-xs font-semibold uppercase tracking-wider text-gray-500 dark:text-gray-400">Lifecycle rationale</p>
|
|
||||||
<p class="mt-1 text-base font-semibold text-gray-950 dark:text-white">{{ $commercialLifecycle['rationale'] ?? 'No explicit rationale recorded.' }}</p>
|
|
||||||
<p class="mt-1 text-sm text-gray-500 dark:text-gray-400">
|
|
||||||
{{ $commercialLifecycle['last_changed_by'] ?? 'System default' }}
|
|
||||||
@if (($commercialLifecycle['last_changed_at'] ?? null) instanceof \Carbon\CarbonInterface)
|
|
||||||
· {{ $commercialLifecycle['last_changed_at']->diffForHumans() }}
|
|
||||||
@endif
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="mt-4 space-y-3">
|
|
||||||
@foreach ([
|
|
||||||
'Managed tenant activation' => $activationLifecycleDecision,
|
|
||||||
'Review-pack starts' => $reviewPackLifecycleDecision,
|
|
||||||
'Read-only history and downloads' => $readOnlyLifecycleDecision,
|
|
||||||
] as $label => $decision)
|
|
||||||
@if (is_array($decision))
|
|
||||||
<div class="rounded-lg border border-gray-200 px-4 py-3 dark:border-white/10">
|
|
||||||
<div class="flex items-start justify-between gap-4">
|
|
||||||
<div>
|
|
||||||
<p class="text-sm font-semibold text-gray-950 dark:text-white">{{ $label }}</p>
|
|
||||||
<p class="mt-1 text-sm text-gray-500 dark:text-gray-400">{{ $decision['message'] ?? 'No lifecycle decision message available.' }}</p>
|
|
||||||
</div>
|
|
||||||
<x-filament::badge :color="match ($decision['outcome'] ?? null) {
|
|
||||||
'block' => 'danger',
|
|
||||||
'warn' => 'warning',
|
|
||||||
'allow_read_only' => 'info',
|
|
||||||
default => 'success',
|
|
||||||
}">
|
|
||||||
{{ str_replace('_', ' ', (string) ($decision['outcome'] ?? 'allow')) }}
|
|
||||||
</x-filament::badge>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
@endif
|
|
||||||
@endforeach
|
|
||||||
</div>
|
|
||||||
</x-filament::section>
|
|
||||||
|
|
||||||
@if (is_array($planProfile) && is_array($managedTenantDecision) && is_array($reviewPackDecision))
|
|
||||||
<x-filament::section>
|
|
||||||
<x-slot name="heading">
|
|
||||||
Workspace entitlements
|
|
||||||
</x-slot>
|
|
||||||
|
|
||||||
<div class="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
|
||||||
<div class="rounded-lg bg-gray-50 px-4 py-3 dark:bg-white/5">
|
|
||||||
<p class="text-xs font-semibold uppercase tracking-wider text-gray-500 dark:text-gray-400">Plan profile</p>
|
|
||||||
<p class="mt-1 text-base font-semibold text-gray-950 dark:text-white">{{ $planProfile['label'] }}</p>
|
|
||||||
<p class="mt-1 text-sm text-gray-500 dark:text-gray-400">{{ $planProfile['description'] }}</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="rounded-lg bg-gray-50 px-4 py-3 dark:bg-white/5">
|
|
||||||
<p class="text-xs font-semibold uppercase tracking-wider text-gray-500 dark:text-gray-400">Last changed</p>
|
|
||||||
<p class="mt-1 text-base font-semibold text-gray-950 dark:text-white">{{ $managedTenantDecision['last_changed_by'] ?? 'Not set' }}</p>
|
|
||||||
<p class="mt-1 text-sm text-gray-500 dark:text-gray-400">{{ $managedTenantDecision['last_changed_at']?->diffForHumans() ?? 'No entitlement override recorded yet.' }}</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="mt-4 space-y-3">
|
|
||||||
<div class="rounded-lg border border-gray-200 px-4 py-3 dark:border-white/10">
|
|
||||||
<div class="flex items-center justify-between gap-4">
|
|
||||||
<div>
|
|
||||||
<p class="text-sm font-semibold text-gray-950 dark:text-white">Managed tenant activation limit</p>
|
|
||||||
<p class="text-sm text-gray-500 dark:text-gray-400">{{ $managedTenantDecision['current_usage'] }} active of {{ $managedTenantDecision['effective_value'] }} allowed</p>
|
|
||||||
</div>
|
|
||||||
<x-filament::badge :color="$managedTenantDecision['source'] === 'workspace_override' ? 'warning' : 'gray'">
|
|
||||||
{{ $managedTenantDecision['source'] === 'workspace_override' ? 'workspace override' : ($managedTenantDecision['plan_profile_label'].' plan profile') }}
|
|
||||||
</x-filament::badge>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<p class="mt-2 text-sm text-gray-500 dark:text-gray-400">{{ $managedTenantDecision['rationale'] }}</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="rounded-lg border border-gray-200 px-4 py-3 dark:border-white/10">
|
|
||||||
<div class="flex items-center justify-between gap-4">
|
|
||||||
<div>
|
|
||||||
<p class="text-sm font-semibold text-gray-950 dark:text-white">Review pack generation</p>
|
|
||||||
<p class="text-sm text-gray-500 dark:text-gray-400">{{ $reviewPackDecision['effective_value'] ? 'Enabled' : 'Disabled' }}</p>
|
|
||||||
</div>
|
|
||||||
<x-filament::badge :color="$reviewPackDecision['source'] === 'workspace_override' ? 'warning' : 'gray'">
|
|
||||||
{{ $reviewPackDecision['source'] === 'workspace_override' ? 'workspace override' : ($reviewPackDecision['plan_profile_label'].' plan profile') }}
|
|
||||||
</x-filament::badge>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<p class="mt-2 text-sm text-gray-500 dark:text-gray-400">{{ $reviewPackDecision['rationale'] }}</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</x-filament::section>
|
|
||||||
@endif
|
|
||||||
|
|
||||||
<x-filament::section>
|
<x-filament::section>
|
||||||
<x-slot name="heading">
|
<x-slot name="heading">
|
||||||
Tenants summary
|
Tenants summary
|
||||||
|
|||||||
@ -9,10 +9,6 @@
|
|||||||
/** @var ?string $pollingInterval */
|
/** @var ?string $pollingInterval */
|
||||||
/** @var bool $canView */
|
/** @var bool $canView */
|
||||||
/** @var bool $canManage */
|
/** @var bool $canManage */
|
||||||
/** @var bool $generationBlocked */
|
|
||||||
/** @var ?string $generationBlockReason */
|
|
||||||
/** @var ?string $generationWarningReason */
|
|
||||||
/** @var ?string $customerWorkspaceUrl */
|
|
||||||
/** @var ?string $downloadUrl */
|
/** @var ?string $downloadUrl */
|
||||||
/** @var ?string $failedReason */
|
/** @var ?string $failedReason */
|
||||||
/** @var ?string $failedReasonDetail */
|
/** @var ?string $failedReasonDetail */
|
||||||
@ -28,18 +24,6 @@
|
|||||||
@endif
|
@endif
|
||||||
>
|
>
|
||||||
<x-filament::section heading="Review Pack">
|
<x-filament::section heading="Review Pack">
|
||||||
@if ($canManage && $generationBlocked && $generationBlockReason)
|
|
||||||
<div class="mb-3 rounded-lg border border-warning-200 bg-warning-50 px-3 py-2 text-sm text-warning-800 dark:border-warning-500/30 dark:bg-warning-500/10 dark:text-warning-200">
|
|
||||||
{{ $generationBlockReason }}
|
|
||||||
</div>
|
|
||||||
@endif
|
|
||||||
|
|
||||||
@if ($canManage && ! $generationBlocked && $generationWarningReason)
|
|
||||||
<div class="mb-3 rounded-lg border border-warning-200 bg-warning-50 px-3 py-2 text-sm text-warning-800 dark:border-warning-500/30 dark:bg-warning-500/10 dark:text-warning-200">
|
|
||||||
{{ $generationWarningReason }}
|
|
||||||
</div>
|
|
||||||
@endif
|
|
||||||
|
|
||||||
@if (! $pack)
|
@if (! $pack)
|
||||||
{{-- State 1: No pack --}}
|
{{-- State 1: No pack --}}
|
||||||
<div class="flex flex-col items-center gap-3 py-4 text-center">
|
<div class="flex flex-col items-center gap-3 py-4 text-center">
|
||||||
@ -53,15 +37,12 @@
|
|||||||
size="sm"
|
size="sm"
|
||||||
wire:click="generatePack"
|
wire:click="generatePack"
|
||||||
wire:loading.attr="disabled"
|
wire:loading.attr="disabled"
|
||||||
:disabled="$generationBlocked"
|
|
||||||
>
|
>
|
||||||
Generate pack
|
Generate pack
|
||||||
</x-filament::button>
|
</x-filament::button>
|
||||||
@endif
|
@endif
|
||||||
</div>
|
</div>
|
||||||
@endif
|
@elseif ($statusEnum === ReviewPackStatus::Queued || $statusEnum === ReviewPackStatus::Generating)
|
||||||
|
|
||||||
@if ($pack && ($statusEnum === ReviewPackStatus::Queued || $statusEnum === ReviewPackStatus::Generating))
|
|
||||||
{{-- State 2: Queued / Generating --}}
|
{{-- State 2: Queued / Generating --}}
|
||||||
<div class="flex flex-col gap-3">
|
<div class="flex flex-col gap-3">
|
||||||
<div class="flex items-center gap-2">
|
<div class="flex items-center gap-2">
|
||||||
@ -82,9 +63,7 @@
|
|||||||
Started {{ $pack->created_at?->diffForHumans() ?? '—' }}
|
Started {{ $pack->created_at?->diffForHumans() ?? '—' }}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@endif
|
@elseif ($statusEnum === ReviewPackStatus::Ready)
|
||||||
|
|
||||||
@if ($pack && $statusEnum === ReviewPackStatus::Ready)
|
|
||||||
{{-- State 3: Ready --}}
|
{{-- State 3: Ready --}}
|
||||||
<div class="flex flex-col gap-3">
|
<div class="flex flex-col gap-3">
|
||||||
<div class="flex items-center gap-2">
|
<div class="flex items-center gap-2">
|
||||||
@ -137,16 +116,13 @@
|
|||||||
color="gray"
|
color="gray"
|
||||||
wire:click="generatePack"
|
wire:click="generatePack"
|
||||||
wire:loading.attr="disabled"
|
wire:loading.attr="disabled"
|
||||||
:disabled="$generationBlocked"
|
|
||||||
>
|
>
|
||||||
Generate new
|
Generate new
|
||||||
</x-filament::button>
|
</x-filament::button>
|
||||||
@endif
|
@endif
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@endif
|
@elseif ($statusEnum === ReviewPackStatus::Failed)
|
||||||
|
|
||||||
@if ($pack && $statusEnum === ReviewPackStatus::Failed)
|
|
||||||
{{-- State 4: Failed --}}
|
{{-- State 4: Failed --}}
|
||||||
<div class="flex flex-col gap-3">
|
<div class="flex flex-col gap-3">
|
||||||
<div class="flex items-center gap-2">
|
<div class="flex items-center gap-2">
|
||||||
@ -187,15 +163,12 @@
|
|||||||
size="sm"
|
size="sm"
|
||||||
wire:click="generatePack"
|
wire:click="generatePack"
|
||||||
wire:loading.attr="disabled"
|
wire:loading.attr="disabled"
|
||||||
:disabled="$generationBlocked"
|
|
||||||
>
|
>
|
||||||
Retry
|
Retry
|
||||||
</x-filament::button>
|
</x-filament::button>
|
||||||
@endif
|
@endif
|
||||||
</div>
|
</div>
|
||||||
@endif
|
@elseif ($statusEnum === ReviewPackStatus::Expired)
|
||||||
|
|
||||||
@if ($pack && $statusEnum === ReviewPackStatus::Expired)
|
|
||||||
{{-- State 5: Expired --}}
|
{{-- State 5: Expired --}}
|
||||||
<div class="flex flex-col gap-3">
|
<div class="flex flex-col gap-3">
|
||||||
<div class="flex items-center gap-2">
|
<div class="flex items-center gap-2">
|
||||||
@ -216,25 +189,11 @@
|
|||||||
size="sm"
|
size="sm"
|
||||||
wire:click="generatePack"
|
wire:click="generatePack"
|
||||||
wire:loading.attr="disabled"
|
wire:loading.attr="disabled"
|
||||||
:disabled="$generationBlocked"
|
|
||||||
>
|
>
|
||||||
Generate new
|
Generate new
|
||||||
</x-filament::button>
|
</x-filament::button>
|
||||||
@endif
|
@endif
|
||||||
</div>
|
</div>
|
||||||
@endif
|
@endif
|
||||||
|
|
||||||
@if ($canView && $customerWorkspaceUrl)
|
|
||||||
<div class="mt-3 flex items-center gap-2">
|
|
||||||
<x-filament::button
|
|
||||||
size="sm"
|
|
||||||
color="gray"
|
|
||||||
tag="a"
|
|
||||||
:href="$customerWorkspaceUrl"
|
|
||||||
>
|
|
||||||
Customer workspace
|
|
||||||
</x-filament::button>
|
|
||||||
</div>
|
|
||||||
@endif
|
|
||||||
</x-filament::section>
|
</x-filament::section>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -1,100 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
use App\Filament\Resources\TenantReviewResource;
|
|
||||||
use App\Models\ReviewPack;
|
|
||||||
use App\Models\Tenant;
|
|
||||||
use App\Support\TenantReviewStatus;
|
|
||||||
use App\Support\Workspaces\WorkspaceContext;
|
|
||||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
|
||||||
use Illuminate\Support\Facades\Storage;
|
|
||||||
|
|
||||||
uses(RefreshDatabase::class);
|
|
||||||
|
|
||||||
pest()->browser()->timeout(20_000);
|
|
||||||
|
|
||||||
beforeEach(function (): void {
|
|
||||||
Storage::fake('exports');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('smokes the customer review workspace handoff from tenant review detail', function (): void {
|
|
||||||
$tenantPublished = Tenant::factory()->create(['name' => 'Published Tenant']);
|
|
||||||
[$user, $tenantPublished] = createUserWithTenant(
|
|
||||||
tenant: $tenantPublished,
|
|
||||||
role: 'owner',
|
|
||||||
workspaceRole: 'manager',
|
|
||||||
);
|
|
||||||
|
|
||||||
$tenantWithoutPublished = Tenant::factory()->create([
|
|
||||||
'workspace_id' => (int) $tenantPublished->workspace_id,
|
|
||||||
'name' => 'No Published Tenant',
|
|
||||||
]);
|
|
||||||
|
|
||||||
createUserWithTenant(
|
|
||||||
tenant: $tenantWithoutPublished,
|
|
||||||
user: $user,
|
|
||||||
role: 'owner',
|
|
||||||
workspaceRole: 'manager',
|
|
||||||
);
|
|
||||||
|
|
||||||
$publishedSnapshot = seedTenantReviewEvidence($tenantPublished);
|
|
||||||
$noPublishedSnapshot = seedTenantReviewEvidence($tenantWithoutPublished);
|
|
||||||
|
|
||||||
$publishedReview = composeTenantReviewForTest($tenantPublished, $user, $publishedSnapshot);
|
|
||||||
$publishedReview->forceFill([
|
|
||||||
'status' => TenantReviewStatus::Published->value,
|
|
||||||
'published_at' => now(),
|
|
||||||
'published_by_user_id' => (int) $user->getKey(),
|
|
||||||
])->save();
|
|
||||||
|
|
||||||
$internalOnlyReview = composeTenantReviewForTest($tenantWithoutPublished, $user, $noPublishedSnapshot);
|
|
||||||
$internalOnlyReview->forceFill([
|
|
||||||
'status' => TenantReviewStatus::Ready->value,
|
|
||||||
'published_at' => null,
|
|
||||||
'published_by_user_id' => null,
|
|
||||||
])->save();
|
|
||||||
|
|
||||||
Storage::disk('exports')->put('review-packs/customer-review-workspace-smoke.zip', 'PK-test');
|
|
||||||
|
|
||||||
ReviewPack::factory()->ready()->create([
|
|
||||||
'tenant_id' => (int) $tenantPublished->getKey(),
|
|
||||||
'workspace_id' => (int) $tenantPublished->workspace_id,
|
|
||||||
'tenant_review_id' => (int) $publishedReview->getKey(),
|
|
||||||
'evidence_snapshot_id' => (int) $publishedSnapshot->getKey(),
|
|
||||||
'initiated_by_user_id' => (int) $user->getKey(),
|
|
||||||
'file_path' => 'review-packs/customer-review-workspace-smoke.zip',
|
|
||||||
'file_disk' => 'exports',
|
|
||||||
]);
|
|
||||||
|
|
||||||
$this->actingAs($user)->withSession([
|
|
||||||
WorkspaceContext::SESSION_KEY => (int) $tenantPublished->workspace_id,
|
|
||||||
WorkspaceContext::LAST_TENANT_IDS_SESSION_KEY => [
|
|
||||||
(string) $tenantPublished->workspace_id => (int) $tenantPublished->getKey(),
|
|
||||||
],
|
|
||||||
]);
|
|
||||||
|
|
||||||
visit(TenantReviewResource::tenantScopedUrl('view', ['record' => $publishedReview], $tenantPublished))
|
|
||||||
->waitForText('Related context')
|
|
||||||
->assertSee('Open customer workspace')
|
|
||||||
->assertNoJavaScriptErrors()
|
|
||||||
->assertNoConsoleLogs()
|
|
||||||
->click('Open customer workspace')
|
|
||||||
->waitForText('Customer-safe review workspace')
|
|
||||||
->assertSee('Clear filters')
|
|
||||||
->assertSee('Open latest review')
|
|
||||||
->assertDontSee('Publish review')
|
|
||||||
->assertDontSee('Refresh review')
|
|
||||||
->click('Clear filters')
|
|
||||||
->waitForText('No published review available yet')
|
|
||||||
->assertSee('No published review available yet')
|
|
||||||
->click('Open latest review')
|
|
||||||
->waitForText('Outcome summary')
|
|
||||||
->assertDontSee('Publish review')
|
|
||||||
->assertDontSee('Refresh review')
|
|
||||||
->assertDontSee('Create next review')
|
|
||||||
->assertDontSee('Export executive pack')
|
|
||||||
->assertDontSee('Archive review')
|
|
||||||
->assertNoJavaScriptErrors()
|
|
||||||
->assertNoConsoleLogs();
|
|
||||||
});
|
|
||||||
@ -10,14 +10,10 @@
|
|||||||
use App\Models\EvidenceSnapshotItem;
|
use App\Models\EvidenceSnapshotItem;
|
||||||
use App\Models\Finding;
|
use App\Models\Finding;
|
||||||
use App\Models\OperationRun;
|
use App\Models\OperationRun;
|
||||||
use App\Models\PlatformUser;
|
|
||||||
use App\Models\ReviewPack;
|
use App\Models\ReviewPack;
|
||||||
use App\Models\StoredReport;
|
use App\Models\StoredReport;
|
||||||
use App\Models\Tenant;
|
use App\Models\Tenant;
|
||||||
use App\Services\Entitlements\WorkspaceCommercialLifecycleResolver;
|
|
||||||
use App\Services\Settings\SettingsWriter;
|
|
||||||
use App\Support\Auth\Capabilities;
|
use App\Support\Auth\Capabilities;
|
||||||
use App\Support\Auth\PlatformCapabilities;
|
|
||||||
use App\Support\Evidence\EvidenceCompletenessState;
|
use App\Support\Evidence\EvidenceCompletenessState;
|
||||||
use App\Support\Evidence\EvidenceSnapshotStatus;
|
use App\Support\Evidence\EvidenceSnapshotStatus;
|
||||||
use App\Support\Ui\GovernanceActions\GovernanceActionCatalog;
|
use App\Support\Ui\GovernanceActions\GovernanceActionCatalog;
|
||||||
@ -72,23 +68,6 @@ function evidenceSnapshotHeaderActions(Testable $component): array
|
|||||||
return $instance->getCachedHeaderActions();
|
return $instance->getCachedHeaderActions();
|
||||||
}
|
}
|
||||||
|
|
||||||
function suspendEvidenceSnapshotWorkspace(Tenant $tenant): void
|
|
||||||
{
|
|
||||||
app(SettingsWriter::class)->updateWorkspaceCommercialLifecycle(
|
|
||||||
actor: PlatformUser::factory()->create([
|
|
||||||
'capabilities' => [
|
|
||||||
PlatformCapabilities::ACCESS_SYSTEM_PANEL,
|
|
||||||
PlatformCapabilities::DIRECTORY_VIEW,
|
|
||||||
PlatformCapabilities::COMMERCIAL_LIFECYCLE_MANAGE,
|
|
||||||
],
|
|
||||||
'is_active' => true,
|
|
||||||
]),
|
|
||||||
workspace: $tenant->workspace,
|
|
||||||
state: WorkspaceCommercialLifecycleResolver::STATE_SUSPENDED_READ_ONLY,
|
|
||||||
reason: 'Evidence read-only preservation test',
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
it('renders the evidence list page for an authorized user', function (): void {
|
it('renders the evidence list page for an authorized user', function (): void {
|
||||||
$tenant = Tenant::factory()->create();
|
$tenant = Tenant::factory()->create();
|
||||||
[$user, $tenant] = createUserWithTenant(tenant: $tenant, role: 'owner');
|
[$user, $tenant] = createUserWithTenant(tenant: $tenant, role: 'owner');
|
||||||
@ -228,36 +207,6 @@ function suspendEvidenceSnapshotWorkspace(Tenant $tenant): void
|
|||||||
->toContain('operation_run', 'review_pack');
|
->toContain('operation_run', 'review_pack');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('keeps evidence snapshot detail accessible for readonly members while suspended read-only', function (): void {
|
|
||||||
$tenant = Tenant::factory()->create();
|
|
||||||
[$user, $tenant] = createUserWithTenant(tenant: $tenant, role: 'readonly');
|
|
||||||
|
|
||||||
$snapshot = EvidenceSnapshot::query()->create([
|
|
||||||
'tenant_id' => (int) $tenant->getKey(),
|
|
||||||
'workspace_id' => (int) $tenant->workspace_id,
|
|
||||||
'status' => EvidenceSnapshotStatus::Active->value,
|
|
||||||
'completeness_state' => EvidenceCompletenessState::Complete->value,
|
|
||||||
'summary' => ['finding_count' => 2],
|
|
||||||
'generated_at' => now(),
|
|
||||||
]);
|
|
||||||
|
|
||||||
suspendEvidenceSnapshotWorkspace($tenant);
|
|
||||||
|
|
||||||
$this->actingAs($user)
|
|
||||||
->get(EvidenceSnapshotResource::getUrl('view', ['record' => $snapshot], tenant: $tenant, panel: 'tenant'))
|
|
||||||
->assertOk();
|
|
||||||
|
|
||||||
$tenant->makeCurrent();
|
|
||||||
Filament::setTenant($tenant, true);
|
|
||||||
|
|
||||||
Livewire::actingAs($user)
|
|
||||||
->test(ViewEvidenceSnapshot::class, ['record' => $snapshot->getKey()])
|
|
||||||
->assertActionVisible('refresh_evidence')
|
|
||||||
->assertActionDisabled('refresh_evidence')
|
|
||||||
->assertActionVisible('expire_snapshot')
|
|
||||||
->assertActionDisabled('expire_snapshot');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('shows artifact truth and next-step guidance for degraded evidence snapshots', function (): void {
|
it('shows artifact truth and next-step guidance for degraded evidence snapshots', function (): void {
|
||||||
$tenant = Tenant::factory()->create();
|
$tenant = Tenant::factory()->create();
|
||||||
[$user, $tenant] = createUserWithTenant(tenant: $tenant, role: 'owner');
|
[$user, $tenant] = createUserWithTenant(tenant: $tenant, role: 'owner');
|
||||||
|
|||||||
@ -5,14 +5,12 @@
|
|||||||
use App\Filament\Resources\BackupScheduleResource\Pages\ListBackupSchedules;
|
use App\Filament\Resources\BackupScheduleResource\Pages\ListBackupSchedules;
|
||||||
use App\Filament\Resources\BackupSetResource\Pages\ListBackupSets;
|
use App\Filament\Resources\BackupSetResource\Pages\ListBackupSets;
|
||||||
use App\Filament\Resources\ProviderConnectionResource\Pages\ListProviderConnections;
|
use App\Filament\Resources\ProviderConnectionResource\Pages\ListProviderConnections;
|
||||||
use App\Filament\Resources\ReviewPackResource\Pages\ListReviewPacks;
|
|
||||||
use App\Filament\Resources\RestoreRunResource\Pages\ListRestoreRuns;
|
use App\Filament\Resources\RestoreRunResource\Pages\ListRestoreRuns;
|
||||||
use App\Filament\Resources\TenantResource\Pages\ListTenants;
|
use App\Filament\Resources\TenantResource\Pages\ListTenants;
|
||||||
use App\Filament\Resources\Workspaces\Pages\ListWorkspaces;
|
use App\Filament\Resources\Workspaces\Pages\ListWorkspaces;
|
||||||
use App\Models\BackupSchedule;
|
use App\Models\BackupSchedule;
|
||||||
use App\Models\BackupSet;
|
use App\Models\BackupSet;
|
||||||
use App\Models\ProviderConnection;
|
use App\Models\ProviderConnection;
|
||||||
use App\Models\ReviewPack;
|
|
||||||
use App\Models\RestoreRun;
|
use App\Models\RestoreRun;
|
||||||
use App\Models\User;
|
use App\Models\User;
|
||||||
use App\Models\Workspace;
|
use App\Models\Workspace;
|
||||||
@ -242,47 +240,6 @@ function getPlacementEmptyStateAction(Testable $component, string $name): ?Actio
|
|||||||
expect($headerCreate?->isVisible())->toBeTrue();
|
expect($headerCreate?->isVisible())->toBeTrue();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('shows generate only in empty state when review packs table is empty', function (): void {
|
|
||||||
[$user, $tenant] = createUserWithTenant(role: 'owner');
|
|
||||||
|
|
||||||
$this->actingAs($user);
|
|
||||||
$tenant->makeCurrent();
|
|
||||||
Filament::setTenant($tenant, true);
|
|
||||||
|
|
||||||
$component = Livewire::test(ListReviewPacks::class)
|
|
||||||
->assertTableEmptyStateActionsExistInOrder(['generate_first']);
|
|
||||||
|
|
||||||
$emptyStateGenerate = getPlacementEmptyStateAction($component, 'generate_first');
|
|
||||||
expect($emptyStateGenerate)->not->toBeNull();
|
|
||||||
expect($emptyStateGenerate?->getLabel())->toBe('Generate first pack');
|
|
||||||
|
|
||||||
$headerGenerate = getHeaderAction($component, 'generate_pack');
|
|
||||||
expect($headerGenerate)->not->toBeNull();
|
|
||||||
expect($headerGenerate?->isVisible())->toBeFalse();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('shows generate only in header when review packs table is not empty', function (): void {
|
|
||||||
[$user, $tenant] = createUserWithTenant(role: 'owner');
|
|
||||||
|
|
||||||
$this->actingAs($user);
|
|
||||||
$tenant->makeCurrent();
|
|
||||||
Filament::setTenant($tenant, true);
|
|
||||||
|
|
||||||
ReviewPack::factory()->ready()->create([
|
|
||||||
'tenant_id' => (int) $tenant->getKey(),
|
|
||||||
'workspace_id' => (int) $tenant->workspace_id,
|
|
||||||
'initiated_by_user_id' => (int) $user->getKey(),
|
|
||||||
]);
|
|
||||||
|
|
||||||
$component = Livewire::test(ListReviewPacks::class)
|
|
||||||
->assertCountTableRecords(1);
|
|
||||||
|
|
||||||
$headerGenerate = getHeaderAction($component, 'generate_pack');
|
|
||||||
expect($headerGenerate)->not->toBeNull();
|
|
||||||
expect($headerGenerate?->isVisible())->toBeTrue();
|
|
||||||
expect($headerGenerate?->getLabel())->toBe('Generate Pack');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('shows create only in empty state when tenants table is empty', function (): void {
|
it('shows create only in empty state when tenants table is empty', function (): void {
|
||||||
$workspace = Workspace::factory()->create([
|
$workspace = Workspace::factory()->create([
|
||||||
'archived_at' => now(),
|
'archived_at' => now(),
|
||||||
|
|||||||
@ -1,111 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
use App\Filament\Pages\Settings\WorkspaceSettings;
|
|
||||||
use App\Models\User;
|
|
||||||
use App\Models\Workspace;
|
|
||||||
use App\Models\WorkspaceMembership;
|
|
||||||
use App\Services\Entitlements\WorkspaceEntitlementResolver;
|
|
||||||
use App\Support\Workspaces\WorkspaceContext;
|
|
||||||
use Livewire\Livewire;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @return array{0: Workspace, 1: User}
|
|
||||||
*/
|
|
||||||
function entitlementSettingsManager(): array
|
|
||||||
{
|
|
||||||
$workspace = Workspace::factory()->create();
|
|
||||||
$user = User::factory()->create();
|
|
||||||
|
|
||||||
WorkspaceMembership::factory()->create([
|
|
||||||
'workspace_id' => (int) $workspace->getKey(),
|
|
||||||
'user_id' => (int) $user->getKey(),
|
|
||||||
'role' => 'manager',
|
|
||||||
]);
|
|
||||||
|
|
||||||
session()->put(WorkspaceContext::SESSION_KEY, (int) $workspace->getKey());
|
|
||||||
|
|
||||||
return [$workspace, $user];
|
|
||||||
}
|
|
||||||
|
|
||||||
it('saves entitlement plan profile and override pairs through the workspace settings page', function (): void {
|
|
||||||
[$workspace, $user] = entitlementSettingsManager();
|
|
||||||
|
|
||||||
$this->actingAs($user)
|
|
||||||
->get(WorkspaceSettings::getUrl(panel: 'admin'))
|
|
||||||
->assertSuccessful()
|
|
||||||
->assertSee('Workspace entitlements');
|
|
||||||
|
|
||||||
$component = Livewire::actingAs($user)
|
|
||||||
->test(WorkspaceSettings::class)
|
|
||||||
->assertSet('data.entitlements_plan_profile', null)
|
|
||||||
->assertSet('data.entitlements_managed_tenant_limit_override_value', null)
|
|
||||||
->assertSet('data.entitlements_managed_tenant_limit_override_reason', null)
|
|
||||||
->assertSet('data.entitlements_review_pack_generation_override_value', null)
|
|
||||||
->assertSet('data.entitlements_review_pack_generation_override_reason', null)
|
|
||||||
->set('data.entitlements_plan_profile', 'starter')
|
|
||||||
->set('data.entitlements_managed_tenant_limit_override_value', 2)
|
|
||||||
->set('data.entitlements_managed_tenant_limit_override_reason', 'Temporary support-approved exception')
|
|
||||||
->set('data.entitlements_review_pack_generation_override_value', '0')
|
|
||||||
->set('data.entitlements_review_pack_generation_override_reason', 'Workspace is temporarily limited to manual reporting only')
|
|
||||||
->callAction('save')
|
|
||||||
->assertHasNoErrors()
|
|
||||||
->assertSet('data.entitlements_plan_profile', 'starter')
|
|
||||||
->assertSet('data.entitlements_managed_tenant_limit_override_value', 2)
|
|
||||||
->assertSet('data.entitlements_managed_tenant_limit_override_reason', 'Temporary support-approved exception')
|
|
||||||
->assertSet('data.entitlements_review_pack_generation_override_value', '0')
|
|
||||||
->assertSet('data.entitlements_review_pack_generation_override_reason', 'Workspace is temporarily limited to manual reporting only');
|
|
||||||
|
|
||||||
$summary = app(WorkspaceEntitlementResolver::class)->summary($workspace);
|
|
||||||
|
|
||||||
expect($summary['plan_profile']['id'])->toBe('starter')
|
|
||||||
->and($summary['decisions'][WorkspaceEntitlementResolver::KEY_MANAGED_TENANT_ACTIVATION_LIMIT])
|
|
||||||
->toMatchArray([
|
|
||||||
'effective_value' => 2,
|
|
||||||
'source' => 'workspace_override',
|
|
||||||
'rationale' => 'Temporary support-approved exception',
|
|
||||||
'last_changed_by' => $user->name,
|
|
||||||
])
|
|
||||||
->and($summary['decisions'][WorkspaceEntitlementResolver::KEY_REVIEW_PACK_GENERATION_ENABLED])
|
|
||||||
->toMatchArray([
|
|
||||||
'effective_value' => false,
|
|
||||||
'source' => 'workspace_override',
|
|
||||||
'rationale' => 'Workspace is temporarily limited to manual reporting only',
|
|
||||||
'last_changed_by' => $user->name,
|
|
||||||
]);
|
|
||||||
|
|
||||||
$component
|
|
||||||
->mountFormComponentAction('entitlements_managed_tenant_limit_override_value', 'reset_entitlements_managed_tenant_limit_override_value', [], 'content')
|
|
||||||
->callMountedFormComponentAction()
|
|
||||||
->assertHasNoErrors()
|
|
||||||
->assertSet('data.entitlements_managed_tenant_limit_override_value', null)
|
|
||||||
->assertSet('data.entitlements_managed_tenant_limit_override_reason', null);
|
|
||||||
|
|
||||||
$summary = app(WorkspaceEntitlementResolver::class)->summary($workspace);
|
|
||||||
|
|
||||||
expect($summary['decisions'][WorkspaceEntitlementResolver::KEY_MANAGED_TENANT_ACTIVATION_LIMIT])
|
|
||||||
->toMatchArray([
|
|
||||||
'effective_value' => 1,
|
|
||||||
'source' => 'plan_profile_default',
|
|
||||||
'rationale' => 'Minimal allowance for early workspace access and low-volume operations.',
|
|
||||||
]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('requires an override reason when a workspace entitlement override value is set', function (): void {
|
|
||||||
[, $user] = entitlementSettingsManager();
|
|
||||||
|
|
||||||
Livewire::actingAs($user)
|
|
||||||
->test(WorkspaceSettings::class)
|
|
||||||
->set('data.entitlements_managed_tenant_limit_override_value', 3)
|
|
||||||
->set('data.entitlements_managed_tenant_limit_override_reason', '')
|
|
||||||
->callAction('save')
|
|
||||||
->assertHasErrors(['data.entitlements_managed_tenant_limit_override_reason']);
|
|
||||||
|
|
||||||
Livewire::actingAs($user)
|
|
||||||
->test(WorkspaceSettings::class)
|
|
||||||
->set('data.entitlements_review_pack_generation_override_value', '0')
|
|
||||||
->set('data.entitlements_review_pack_generation_override_reason', '')
|
|
||||||
->callAction('save')
|
|
||||||
->assertHasErrors(['data.entitlements_review_pack_generation_override_reason']);
|
|
||||||
});
|
|
||||||
@ -1,99 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
use App\Filament\Pages\Governance\GovernanceInbox;
|
|
||||||
use App\Models\Tenant;
|
|
||||||
use App\Models\User;
|
|
||||||
use App\Models\Workspace;
|
|
||||||
use App\Models\WorkspaceMembership;
|
|
||||||
use App\Services\Auth\WorkspaceCapabilityResolver;
|
|
||||||
use App\Support\Workspaces\WorkspaceContext;
|
|
||||||
|
|
||||||
use function Pest\Laravel\mock;
|
|
||||||
|
|
||||||
it('redirects governance inbox visits without workspace context into the existing workspace chooser flow', function (): void {
|
|
||||||
$user = User::factory()->create();
|
|
||||||
|
|
||||||
$workspaceA = Workspace::factory()->create();
|
|
||||||
$workspaceB = Workspace::factory()->create();
|
|
||||||
|
|
||||||
WorkspaceMembership::factory()->create([
|
|
||||||
'workspace_id' => (int) $workspaceA->getKey(),
|
|
||||||
'user_id' => (int) $user->getKey(),
|
|
||||||
'role' => 'owner',
|
|
||||||
]);
|
|
||||||
|
|
||||||
WorkspaceMembership::factory()->create([
|
|
||||||
'workspace_id' => (int) $workspaceB->getKey(),
|
|
||||||
'user_id' => (int) $user->getKey(),
|
|
||||||
'role' => 'owner',
|
|
||||||
]);
|
|
||||||
|
|
||||||
$this->actingAs($user)
|
|
||||||
->get(GovernanceInbox::getUrl(panel: 'admin'))
|
|
||||||
->assertRedirect('/admin/choose-workspace');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('returns 404 for users outside the active workspace on the governance inbox route', function (): void {
|
|
||||||
$user = User::factory()->create();
|
|
||||||
$workspace = Workspace::factory()->create();
|
|
||||||
|
|
||||||
WorkspaceMembership::factory()->create([
|
|
||||||
'workspace_id' => (int) Workspace::factory()->create()->getKey(),
|
|
||||||
'user_id' => (int) $user->getKey(),
|
|
||||||
'role' => 'owner',
|
|
||||||
]);
|
|
||||||
|
|
||||||
$this->actingAs($user)
|
|
||||||
->withSession([WorkspaceContext::SESSION_KEY => (int) $workspace->getKey()])
|
|
||||||
->get(GovernanceInbox::getUrl(panel: 'admin'))
|
|
||||||
->assertNotFound();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('returns 403 for workspace members with no qualifying family visibility anywhere', function (): void {
|
|
||||||
$user = User::factory()->create();
|
|
||||||
$workspace = Workspace::factory()->create();
|
|
||||||
|
|
||||||
WorkspaceMembership::factory()->create([
|
|
||||||
'workspace_id' => (int) $workspace->getKey(),
|
|
||||||
'user_id' => (int) $user->getKey(),
|
|
||||||
'role' => 'owner',
|
|
||||||
]);
|
|
||||||
|
|
||||||
mock(WorkspaceCapabilityResolver::class, function ($mock): void {
|
|
||||||
$mock->shouldReceive('isMember')->andReturnTrue();
|
|
||||||
$mock->shouldReceive('can')->andReturnFalse();
|
|
||||||
});
|
|
||||||
|
|
||||||
$this->actingAs($user)
|
|
||||||
->withSession([WorkspaceContext::SESSION_KEY => (int) $workspace->getKey()])
|
|
||||||
->get(GovernanceInbox::getUrl(panel: 'admin'))
|
|
||||||
->assertForbidden();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('allows readonly tenant members to open the governance inbox through operations-family visibility', function (): void {
|
|
||||||
$tenant = Tenant::factory()->create(['status' => 'active']);
|
|
||||||
[$user, $tenant] = createUserWithTenant($tenant, role: 'readonly', workspaceRole: 'readonly');
|
|
||||||
|
|
||||||
$this->actingAs($user)
|
|
||||||
->withSession([WorkspaceContext::SESSION_KEY => (int) $tenant->workspace_id])
|
|
||||||
->get(GovernanceInbox::getUrl(panel: 'admin'))
|
|
||||||
->assertOk()
|
|
||||||
->assertSee('Governance inbox');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('returns 404 for explicit tenant filters outside the actor scope', function (): void {
|
|
||||||
$visibleTenant = Tenant::factory()->create(['status' => 'active']);
|
|
||||||
[$user, $visibleTenant] = createUserWithTenant($visibleTenant, role: 'readonly', workspaceRole: 'readonly');
|
|
||||||
|
|
||||||
$hiddenTenant = Tenant::factory()->create([
|
|
||||||
'status' => 'active',
|
|
||||||
'workspace_id' => (int) $visibleTenant->workspace_id,
|
|
||||||
]);
|
|
||||||
|
|
||||||
$this->actingAs($user)
|
|
||||||
->withSession([WorkspaceContext::SESSION_KEY => (int) $visibleTenant->workspace_id])
|
|
||||||
->get(GovernanceInbox::getUrl(panel: 'admin').'?tenant_id='.(string) $hiddenTenant->getKey())
|
|
||||||
->assertNotFound();
|
|
||||||
});
|
|
||||||
@ -1,64 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
use App\Filament\Pages\Findings\MyFindingsInbox;
|
|
||||||
use App\Filament\Pages\Governance\GovernanceInbox;
|
|
||||||
use App\Models\Finding;
|
|
||||||
use App\Models\OperationRun;
|
|
||||||
use App\Models\Tenant;
|
|
||||||
use App\Support\Navigation\CanonicalNavigationContext;
|
|
||||||
use App\Support\OperationRunLinks;
|
|
||||||
use App\Support\OperationRunOutcome;
|
|
||||||
use App\Support\OperationRunStatus;
|
|
||||||
use App\Support\Workspaces\WorkspaceContext;
|
|
||||||
use Filament\Facades\Filament;
|
|
||||||
|
|
||||||
it('embeds canonical governance inbox navigation context into source links', function (): void {
|
|
||||||
$tenant = Tenant::factory()->create([
|
|
||||||
'status' => 'active',
|
|
||||||
'name' => 'Alpha Tenant',
|
|
||||||
'external_id' => 'alpha-tenant',
|
|
||||||
]);
|
|
||||||
[$user, $tenant] = createUserWithTenant($tenant, role: 'owner', workspaceRole: 'owner');
|
|
||||||
|
|
||||||
$finding = Finding::factory()
|
|
||||||
->for($tenant)
|
|
||||||
->assignedTo((int) $user->getKey())
|
|
||||||
->create();
|
|
||||||
|
|
||||||
$run = OperationRun::factory()
|
|
||||||
->forTenant($tenant)
|
|
||||||
->create([
|
|
||||||
'status' => OperationRunStatus::Completed->value,
|
|
||||||
'outcome' => OperationRunOutcome::Failed->value,
|
|
||||||
'completed_at' => now()->subMinute(),
|
|
||||||
]);
|
|
||||||
|
|
||||||
$context = new CanonicalNavigationContext(
|
|
||||||
sourceSurface: 'governance.inbox',
|
|
||||||
canonicalRouteName: GovernanceInbox::getRouteName(Filament::getPanel('admin')),
|
|
||||||
backLinkLabel: 'Back to governance inbox',
|
|
||||||
backLinkUrl: GovernanceInbox::getUrl(panel: 'admin'),
|
|
||||||
);
|
|
||||||
|
|
||||||
$response = $this->actingAs($user)
|
|
||||||
->withSession([WorkspaceContext::SESSION_KEY => (int) $tenant->workspace_id])
|
|
||||||
->get(GovernanceInbox::getUrl(panel: 'admin'));
|
|
||||||
|
|
||||||
$response->assertOk();
|
|
||||||
|
|
||||||
$expectedMyFindingsUrl = htmlspecialchars(
|
|
||||||
MyFindingsInbox::getUrl(panel: 'admin').'?'.http_build_query($context->toQuery()),
|
|
||||||
ENT_QUOTES,
|
|
||||||
);
|
|
||||||
$expectedOperationUrl = htmlspecialchars(
|
|
||||||
OperationRunLinks::tenantlessView($run, $context),
|
|
||||||
ENT_QUOTES,
|
|
||||||
);
|
|
||||||
|
|
||||||
$response->assertSee($expectedMyFindingsUrl, false)
|
|
||||||
->assertSee($expectedOperationUrl, false)
|
|
||||||
->assertSee((string) $finding->getKey())
|
|
||||||
->assertSee('nav%5Bback_label%5D=Back+to+governance+inbox', false);
|
|
||||||
});
|
|
||||||
@ -1,143 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
use App\Filament\Pages\Governance\GovernanceInbox;
|
|
||||||
use App\Models\AlertDelivery;
|
|
||||||
use App\Models\Finding;
|
|
||||||
use App\Models\OperationRun;
|
|
||||||
use App\Models\Tenant;
|
|
||||||
use App\Models\TenantTriageReview;
|
|
||||||
use App\Support\BackupHealth\TenantBackupHealthResolver;
|
|
||||||
use App\Support\OperationRunOutcome;
|
|
||||||
use App\Support\OperationRunStatus;
|
|
||||||
use App\Support\PortfolioTriage\PortfolioArrivalContextToken;
|
|
||||||
use App\Support\PortfolioTriage\TenantTriageReviewFingerprint;
|
|
||||||
use App\Support\Workspaces\WorkspaceContext;
|
|
||||||
|
|
||||||
it('renders visible governance attention sections on the governance inbox page', function (): void {
|
|
||||||
$alphaTenant = Tenant::factory()->create([
|
|
||||||
'status' => 'active',
|
|
||||||
'name' => 'Alpha Tenant',
|
|
||||||
'external_id' => 'alpha-tenant',
|
|
||||||
]);
|
|
||||||
[$user, $alphaTenant] = createUserWithTenant($alphaTenant, role: 'owner', workspaceRole: 'owner');
|
|
||||||
|
|
||||||
$bravoTenant = Tenant::factory()->create([
|
|
||||||
'status' => 'active',
|
|
||||||
'workspace_id' => (int) $alphaTenant->workspace_id,
|
|
||||||
'name' => 'Bravo Tenant',
|
|
||||||
'external_id' => 'bravo-tenant',
|
|
||||||
]);
|
|
||||||
|
|
||||||
$user->tenants()->syncWithoutDetaching([
|
|
||||||
(int) $bravoTenant->getKey() => ['role' => 'owner'],
|
|
||||||
]);
|
|
||||||
|
|
||||||
Finding::factory()
|
|
||||||
->for($alphaTenant)
|
|
||||||
->assignedTo((int) $user->getKey())
|
|
||||||
->ownedBy((int) $user->getKey())
|
|
||||||
->overdueByHours()
|
|
||||||
->create();
|
|
||||||
|
|
||||||
Finding::factory()
|
|
||||||
->for($bravoTenant)
|
|
||||||
->reopened()
|
|
||||||
->create();
|
|
||||||
|
|
||||||
OperationRun::factory()
|
|
||||||
->forTenant($alphaTenant)
|
|
||||||
->create([
|
|
||||||
'status' => OperationRunStatus::Completed->value,
|
|
||||||
'outcome' => OperationRunOutcome::Failed->value,
|
|
||||||
'completed_at' => now()->subMinute(),
|
|
||||||
]);
|
|
||||||
|
|
||||||
AlertDelivery::factory()->create([
|
|
||||||
'tenant_id' => null,
|
|
||||||
'workspace_id' => (int) $alphaTenant->workspace_id,
|
|
||||||
'status' => AlertDelivery::STATUS_FAILED,
|
|
||||||
'payload' => [
|
|
||||||
'title' => 'Delivery failed',
|
|
||||||
'body' => 'A notification destination failed.',
|
|
||||||
],
|
|
||||||
]);
|
|
||||||
|
|
||||||
$backupHealthResolver = app(TenantBackupHealthResolver::class);
|
|
||||||
$fingerprints = app(TenantTriageReviewFingerprint::class);
|
|
||||||
$alphaBackupFingerprint = $fingerprints->forBackupHealth($backupHealthResolver->assess($alphaTenant));
|
|
||||||
|
|
||||||
expect($alphaBackupFingerprint)->not->toBeNull();
|
|
||||||
|
|
||||||
TenantTriageReview::factory()
|
|
||||||
->for($alphaTenant)
|
|
||||||
->followUpNeeded()
|
|
||||||
->create([
|
|
||||||
'workspace_id' => (int) $alphaTenant->workspace_id,
|
|
||||||
'reviewed_by_user_id' => (int) $user->getKey(),
|
|
||||||
'concern_family' => PortfolioArrivalContextToken::FAMILY_BACKUP_HEALTH,
|
|
||||||
'review_fingerprint' => $alphaBackupFingerprint['fingerprint'],
|
|
||||||
'review_snapshot' => $alphaBackupFingerprint['snapshot'],
|
|
||||||
]);
|
|
||||||
|
|
||||||
$this->actingAs($user)
|
|
||||||
->withSession([WorkspaceContext::SESSION_KEY => (int) $alphaTenant->workspace_id])
|
|
||||||
->get(GovernanceInbox::getUrl(panel: 'admin'))
|
|
||||||
->assertOk()
|
|
||||||
->assertSee('Assigned findings')
|
|
||||||
->assertSee('Findings intake')
|
|
||||||
->assertSee('Operations follow-up')
|
|
||||||
->assertSee('Alert delivery failures')
|
|
||||||
->assertSee('Review follow-up')
|
|
||||||
->assertSee('Open my findings')
|
|
||||||
->assertSee('Open terminal follow-up')
|
|
||||||
->assertSee('Open alert deliveries')
|
|
||||||
->assertSee('Open review follow-up');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('renders honest empty states for tenant and family filtering on the governance inbox page', function (): void {
|
|
||||||
$alphaTenant = Tenant::factory()->create([
|
|
||||||
'status' => 'active',
|
|
||||||
'name' => 'Alpha Tenant',
|
|
||||||
'external_id' => 'alpha-tenant',
|
|
||||||
]);
|
|
||||||
[$user, $alphaTenant] = createUserWithTenant($alphaTenant, role: 'owner', workspaceRole: 'owner');
|
|
||||||
|
|
||||||
$bravoTenant = Tenant::factory()->create([
|
|
||||||
'status' => 'active',
|
|
||||||
'workspace_id' => (int) $alphaTenant->workspace_id,
|
|
||||||
'name' => 'Bravo Tenant',
|
|
||||||
'external_id' => 'bravo-tenant',
|
|
||||||
]);
|
|
||||||
|
|
||||||
$user->tenants()->syncWithoutDetaching([
|
|
||||||
(int) $bravoTenant->getKey() => ['role' => 'owner'],
|
|
||||||
]);
|
|
||||||
|
|
||||||
Finding::factory()
|
|
||||||
->for($bravoTenant)
|
|
||||||
->assignedTo((int) $user->getKey())
|
|
||||||
->create();
|
|
||||||
|
|
||||||
AlertDelivery::factory()->create([
|
|
||||||
'tenant_id' => null,
|
|
||||||
'workspace_id' => (int) $alphaTenant->workspace_id,
|
|
||||||
'status' => AlertDelivery::STATUS_FAILED,
|
|
||||||
]);
|
|
||||||
|
|
||||||
$this->actingAs($user)
|
|
||||||
->withSession([WorkspaceContext::SESSION_KEY => (int) $alphaTenant->workspace_id])
|
|
||||||
->get(GovernanceInbox::getUrl(panel: 'admin').'?tenant_id='.(string) $alphaTenant->getKey())
|
|
||||||
->assertOk()
|
|
||||||
->assertSee('This tenant filter is hiding other visible attention')
|
|
||||||
->assertSee('Clear tenant filter');
|
|
||||||
|
|
||||||
$this->actingAs($user)
|
|
||||||
->withSession([WorkspaceContext::SESSION_KEY => (int) $alphaTenant->workspace_id])
|
|
||||||
->get(GovernanceInbox::getUrl(panel: 'admin').'?tenant_id='.(string) $alphaTenant->getKey().'&family=alert_delivery_failures')
|
|
||||||
->assertOk()
|
|
||||||
->assertSee('Alert delivery failures')
|
|
||||||
->assertSee('No failed alert deliveries match this tenant filter right now.')
|
|
||||||
->assertDontSee('Open my findings');
|
|
||||||
});
|
|
||||||
@ -950,7 +950,6 @@ function actionSurfaceSystemPanelContext(array $capabilities): PlatformUser
|
|||||||
\App\Filament\System\Pages\Dashboard::class,
|
\App\Filament\System\Pages\Dashboard::class,
|
||||||
\App\Filament\System\Pages\Ops\ViewRun::class,
|
\App\Filament\System\Pages\Ops\ViewRun::class,
|
||||||
\App\Filament\System\Pages\Ops\Runbooks::class,
|
\App\Filament\System\Pages\Ops\Runbooks::class,
|
||||||
\App\Filament\System\Pages\Ops\Controls::class,
|
|
||||||
\App\Filament\System\Pages\RepairWorkspaceOwners::class,
|
\App\Filament\System\Pages\RepairWorkspaceOwners::class,
|
||||||
\App\Filament\System\Pages\Directory\ViewTenant::class,
|
\App\Filament\System\Pages\Directory\ViewTenant::class,
|
||||||
\App\Filament\System\Pages\Directory\ViewWorkspace::class,
|
\App\Filament\System\Pages\Directory\ViewWorkspace::class,
|
||||||
|
|||||||
@ -1,49 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
use Illuminate\Support\Facades\File;
|
|
||||||
|
|
||||||
it('prevents ai governance surfaces from declaring direct outbound or vendor-specific provider runtime code', function (): void {
|
|
||||||
$root = app_path();
|
|
||||||
|
|
||||||
$files = collect(File::allFiles($root))
|
|
||||||
->map(fn (\SplFileInfo $file): string => str_replace($root.'/', '', $file->getPathname()))
|
|
||||||
->filter(fn (string $relativePath): bool => str_starts_with($relativePath, 'Support/Ai/')
|
|
||||||
|| $relativePath === 'Support/ProductKnowledge/ContextualHelpResolver.php'
|
|
||||||
|| $relativePath === 'Support/SupportDiagnostics/SupportDiagnosticBundleBuilder.php')
|
|
||||||
->values();
|
|
||||||
|
|
||||||
$patterns = [
|
|
||||||
'outbound_http' => '/\bHttp::/',
|
|
||||||
'guzzle_client' => '/\bnew\s+Client\b/',
|
|
||||||
'curl_runtime' => '/\bcurl_/i',
|
|
||||||
'openai_vendor' => '/\bOpenAI\b/i',
|
|
||||||
'anthropic_vendor' => '/\bAnthropic\b/i',
|
|
||||||
'gemini_vendor' => '/\bGemini\b/i',
|
|
||||||
'openrouter_vendor' => '/\bOpenRouter\b/i',
|
|
||||||
'chat_completions_runtime' => '/\bChatCompletion\b/i',
|
|
||||||
];
|
|
||||||
|
|
||||||
$hits = [];
|
|
||||||
|
|
||||||
foreach ($files as $relativePath) {
|
|
||||||
$contents = file_get_contents($root.'/'.$relativePath);
|
|
||||||
|
|
||||||
if (! is_string($contents) || $contents === '') {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
$lines = preg_split('/\R/', $contents) ?: [];
|
|
||||||
|
|
||||||
foreach ($patterns as $label => $pattern) {
|
|
||||||
foreach ($lines as $index => $line) {
|
|
||||||
if (preg_match($pattern, $line) === 1) {
|
|
||||||
$hits[] = $relativePath.':'.($index + 1).' ['.$label.'] '.trim($line);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
expect($hits)->toBeEmpty("AI governance surfaces must stay vendor-neutral and must not perform outbound provider runtime calls directly:\n".implode("\n", $hits));
|
|
||||||
});
|
|
||||||
@ -35,7 +35,6 @@ function spec195FormattedIssues(array $issues): string
|
|||||||
\App\Filament\System\Pages\Dashboard::class,
|
\App\Filament\System\Pages\Dashboard::class,
|
||||||
\App\Filament\System\Pages\Ops\ViewRun::class,
|
\App\Filament\System\Pages\Ops\ViewRun::class,
|
||||||
\App\Filament\System\Pages\Ops\Runbooks::class,
|
\App\Filament\System\Pages\Ops\Runbooks::class,
|
||||||
\App\Filament\System\Pages\Ops\Controls::class,
|
|
||||||
\App\Filament\System\Pages\RepairWorkspaceOwners::class,
|
\App\Filament\System\Pages\RepairWorkspaceOwners::class,
|
||||||
\App\Filament\System\Pages\Directory\ViewTenant::class,
|
\App\Filament\System\Pages\Directory\ViewTenant::class,
|
||||||
\App\Filament\System\Pages\Directory\ViewWorkspace::class,
|
\App\Filament\System\Pages\Directory\ViewWorkspace::class,
|
||||||
@ -68,7 +67,6 @@ function spec195FormattedIssues(array $issues): string
|
|||||||
|
|
||||||
expect($inventory[\App\Filament\System\Pages\Ops\ViewRun::class]['closureDecision'] ?? null)->toBe('separately_governed')
|
expect($inventory[\App\Filament\System\Pages\Ops\ViewRun::class]['closureDecision'] ?? null)->toBe('separately_governed')
|
||||||
->and($inventory[\App\Filament\System\Pages\Ops\Runbooks::class]['closureDecision'] ?? null)->toBe('separately_governed')
|
->and($inventory[\App\Filament\System\Pages\Ops\Runbooks::class]['closureDecision'] ?? null)->toBe('separately_governed')
|
||||||
->and($inventory[\App\Filament\System\Pages\Ops\Controls::class]['closureDecision'] ?? null)->toBe('separately_governed')
|
|
||||||
->and($inventory[\App\Filament\System\Pages\RepairWorkspaceOwners::class]['closureDecision'] ?? null)->toBe('separately_governed')
|
->and($inventory[\App\Filament\System\Pages\RepairWorkspaceOwners::class]['closureDecision'] ?? null)->toBe('separately_governed')
|
||||||
->and($inventory[\App\Filament\System\Pages\Directory\ViewTenant::class]['closureDecision'] ?? null)->toBe('harmless_special_case')
|
->and($inventory[\App\Filament\System\Pages\Directory\ViewTenant::class]['closureDecision'] ?? null)->toBe('harmless_special_case')
|
||||||
->and($inventory[\App\Filament\System\Pages\Directory\ViewWorkspace::class]['closureDecision'] ?? null)->toBe('harmless_special_case')
|
->and($inventory[\App\Filament\System\Pages\Directory\ViewWorkspace::class]['closureDecision'] ?? null)->toBe('harmless_special_case')
|
||||||
@ -78,7 +76,6 @@ function spec195FormattedIssues(array $issues): string
|
|||||||
\App\Filament\System\Pages\Dashboard::class,
|
\App\Filament\System\Pages\Dashboard::class,
|
||||||
\App\Filament\System\Pages\Ops\ViewRun::class,
|
\App\Filament\System\Pages\Ops\ViewRun::class,
|
||||||
\App\Filament\System\Pages\Ops\Runbooks::class,
|
\App\Filament\System\Pages\Ops\Runbooks::class,
|
||||||
\App\Filament\System\Pages\Ops\Controls::class,
|
|
||||||
\App\Filament\System\Pages\RepairWorkspaceOwners::class,
|
\App\Filament\System\Pages\RepairWorkspaceOwners::class,
|
||||||
\App\Filament\System\Pages\Directory\ViewTenant::class,
|
\App\Filament\System\Pages\Directory\ViewTenant::class,
|
||||||
\App\Filament\System\Pages\Directory\ViewWorkspace::class,
|
\App\Filament\System\Pages\Directory\ViewWorkspace::class,
|
||||||
|
|||||||
@ -1,276 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
use App\Filament\Pages\Workspaces\ManagedTenantOnboardingWizard;
|
|
||||||
use App\Models\AuditLog;
|
|
||||||
use App\Models\OperationRun;
|
|
||||||
use App\Models\PlatformUser;
|
|
||||||
use App\Models\ProviderConnection;
|
|
||||||
use App\Models\Tenant;
|
|
||||||
use App\Models\TenantOnboardingSession;
|
|
||||||
use App\Models\User;
|
|
||||||
use App\Models\Workspace;
|
|
||||||
use App\Services\Entitlements\WorkspaceCommercialLifecycleResolver;
|
|
||||||
use App\Services\Entitlements\WorkspaceEntitlementResolver;
|
|
||||||
use App\Services\Settings\SettingsWriter;
|
|
||||||
use App\Support\Auth\PlatformCapabilities;
|
|
||||||
use App\Support\OperationRunOutcome;
|
|
||||||
use App\Support\OperationRunStatus;
|
|
||||||
use App\Support\Workspaces\WorkspaceContext;
|
|
||||||
use Illuminate\Support\Facades\Queue;
|
|
||||||
use Livewire\Livewire;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @return array{workspace: Workspace, user: User, tenant: Tenant, draft: TenantOnboardingSession, component: \Livewire\Features\SupportTesting\Testable}
|
|
||||||
*/
|
|
||||||
function readyOnboardingEntitlementContext(
|
|
||||||
int $activeTenantCount = 0,
|
|
||||||
?int $limitOverride = null,
|
|
||||||
?string $overrideReason = null,
|
|
||||||
?string $commercialState = null,
|
|
||||||
): array
|
|
||||||
{
|
|
||||||
Queue::fake();
|
|
||||||
|
|
||||||
$workspace = Workspace::factory()->create();
|
|
||||||
$user = User::factory()->create();
|
|
||||||
|
|
||||||
$tenant = Tenant::factory()->create([
|
|
||||||
'workspace_id' => (int) $workspace->getKey(),
|
|
||||||
'status' => Tenant::STATUS_ONBOARDING,
|
|
||||||
]);
|
|
||||||
|
|
||||||
createUserWithTenant(
|
|
||||||
tenant: $tenant,
|
|
||||||
user: $user,
|
|
||||||
role: 'owner',
|
|
||||||
workspaceRole: 'owner',
|
|
||||||
ensureDefaultMicrosoftProviderConnection: false,
|
|
||||||
);
|
|
||||||
|
|
||||||
if ($activeTenantCount > 0) {
|
|
||||||
Tenant::factory()->count($activeTenantCount)->create([
|
|
||||||
'workspace_id' => (int) $workspace->getKey(),
|
|
||||||
'status' => Tenant::STATUS_ACTIVE,
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
|
|
||||||
$connection = ProviderConnection::factory()->platform()->consentGranted()->create([
|
|
||||||
'workspace_id' => (int) $workspace->getKey(),
|
|
||||||
'tenant_id' => (int) $tenant->getKey(),
|
|
||||||
'provider' => 'microsoft',
|
|
||||||
'entra_tenant_id' => (string) $tenant->tenant_id,
|
|
||||||
'display_name' => 'Ready connection',
|
|
||||||
'is_default' => true,
|
|
||||||
'consent_status' => 'granted',
|
|
||||||
]);
|
|
||||||
|
|
||||||
$run = OperationRun::factory()->create([
|
|
||||||
'tenant_id' => (int) $tenant->getKey(),
|
|
||||||
'workspace_id' => (int) $workspace->getKey(),
|
|
||||||
'user_id' => (int) $user->getKey(),
|
|
||||||
'type' => 'provider.connection.check',
|
|
||||||
'status' => OperationRunStatus::Completed->value,
|
|
||||||
'outcome' => OperationRunOutcome::Succeeded->value,
|
|
||||||
'context' => [
|
|
||||||
'provider' => 'microsoft',
|
|
||||||
'module' => 'health_check',
|
|
||||||
'provider_connection_id' => (int) $connection->getKey(),
|
|
||||||
'target_scope' => [
|
|
||||||
'entra_tenant_id' => (string) $tenant->tenant_id,
|
|
||||||
],
|
|
||||||
],
|
|
||||||
]);
|
|
||||||
|
|
||||||
$draft = createOnboardingDraft([
|
|
||||||
'workspace' => $workspace,
|
|
||||||
'tenant' => $tenant,
|
|
||||||
'started_by' => $user,
|
|
||||||
'updated_by' => $user,
|
|
||||||
'current_step' => 'bootstrap',
|
|
||||||
'state' => [
|
|
||||||
'entra_tenant_id' => (string) $tenant->tenant_id,
|
|
||||||
'tenant_name' => (string) $tenant->name,
|
|
||||||
'provider_connection_id' => (int) $connection->getKey(),
|
|
||||||
'verification_operation_run_id' => (int) $run->getKey(),
|
|
||||||
],
|
|
||||||
]);
|
|
||||||
|
|
||||||
if ($limitOverride !== null) {
|
|
||||||
$writer = app(SettingsWriter::class);
|
|
||||||
$writer->updateWorkspaceSetting(
|
|
||||||
actor: $user,
|
|
||||||
workspace: $workspace,
|
|
||||||
domain: WorkspaceEntitlementResolver::SETTING_DOMAIN,
|
|
||||||
key: WorkspaceEntitlementResolver::SETTING_MANAGED_TENANT_LIMIT_OVERRIDE_VALUE,
|
|
||||||
value: $limitOverride,
|
|
||||||
);
|
|
||||||
|
|
||||||
if ($overrideReason !== null) {
|
|
||||||
$writer->updateWorkspaceSetting(
|
|
||||||
actor: $user,
|
|
||||||
workspace: $workspace,
|
|
||||||
domain: WorkspaceEntitlementResolver::SETTING_DOMAIN,
|
|
||||||
key: WorkspaceEntitlementResolver::SETTING_MANAGED_TENANT_LIMIT_OVERRIDE_REASON,
|
|
||||||
value: $overrideReason,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if ($commercialState !== null) {
|
|
||||||
app(SettingsWriter::class)->updateWorkspaceCommercialLifecycle(
|
|
||||||
actor: PlatformUser::factory()->create([
|
|
||||||
'capabilities' => [
|
|
||||||
PlatformCapabilities::ACCESS_SYSTEM_PANEL,
|
|
||||||
PlatformCapabilities::DIRECTORY_VIEW,
|
|
||||||
PlatformCapabilities::COMMERCIAL_LIFECYCLE_MANAGE,
|
|
||||||
],
|
|
||||||
'is_active' => true,
|
|
||||||
]),
|
|
||||||
workspace: $workspace,
|
|
||||||
state: $commercialState,
|
|
||||||
reason: 'Onboarding entitlement test commercial state',
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
session()->put(WorkspaceContext::SESSION_KEY, (int) $workspace->getKey());
|
|
||||||
|
|
||||||
$component = Livewire::actingAs($user)->test(ManagedTenantOnboardingWizard::class, [
|
|
||||||
'onboardingDraft' => (int) $draft->getKey(),
|
|
||||||
]);
|
|
||||||
|
|
||||||
return compact('workspace', 'user', 'tenant', 'draft', 'component');
|
|
||||||
}
|
|
||||||
|
|
||||||
it('allows onboarding activation when the workspace is within its managed tenant limit', function (): void {
|
|
||||||
$context = readyOnboardingEntitlementContext(activeTenantCount: 0);
|
|
||||||
|
|
||||||
$context['component']->call('completeOnboarding');
|
|
||||||
|
|
||||||
$context['tenant']->refresh();
|
|
||||||
|
|
||||||
expect($context['tenant']->status)->toBe(Tenant::STATUS_ACTIVE)
|
|
||||||
->and(AuditLog::query()
|
|
||||||
->where('workspace_id', (int) $context['workspace']->getKey())
|
|
||||||
->where('action', 'managed_tenant_onboarding.activation')
|
|
||||||
->exists())->toBeTrue();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('blocks onboarding activation with a business-state reason when the workspace is at limit', function (): void {
|
|
||||||
$context = readyOnboardingEntitlementContext(
|
|
||||||
activeTenantCount: 1,
|
|
||||||
limitOverride: 1,
|
|
||||||
overrideReason: 'Customer currently allows one active tenant',
|
|
||||||
);
|
|
||||||
|
|
||||||
$decision = app(WorkspaceEntitlementResolver::class)->resolve(
|
|
||||||
$context['workspace'],
|
|
||||||
WorkspaceEntitlementResolver::KEY_MANAGED_TENANT_ACTIVATION_LIMIT,
|
|
||||||
);
|
|
||||||
|
|
||||||
expect($decision['is_blocked'])->toBeTrue();
|
|
||||||
|
|
||||||
$context['component']
|
|
||||||
->assertSee('Activation entitlement')
|
|
||||||
->assertSee('Blocked')
|
|
||||||
->call('completeOnboarding');
|
|
||||||
|
|
||||||
$context['tenant']->refresh();
|
|
||||||
|
|
||||||
expect($context['tenant']->status)->toBe(Tenant::STATUS_ONBOARDING)
|
|
||||||
->and(AuditLog::query()
|
|
||||||
->where('workspace_id', (int) $context['workspace']->getKey())
|
|
||||||
->where('action', 'managed_tenant_onboarding.activation')
|
|
||||||
->exists())->toBeFalse();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('allows onboarding activation when a workspace override raises the limit above current usage', function (): void {
|
|
||||||
$context = readyOnboardingEntitlementContext(
|
|
||||||
activeTenantCount: 1,
|
|
||||||
limitOverride: 2,
|
|
||||||
overrideReason: 'Temporary support-approved exception',
|
|
||||||
);
|
|
||||||
|
|
||||||
$decision = app(WorkspaceEntitlementResolver::class)->resolve(
|
|
||||||
$context['workspace'],
|
|
||||||
WorkspaceEntitlementResolver::KEY_MANAGED_TENANT_ACTIVATION_LIMIT,
|
|
||||||
);
|
|
||||||
|
|
||||||
expect($decision)
|
|
||||||
->toMatchArray([
|
|
||||||
'source' => 'workspace_override',
|
|
||||||
'effective_value' => 2,
|
|
||||||
'current_usage' => 1,
|
|
||||||
'is_blocked' => false,
|
|
||||||
'rationale' => 'Temporary support-approved exception',
|
|
||||||
]);
|
|
||||||
|
|
||||||
$context['component']->call('completeOnboarding');
|
|
||||||
|
|
||||||
$context['tenant']->refresh();
|
|
||||||
|
|
||||||
expect($context['tenant']->status)->toBe(Tenant::STATUS_ACTIVE);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('allows onboarding activation while a workspace is in trial', function (): void {
|
|
||||||
$context = readyOnboardingEntitlementContext(
|
|
||||||
activeTenantCount: 0,
|
|
||||||
commercialState: WorkspaceCommercialLifecycleResolver::STATE_TRIAL,
|
|
||||||
);
|
|
||||||
|
|
||||||
$context['component']
|
|
||||||
->assertSee('Activation entitlement')
|
|
||||||
->assertSee('Trial')
|
|
||||||
->call('completeOnboarding');
|
|
||||||
|
|
||||||
$context['tenant']->refresh();
|
|
||||||
|
|
||||||
expect($context['tenant']->status)->toBe(Tenant::STATUS_ACTIVE)
|
|
||||||
->and(AuditLog::query()
|
|
||||||
->where('workspace_id', (int) $context['workspace']->getKey())
|
|
||||||
->where('action', 'managed_tenant_onboarding.activation')
|
|
||||||
->exists())->toBeTrue();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('blocks onboarding activation with a grace commercial-state reason before tenant mutation', function (): void {
|
|
||||||
$context = readyOnboardingEntitlementContext(
|
|
||||||
activeTenantCount: 0,
|
|
||||||
commercialState: WorkspaceCommercialLifecycleResolver::STATE_GRACE,
|
|
||||||
);
|
|
||||||
|
|
||||||
$context['component']
|
|
||||||
->assertSee('Activation entitlement')
|
|
||||||
->assertSee('Grace')
|
|
||||||
->assertSee('New managed-tenant activation is frozen while this workspace is in grace.')
|
|
||||||
->call('completeOnboarding');
|
|
||||||
|
|
||||||
$context['tenant']->refresh();
|
|
||||||
|
|
||||||
expect($context['tenant']->status)->toBe(Tenant::STATUS_ONBOARDING)
|
|
||||||
->and(AuditLog::query()
|
|
||||||
->where('workspace_id', (int) $context['workspace']->getKey())
|
|
||||||
->where('action', 'managed_tenant_onboarding.activation')
|
|
||||||
->exists())->toBeFalse();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('blocks onboarding activation with a suspended read-only commercial-state reason before tenant mutation', function (): void {
|
|
||||||
$context = readyOnboardingEntitlementContext(
|
|
||||||
activeTenantCount: 0,
|
|
||||||
commercialState: WorkspaceCommercialLifecycleResolver::STATE_SUSPENDED_READ_ONLY,
|
|
||||||
);
|
|
||||||
|
|
||||||
$context['component']
|
|
||||||
->assertSee('Activation entitlement')
|
|
||||||
->assertSee('Suspended / read-only')
|
|
||||||
->assertSee('This workspace is suspended / read-only. New managed-tenant activation is blocked')
|
|
||||||
->call('completeOnboarding');
|
|
||||||
|
|
||||||
$context['tenant']->refresh();
|
|
||||||
|
|
||||||
expect($context['tenant']->status)->toBe(Tenant::STATUS_ONBOARDING)
|
|
||||||
->and(AuditLog::query()
|
|
||||||
->where('workspace_id', (int) $context['workspace']->getKey())
|
|
||||||
->where('action', 'managed_tenant_onboarding.activation')
|
|
||||||
->exists())->toBeFalse();
|
|
||||||
});
|
|
||||||
@ -2,17 +2,14 @@
|
|||||||
|
|
||||||
declare(strict_types=1);
|
declare(strict_types=1);
|
||||||
|
|
||||||
use App\Filament\System\Pages\Ops\Controls;
|
|
||||||
use App\Filament\Resources\RestoreRunResource;
|
use App\Filament\Resources\RestoreRunResource;
|
||||||
use App\Filament\Resources\RestoreRunResource\Pages\CreateRestoreRun;
|
use App\Filament\Resources\RestoreRunResource\Pages\CreateRestoreRun;
|
||||||
use App\Models\BackupItem;
|
use App\Models\BackupItem;
|
||||||
use App\Models\BackupSet;
|
use App\Models\BackupSet;
|
||||||
use App\Models\OperationalControlActivation;
|
use App\Models\OperationalControlActivation;
|
||||||
use App\Models\PlatformUser;
|
|
||||||
use App\Models\Policy;
|
use App\Models\Policy;
|
||||||
use App\Models\Tenant;
|
use App\Models\Tenant;
|
||||||
use App\Models\User;
|
use App\Models\User;
|
||||||
use App\Support\Auth\PlatformCapabilities;
|
|
||||||
use Filament\Facades\Filament;
|
use Filament\Facades\Filament;
|
||||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||||
use Livewire\Livewire;
|
use Livewire\Livewire;
|
||||||
@ -134,49 +131,3 @@ function seedRestoreAuthorizationContext(): array
|
|||||||
->call('create')
|
->call('create')
|
||||||
->assertNotified('Restore execution paused');
|
->assertNotified('Restore execution paused');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('forbids ai execution controls for platform users missing system panel access', function (): void {
|
|
||||||
$user = PlatformUser::factory()->create([
|
|
||||||
'capabilities' => [
|
|
||||||
PlatformCapabilities::OPS_CONTROLS_MANAGE,
|
|
||||||
],
|
|
||||||
'is_active' => true,
|
|
||||||
]);
|
|
||||||
|
|
||||||
$this->actingAs($user, 'platform')
|
|
||||||
->get(Controls::getUrl(panel: 'system'))
|
|
||||||
->assertForbidden();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('forbids ai execution controls for platform users missing ops controls manage', function (): void {
|
|
||||||
$user = PlatformUser::factory()->create([
|
|
||||||
'capabilities' => [
|
|
||||||
PlatformCapabilities::ACCESS_SYSTEM_PANEL,
|
|
||||||
],
|
|
||||||
'is_active' => true,
|
|
||||||
]);
|
|
||||||
|
|
||||||
$this->actingAs($user, 'platform')
|
|
||||||
->get(Controls::getUrl(panel: 'system'))
|
|
||||||
->assertForbidden();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('shows ai execution controls only to platform users with the existing system control capabilities', function (): void {
|
|
||||||
$user = PlatformUser::factory()->create([
|
|
||||||
'capabilities' => [
|
|
||||||
PlatformCapabilities::ACCESS_SYSTEM_PANEL,
|
|
||||||
PlatformCapabilities::OPS_CONTROLS_MANAGE,
|
|
||||||
],
|
|
||||||
'is_active' => true,
|
|
||||||
]);
|
|
||||||
|
|
||||||
$this->actingAs($user, 'platform')
|
|
||||||
->get(Controls::getUrl(panel: 'system'))
|
|
||||||
->assertSuccessful()
|
|
||||||
->assertSee('AI execution');
|
|
||||||
|
|
||||||
Livewire::actingAs($user, 'platform')
|
|
||||||
->test(Controls::class)
|
|
||||||
->assertActionVisible('pause_ai_execution')
|
|
||||||
->assertActionVisible('resume_ai_execution');
|
|
||||||
});
|
|
||||||
@ -27,10 +27,3 @@
|
|||||||
->toContain(".:/var/www/repo:ro")
|
->toContain(".:/var/www/repo:ro")
|
||||||
->toContain('TENANTATLAS_REPO_ROOT: /var/www/repo');
|
->toContain('TENANTATLAS_REPO_ROOT: /var/www/repo');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('keeps the local queue service in code-reloading listen mode', function (): void {
|
|
||||||
$compose = file_get_contents(repo_path('docker-compose.yml'));
|
|
||||||
|
|
||||||
expect($compose)->toContain('command: php artisan queue:listen --tries=3 --timeout=300 --sleep=3')
|
|
||||||
->not->toContain('command: php artisan queue:work --tries=3 --timeout=300 --sleep=3 --max-jobs=1000');
|
|
||||||
});
|
|
||||||
|
|||||||
@ -3,13 +3,7 @@
|
|||||||
declare(strict_types=1);
|
declare(strict_types=1);
|
||||||
|
|
||||||
use App\Models\ReviewPack;
|
use App\Models\ReviewPack;
|
||||||
use App\Models\AuditLog;
|
|
||||||
use App\Models\PlatformUser;
|
|
||||||
use App\Services\Entitlements\WorkspaceCommercialLifecycleResolver;
|
|
||||||
use App\Services\ReviewPackService;
|
use App\Services\ReviewPackService;
|
||||||
use App\Services\Settings\SettingsWriter;
|
|
||||||
use App\Support\Audit\AuditActionId;
|
|
||||||
use App\Support\Auth\PlatformCapabilities;
|
|
||||||
use App\Support\ReviewPackStatus;
|
use App\Support\ReviewPackStatus;
|
||||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||||
use Illuminate\Support\Facades\Storage;
|
use Illuminate\Support\Facades\Storage;
|
||||||
@ -42,56 +36,12 @@ function createReadyPackWithFile(?array $packOverrides = []): array
|
|||||||
return [$user, $tenant, $pack];
|
return [$user, $tenant, $pack];
|
||||||
}
|
}
|
||||||
|
|
||||||
function suspendReadyPackWorkspaceForDownloadTest(ReviewPack $pack): void
|
|
||||||
{
|
|
||||||
app(SettingsWriter::class)->updateWorkspaceCommercialLifecycle(
|
|
||||||
actor: PlatformUser::factory()->create([
|
|
||||||
'capabilities' => [
|
|
||||||
PlatformCapabilities::ACCESS_SYSTEM_PANEL,
|
|
||||||
PlatformCapabilities::DIRECTORY_VIEW,
|
|
||||||
PlatformCapabilities::COMMERCIAL_LIFECYCLE_MANAGE,
|
|
||||||
],
|
|
||||||
'is_active' => true,
|
|
||||||
]),
|
|
||||||
workspace: $pack->workspace,
|
|
||||||
state: WorkspaceCommercialLifecycleResolver::STATE_SUSPENDED_READ_ONLY,
|
|
||||||
reason: 'Download preservation test',
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── Happy Path: Signed URL → 200 ───────────────────────────
|
// ─── Happy Path: Signed URL → 200 ───────────────────────────
|
||||||
|
|
||||||
it('downloads a ready pack via signed URL with correct headers', function (): void {
|
it('downloads a ready pack via signed URL with correct headers', function (): void {
|
||||||
[$user, $tenant, $pack] = createReadyPackWithFile();
|
[$user, $tenant, $pack] = createReadyPackWithFile();
|
||||||
|
|
||||||
$signedUrl = app(ReviewPackService::class)->generateDownloadUrl($pack, [
|
$signedUrl = app(ReviewPackService::class)->generateDownloadUrl($pack);
|
||||||
'source_surface' => 'customer_review_workspace',
|
|
||||||
]);
|
|
||||||
|
|
||||||
$response = $this->actingAs($user)->get($signedUrl);
|
|
||||||
|
|
||||||
$response->assertOk();
|
|
||||||
$response->assertHeader('X-Review-Pack-SHA256', $pack->sha256);
|
|
||||||
$response->assertDownload();
|
|
||||||
|
|
||||||
$audit = AuditLog::query()
|
|
||||||
->where('action', AuditActionId::ReviewPackDownloaded->value)
|
|
||||||
->latest('id')
|
|
||||||
->first();
|
|
||||||
|
|
||||||
expect($audit)->not->toBeNull()
|
|
||||||
->and($audit?->resource_type)->toBe('review_pack')
|
|
||||||
->and(data_get($audit?->metadata, 'review_pack_id'))->toBe((int) $pack->getKey())
|
|
||||||
->and(data_get($audit?->metadata, 'source_surface'))->toBe('customer_review_workspace');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('keeps ready pack downloads available while the workspace is suspended read-only', function (): void {
|
|
||||||
[$user, $tenant, $pack] = createReadyPackWithFile();
|
|
||||||
suspendReadyPackWorkspaceForDownloadTest($pack);
|
|
||||||
|
|
||||||
$signedUrl = app(ReviewPackService::class)->generateDownloadUrl($pack, [
|
|
||||||
'source_surface' => 'suspended_read_only_check',
|
|
||||||
]);
|
|
||||||
|
|
||||||
$response = $this->actingAs($user)->get($signedUrl);
|
$response = $this->actingAs($user)->get($signedUrl);
|
||||||
|
|
||||||
|
|||||||
@ -1,294 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
use App\Exceptions\Entitlements\WorkspaceEntitlementBlockedException;
|
|
||||||
use App\Filament\Resources\ReviewPackResource;
|
|
||||||
use App\Filament\Widgets\Tenant\TenantReviewPackCard;
|
|
||||||
use App\Models\EvidenceSnapshot;
|
|
||||||
use App\Models\Finding;
|
|
||||||
use App\Models\OperationRun;
|
|
||||||
use App\Models\PlatformUser;
|
|
||||||
use App\Models\ReviewPack;
|
|
||||||
use App\Models\StoredReport;
|
|
||||||
use App\Models\Tenant;
|
|
||||||
use App\Models\User;
|
|
||||||
use App\Services\Entitlements\WorkspaceCommercialLifecycleResolver;
|
|
||||||
use App\Services\Evidence\EvidenceSnapshotService;
|
|
||||||
use App\Services\ReviewPackService;
|
|
||||||
use App\Services\Settings\SettingsWriter;
|
|
||||||
use App\Support\Auth\PlatformCapabilities;
|
|
||||||
use App\Support\Evidence\EvidenceSnapshotStatus;
|
|
||||||
use App\Support\OperationRunType;
|
|
||||||
use App\Support\Workspaces\WorkspaceContext;
|
|
||||||
use Illuminate\Support\Facades\Notification;
|
|
||||||
use Illuminate\Support\Facades\Storage;
|
|
||||||
use Livewire\Livewire;
|
|
||||||
|
|
||||||
beforeEach(function (): void {
|
|
||||||
Storage::fake('exports');
|
|
||||||
});
|
|
||||||
|
|
||||||
function seedEntitlementReviewPackSnapshot(Tenant $tenant): EvidenceSnapshot
|
|
||||||
{
|
|
||||||
StoredReport::factory()->create([
|
|
||||||
'tenant_id' => (int) $tenant->getKey(),
|
|
||||||
'report_type' => StoredReport::REPORT_TYPE_PERMISSION_POSTURE,
|
|
||||||
'payload' => ['required_count' => 1, 'granted_count' => 1],
|
|
||||||
]);
|
|
||||||
|
|
||||||
StoredReport::factory()->create([
|
|
||||||
'tenant_id' => (int) $tenant->getKey(),
|
|
||||||
'report_type' => StoredReport::REPORT_TYPE_ENTRA_ADMIN_ROLES,
|
|
||||||
'payload' => ['roles' => [['displayName' => 'Global Administrator']]],
|
|
||||||
]);
|
|
||||||
|
|
||||||
Finding::factory()->create([
|
|
||||||
'tenant_id' => (int) $tenant->getKey(),
|
|
||||||
'workspace_id' => (int) $tenant->workspace_id,
|
|
||||||
]);
|
|
||||||
|
|
||||||
Finding::factory()->create([
|
|
||||||
'tenant_id' => (int) $tenant->getKey(),
|
|
||||||
'workspace_id' => (int) $tenant->workspace_id,
|
|
||||||
'finding_type' => Finding::FINDING_TYPE_DRIFT,
|
|
||||||
]);
|
|
||||||
|
|
||||||
OperationRun::factory()->forTenant($tenant)->create();
|
|
||||||
|
|
||||||
/** @var EvidenceSnapshotService $service */
|
|
||||||
$service = app(EvidenceSnapshotService::class);
|
|
||||||
$payload = $service->buildSnapshotPayload($tenant);
|
|
||||||
|
|
||||||
$snapshot = EvidenceSnapshot::query()->create([
|
|
||||||
'tenant_id' => (int) $tenant->getKey(),
|
|
||||||
'workspace_id' => (int) $tenant->workspace_id,
|
|
||||||
'status' => EvidenceSnapshotStatus::Active->value,
|
|
||||||
'fingerprint' => $payload['fingerprint'],
|
|
||||||
'completeness_state' => $payload['completeness'],
|
|
||||||
'summary' => $payload['summary'],
|
|
||||||
'generated_at' => now(),
|
|
||||||
]);
|
|
||||||
|
|
||||||
foreach ($payload['items'] as $item) {
|
|
||||||
$snapshot->items()->create([
|
|
||||||
'tenant_id' => (int) $tenant->getKey(),
|
|
||||||
'workspace_id' => (int) $tenant->workspace_id,
|
|
||||||
'dimension_key' => $item['dimension_key'],
|
|
||||||
'state' => $item['state'],
|
|
||||||
'required' => $item['required'],
|
|
||||||
'source_kind' => $item['source_kind'],
|
|
||||||
'source_record_type' => $item['source_record_type'],
|
|
||||||
'source_record_id' => $item['source_record_id'],
|
|
||||||
'source_fingerprint' => $item['source_fingerprint'],
|
|
||||||
'measured_at' => $item['measured_at'],
|
|
||||||
'freshness_at' => $item['freshness_at'],
|
|
||||||
'summary_payload' => $item['summary_payload'],
|
|
||||||
'sort_order' => $item['sort_order'],
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
|
|
||||||
return $snapshot;
|
|
||||||
}
|
|
||||||
|
|
||||||
function disableReviewPackGenerationForWorkspace(Tenant $tenant, User $user, string $reason): void
|
|
||||||
{
|
|
||||||
$writer = app(SettingsWriter::class);
|
|
||||||
|
|
||||||
$writer->updateWorkspaceSetting(
|
|
||||||
actor: $user,
|
|
||||||
workspace: $tenant->workspace,
|
|
||||||
domain: 'entitlements',
|
|
||||||
key: 'review_pack_generation_override_value',
|
|
||||||
value: false,
|
|
||||||
);
|
|
||||||
|
|
||||||
$writer->updateWorkspaceSetting(
|
|
||||||
actor: $user,
|
|
||||||
workspace: $tenant->workspace,
|
|
||||||
domain: 'entitlements',
|
|
||||||
key: 'review_pack_generation_override_reason',
|
|
||||||
value: $reason,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function setReviewPackCommercialLifecycleState(Tenant $tenant, string $state, string $reason = 'Review pack commercial lifecycle test'): void
|
|
||||||
{
|
|
||||||
app(SettingsWriter::class)->updateWorkspaceCommercialLifecycle(
|
|
||||||
actor: PlatformUser::factory()->create([
|
|
||||||
'capabilities' => [
|
|
||||||
PlatformCapabilities::ACCESS_SYSTEM_PANEL,
|
|
||||||
PlatformCapabilities::DIRECTORY_VIEW,
|
|
||||||
PlatformCapabilities::COMMERCIAL_LIFECYCLE_MANAGE,
|
|
||||||
],
|
|
||||||
'is_active' => true,
|
|
||||||
]),
|
|
||||||
workspace: $tenant->workspace,
|
|
||||||
state: $state,
|
|
||||||
reason: $reason,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
it('blocks new review pack generation before creating a review pack or operation run when the workspace is not entitled', function (): void {
|
|
||||||
[$user, $tenant] = createUserWithTenant(role: 'owner');
|
|
||||||
seedEntitlementReviewPackSnapshot($tenant);
|
|
||||||
disableReviewPackGenerationForWorkspace($tenant, $user, 'Workspace is temporarily limited to manual reporting only');
|
|
||||||
$initialRunCount = OperationRun::query()
|
|
||||||
->where('tenant_id', (int) $tenant->getKey())
|
|
||||||
->where('type', OperationRunType::ReviewPackGenerate->value)
|
|
||||||
->count();
|
|
||||||
|
|
||||||
expect(fn (): ReviewPack => app(ReviewPackService::class)->generate($tenant, $user))
|
|
||||||
->toThrow(WorkspaceEntitlementBlockedException::class, 'Workspace is temporarily limited to manual reporting only');
|
|
||||||
|
|
||||||
expect(ReviewPack::query()->count())->toBe(0)
|
|
||||||
->and(OperationRun::query()
|
|
||||||
->where('tenant_id', (int) $tenant->getKey())
|
|
||||||
->where('type', OperationRunType::ReviewPackGenerate->value)
|
|
||||||
->count())->toBe($initialRunCount);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('blocks executive pack export before creating a review pack or operation run when the workspace is not entitled', function (): void {
|
|
||||||
[$user, $tenant] = createUserWithTenant(role: 'owner');
|
|
||||||
$snapshot = seedEntitlementReviewPackSnapshot($tenant);
|
|
||||||
$review = composeTenantReviewForTest($tenant, $user, $snapshot);
|
|
||||||
disableReviewPackGenerationForWorkspace($tenant, $user, 'Workspace is temporarily limited to manual reporting only');
|
|
||||||
$initialRunCount = OperationRun::query()
|
|
||||||
->where('tenant_id', (int) $tenant->getKey())
|
|
||||||
->where('type', OperationRunType::ReviewPackGenerate->value)
|
|
||||||
->count();
|
|
||||||
|
|
||||||
expect(fn (): ReviewPack => app(ReviewPackService::class)->generateFromReview($review, $user))
|
|
||||||
->toThrow(WorkspaceEntitlementBlockedException::class, 'Workspace is temporarily limited to manual reporting only');
|
|
||||||
|
|
||||||
expect(ReviewPack::query()->count())->toBe(0)
|
|
||||||
->and(OperationRun::query()
|
|
||||||
->where('tenant_id', (int) $tenant->getKey())
|
|
||||||
->where('type', OperationRunType::ReviewPackGenerate->value)
|
|
||||||
->count())->toBe($initialRunCount);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('shows the blocked reason on the review pack card and keeps existing pack downloads accessible', function (): void {
|
|
||||||
$tenant = Tenant::factory()->create();
|
|
||||||
[$user, $tenant] = createUserWithTenant(tenant: $tenant, role: 'owner');
|
|
||||||
disableReviewPackGenerationForWorkspace($tenant, $user, 'Workspace is temporarily limited to manual reporting only');
|
|
||||||
$initialRunCount = OperationRun::query()
|
|
||||||
->where('tenant_id', (int) $tenant->getKey())
|
|
||||||
->where('type', OperationRunType::ReviewPackGenerate->value)
|
|
||||||
->count();
|
|
||||||
|
|
||||||
session()->put(WorkspaceContext::SESSION_KEY, (int) $tenant->workspace_id);
|
|
||||||
setTenantPanelContext($tenant);
|
|
||||||
|
|
||||||
Livewire::actingAs($user)
|
|
||||||
->test(TenantReviewPackCard::class, ['record' => $tenant])
|
|
||||||
->assertSee('Workspace is temporarily limited to manual reporting only')
|
|
||||||
->assertSee('Generate pack')
|
|
||||||
->call('generatePack', true, true)
|
|
||||||
->assertHasNoErrors();
|
|
||||||
|
|
||||||
expect(ReviewPack::query()->count())->toBe(0)
|
|
||||||
->and(OperationRun::query()
|
|
||||||
->where('tenant_id', (int) $tenant->getKey())
|
|
||||||
->where('type', OperationRunType::ReviewPackGenerate->value)
|
|
||||||
->count())->toBe($initialRunCount);
|
|
||||||
|
|
||||||
$filePath = 'review-packs/entitlement-download-test.zip';
|
|
||||||
Storage::disk('exports')->put($filePath, 'PK-test');
|
|
||||||
|
|
||||||
$pack = ReviewPack::factory()->ready()->create([
|
|
||||||
'tenant_id' => (int) $tenant->getKey(),
|
|
||||||
'workspace_id' => (int) $tenant->workspace_id,
|
|
||||||
'initiated_by_user_id' => (int) $user->getKey(),
|
|
||||||
'file_path' => $filePath,
|
|
||||||
'file_disk' => 'exports',
|
|
||||||
]);
|
|
||||||
|
|
||||||
$this->actingAs($user)
|
|
||||||
->get(ReviewPackResource::getUrl('view', ['record' => $pack], tenant: $tenant, panel: 'tenant'))
|
|
||||||
->assertOk()
|
|
||||||
->assertSee('Download');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('allows review pack generation in trial and active paid states', function (string $state): void {
|
|
||||||
[$user, $tenant] = createUserWithTenant(role: 'owner');
|
|
||||||
seedEntitlementReviewPackSnapshot($tenant);
|
|
||||||
setReviewPackCommercialLifecycleState($tenant, $state);
|
|
||||||
|
|
||||||
$pack = app(ReviewPackService::class)->generate($tenant, $user);
|
|
||||||
|
|
||||||
expect($pack)->toBeInstanceOf(ReviewPack::class)
|
|
||||||
->and($pack->operation_run_id)->not->toBeNull()
|
|
||||||
->and($pack->status)->toBe(\App\Support\ReviewPackStatus::Queued->value);
|
|
||||||
})->with([
|
|
||||||
'trial' => [WorkspaceCommercialLifecycleResolver::STATE_TRIAL],
|
|
||||||
'active paid' => [WorkspaceCommercialLifecycleResolver::STATE_ACTIVE_PAID],
|
|
||||||
]);
|
|
||||||
|
|
||||||
it('warns but allows review pack generation in grace', function (): void {
|
|
||||||
[$user, $tenant] = createUserWithTenant(role: 'owner');
|
|
||||||
seedEntitlementReviewPackSnapshot($tenant);
|
|
||||||
setReviewPackCommercialLifecycleState($tenant, WorkspaceCommercialLifecycleResolver::STATE_GRACE, 'Grace period');
|
|
||||||
|
|
||||||
$decision = app(ReviewPackService::class)->reviewPackGenerationDecisionForTenant($tenant);
|
|
||||||
|
|
||||||
expect($decision)
|
|
||||||
->toMatchArray([
|
|
||||||
'is_blocked' => false,
|
|
||||||
'is_warning' => true,
|
|
||||||
'outcome' => WorkspaceCommercialLifecycleResolver::OUTCOME_WARN,
|
|
||||||
])
|
|
||||||
->and($decision['warning_reason'])->toContain('grace');
|
|
||||||
|
|
||||||
session()->put(WorkspaceContext::SESSION_KEY, (int) $tenant->workspace_id);
|
|
||||||
setTenantPanelContext($tenant);
|
|
||||||
|
|
||||||
Livewire::actingAs($user)
|
|
||||||
->test(TenantReviewPackCard::class, ['record' => $tenant])
|
|
||||||
->assertSee('Workspace is in grace. Review-pack starts remain available');
|
|
||||||
|
|
||||||
$pack = app(ReviewPackService::class)->generate($tenant, $user);
|
|
||||||
|
|
||||||
expect($pack)->toBeInstanceOf(ReviewPack::class)
|
|
||||||
->and(OperationRun::query()
|
|
||||||
->where('tenant_id', (int) $tenant->getKey())
|
|
||||||
->where('type', OperationRunType::ReviewPackGenerate->value)
|
|
||||||
->exists())->toBeTrue();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('blocks suspended read-only review pack generation before creating a review pack or operation run and sends no run notifications', function (): void {
|
|
||||||
Notification::fake();
|
|
||||||
|
|
||||||
[$user, $tenant] = createUserWithTenant(role: 'owner');
|
|
||||||
seedEntitlementReviewPackSnapshot($tenant);
|
|
||||||
setReviewPackCommercialLifecycleState($tenant, WorkspaceCommercialLifecycleResolver::STATE_SUSPENDED_READ_ONLY, 'Suspension');
|
|
||||||
$initialRunCount = OperationRun::query()
|
|
||||||
->where('tenant_id', (int) $tenant->getKey())
|
|
||||||
->where('type', OperationRunType::ReviewPackGenerate->value)
|
|
||||||
->count();
|
|
||||||
|
|
||||||
expect(fn (): ReviewPack => app(ReviewPackService::class)->generate($tenant, $user))
|
|
||||||
->toThrow(WorkspaceEntitlementBlockedException::class, 'suspended / read-only');
|
|
||||||
|
|
||||||
expect(ReviewPack::query()->count())->toBe(0)
|
|
||||||
->and(OperationRun::query()
|
|
||||||
->where('tenant_id', (int) $tenant->getKey())
|
|
||||||
->where('type', OperationRunType::ReviewPackGenerate->value)
|
|
||||||
->count())->toBe($initialRunCount);
|
|
||||||
|
|
||||||
Notification::assertNothingSent();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('does not alter already queued review-pack work when a workspace is suspended later', function (): void {
|
|
||||||
[$user, $tenant] = createUserWithTenant(role: 'owner');
|
|
||||||
seedEntitlementReviewPackSnapshot($tenant);
|
|
||||||
|
|
||||||
$pack = app(ReviewPackService::class)->generate($tenant, $user);
|
|
||||||
$initialStatus = (string) $pack->fresh()?->status;
|
|
||||||
setReviewPackCommercialLifecycleState($tenant, WorkspaceCommercialLifecycleResolver::STATE_SUSPENDED_READ_ONLY, 'Later suspension');
|
|
||||||
|
|
||||||
expect($pack->fresh()?->status)->toBe($initialStatus)
|
|
||||||
->and(OperationRun::query()
|
|
||||||
->whereKey((int) $pack->operation_run_id)
|
|
||||||
->exists())->toBeTrue();
|
|
||||||
});
|
|
||||||
@ -3,23 +3,18 @@
|
|||||||
declare(strict_types=1);
|
declare(strict_types=1);
|
||||||
|
|
||||||
use App\Exceptions\ReviewPackEvidenceResolutionException;
|
use App\Exceptions\ReviewPackEvidenceResolutionException;
|
||||||
use App\Exceptions\Entitlements\WorkspaceEntitlementBlockedException;
|
|
||||||
use App\Filament\Widgets\Tenant\TenantReviewPackCard;
|
use App\Filament\Widgets\Tenant\TenantReviewPackCard;
|
||||||
use App\Jobs\GenerateReviewPackJob;
|
use App\Jobs\GenerateReviewPackJob;
|
||||||
use App\Models\EvidenceSnapshot;
|
use App\Models\EvidenceSnapshot;
|
||||||
use App\Models\Finding;
|
use App\Models\Finding;
|
||||||
use App\Models\OperationRun;
|
use App\Models\OperationRun;
|
||||||
use App\Models\PlatformUser;
|
|
||||||
use App\Models\ReviewPack;
|
use App\Models\ReviewPack;
|
||||||
use App\Models\StoredReport;
|
use App\Models\StoredReport;
|
||||||
use App\Models\Tenant;
|
use App\Models\Tenant;
|
||||||
use App\Notifications\OperationRunCompleted;
|
use App\Notifications\OperationRunCompleted;
|
||||||
use App\Notifications\OperationRunQueued;
|
use App\Notifications\OperationRunQueued;
|
||||||
use App\Services\Entitlements\WorkspaceCommercialLifecycleResolver;
|
|
||||||
use App\Services\Evidence\EvidenceSnapshotService;
|
use App\Services\Evidence\EvidenceSnapshotService;
|
||||||
use App\Services\ReviewPackService;
|
use App\Services\ReviewPackService;
|
||||||
use App\Services\Settings\SettingsWriter;
|
|
||||||
use App\Support\Auth\PlatformCapabilities;
|
|
||||||
use App\Support\Evidence\EvidenceSnapshotStatus;
|
use App\Support\Evidence\EvidenceSnapshotStatus;
|
||||||
use App\Support\OperationRunOutcome;
|
use App\Support\OperationRunOutcome;
|
||||||
use App\Support\OperationRunStatus;
|
use App\Support\OperationRunStatus;
|
||||||
@ -162,23 +157,6 @@ function createEvidenceSnapshotForReviewPack(Tenant $tenant): EvidenceSnapshot
|
|||||||
return $snapshot->load('items');
|
return $snapshot->load('items');
|
||||||
}
|
}
|
||||||
|
|
||||||
function suspendReviewPackGenerationWorkspaceForGenerationTest(Tenant $tenant): void
|
|
||||||
{
|
|
||||||
app(SettingsWriter::class)->updateWorkspaceCommercialLifecycle(
|
|
||||||
actor: PlatformUser::factory()->create([
|
|
||||||
'capabilities' => [
|
|
||||||
PlatformCapabilities::ACCESS_SYSTEM_PANEL,
|
|
||||||
PlatformCapabilities::DIRECTORY_VIEW,
|
|
||||||
PlatformCapabilities::COMMERCIAL_LIFECYCLE_MANAGE,
|
|
||||||
],
|
|
||||||
'is_active' => true,
|
|
||||||
]),
|
|
||||||
workspace: $tenant->workspace,
|
|
||||||
state: WorkspaceCommercialLifecycleResolver::STATE_SUSPENDED_READ_ONLY,
|
|
||||||
reason: 'Generation notification boundary test',
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── Happy Path ──────────────────────────────────────────────
|
// ─── Happy Path ──────────────────────────────────────────────
|
||||||
|
|
||||||
it('generates a review pack end-to-end (happy path)', function (): void {
|
it('generates a review pack end-to-end (happy path)', function (): void {
|
||||||
@ -232,22 +210,6 @@ function suspendReviewPackGenerationWorkspaceForGenerationTest(Tenant $tenant):
|
|||||||
Notification::assertSentTo($user, OperationRunCompleted::class);
|
Notification::assertSentTo($user, OperationRunCompleted::class);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('does not send queued or terminal run notifications when suspended read-only blocks generation', function (): void {
|
|
||||||
[$user, $tenant] = createUserWithTenant();
|
|
||||||
|
|
||||||
seedTenantWithData($tenant);
|
|
||||||
createEvidenceSnapshotForReviewPack($tenant);
|
|
||||||
suspendReviewPackGenerationWorkspaceForGenerationTest($tenant);
|
|
||||||
|
|
||||||
Notification::fake();
|
|
||||||
|
|
||||||
expect(fn (): ReviewPack => app(ReviewPackService::class)->generate($tenant, $user))
|
|
||||||
->toThrow(WorkspaceEntitlementBlockedException::class, 'suspended / read-only');
|
|
||||||
|
|
||||||
Notification::assertNotSentTo($user, OperationRunQueued::class);
|
|
||||||
Notification::assertNotSentTo($user, OperationRunCompleted::class);
|
|
||||||
});
|
|
||||||
|
|
||||||
// ─── Failure Path ──────────────────────────────────────────────
|
// ─── Failure Path ──────────────────────────────────────────────
|
||||||
|
|
||||||
it('marks pack as failed when generation throws an exception', function (): void {
|
it('marks pack as failed when generation throws an exception', function (): void {
|
||||||
|
|||||||
@ -9,13 +9,11 @@
|
|||||||
use App\Services\ReviewPackService;
|
use App\Services\ReviewPackService;
|
||||||
use App\Support\Auth\UiTooltips;
|
use App\Support\Auth\UiTooltips;
|
||||||
use App\Support\ReviewPackStatus;
|
use App\Support\ReviewPackStatus;
|
||||||
use Filament\Actions\Action;
|
|
||||||
use Filament\Facades\Filament;
|
use Filament\Facades\Filament;
|
||||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||||
use Illuminate\Support\Facades\Storage;
|
use Illuminate\Support\Facades\Storage;
|
||||||
use Illuminate\Support\Facades\URL;
|
use Illuminate\Support\Facades\URL;
|
||||||
use Livewire\Livewire;
|
use Livewire\Livewire;
|
||||||
use Livewire\Features\SupportTesting\Testable;
|
|
||||||
|
|
||||||
uses(RefreshDatabase::class);
|
uses(RefreshDatabase::class);
|
||||||
|
|
||||||
@ -23,17 +21,6 @@
|
|||||||
Storage::fake('exports');
|
Storage::fake('exports');
|
||||||
});
|
});
|
||||||
|
|
||||||
function getReviewPackRbacEmptyStateAction(Testable $component, string $name): ?Action
|
|
||||||
{
|
|
||||||
foreach ($component->instance()->getTable()->getEmptyStateActions() as $action) {
|
|
||||||
if ($action instanceof Action && $action->getName() === $name) {
|
|
||||||
return $action;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── Non-Member Access ───────────────────────────────────────
|
// ─── Non-Member Access ───────────────────────────────────────
|
||||||
|
|
||||||
it('returns 404 for non-member on list page', function (): void {
|
it('returns 404 for non-member on list page', function (): void {
|
||||||
@ -77,9 +64,11 @@ function getReviewPackRbacEmptyStateAction(Testable $component, string $name): ?
|
|||||||
'file_disk' => 'exports',
|
'file_disk' => 'exports',
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
// Note: download route uses signed middleware, not tenant-scoped RBAC.
|
||||||
|
// Any user with a valid signature can download. This is by design.
|
||||||
$signedUrl = app(ReviewPackService::class)->generateDownloadUrl($pack);
|
$signedUrl = app(ReviewPackService::class)->generateDownloadUrl($pack);
|
||||||
|
|
||||||
$this->actingAs($user)->get($signedUrl)->assertNotFound();
|
$this->actingAs($user)->get($signedUrl)->assertOk();
|
||||||
});
|
});
|
||||||
|
|
||||||
// ─── REVIEW_PACK_VIEW Member ────────────────────────────────
|
// ─── REVIEW_PACK_VIEW Member ────────────────────────────────
|
||||||
@ -135,15 +124,11 @@ function getReviewPackRbacEmptyStateAction(Testable $component, string $name): ?
|
|||||||
$tenant->makeCurrent();
|
$tenant->makeCurrent();
|
||||||
Filament::setTenant($tenant, true);
|
Filament::setTenant($tenant, true);
|
||||||
|
|
||||||
$component = Livewire::actingAs($user)
|
Livewire::actingAs($user)
|
||||||
->test(ListReviewPacks::class)
|
->test(ListReviewPacks::class)
|
||||||
->assertTableEmptyStateActionsExistInOrder(['generate_first']);
|
->assertActionVisible('generate_pack')
|
||||||
|
->assertActionDisabled('generate_pack')
|
||||||
$emptyStateAction = getReviewPackRbacEmptyStateAction($component, 'generate_first');
|
->assertActionExists('generate_pack', fn ($action): bool => $action->getTooltip() === UiTooltips::insufficientPermission());
|
||||||
|
|
||||||
expect($emptyStateAction)->not->toBeNull()
|
|
||||||
->and($emptyStateAction?->isDisabled())->toBeTrue()
|
|
||||||
->and($emptyStateAction?->getTooltip())->toBe(UiTooltips::insufficientPermission());
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// ─── REVIEW_PACK_MANAGE Member ──────────────────────────────
|
// ─── REVIEW_PACK_MANAGE Member ──────────────────────────────
|
||||||
@ -152,12 +137,6 @@ function getReviewPackRbacEmptyStateAction(Testable $component, string $name): ?
|
|||||||
$tenant = Tenant::factory()->create();
|
$tenant = Tenant::factory()->create();
|
||||||
[$user, $tenant] = createUserWithTenant(tenant: $tenant, role: 'owner');
|
[$user, $tenant] = createUserWithTenant(tenant: $tenant, role: 'owner');
|
||||||
|
|
||||||
ReviewPack::factory()->ready()->create([
|
|
||||||
'tenant_id' => (int) $tenant->getKey(),
|
|
||||||
'workspace_id' => (int) $tenant->workspace_id,
|
|
||||||
'initiated_by_user_id' => (int) $user->getKey(),
|
|
||||||
]);
|
|
||||||
|
|
||||||
$tenant->makeCurrent();
|
$tenant->makeCurrent();
|
||||||
Filament::setTenant($tenant, true);
|
Filament::setTenant($tenant, true);
|
||||||
|
|
||||||
|
|||||||
@ -13,19 +13,16 @@
|
|||||||
use App\Models\Tenant;
|
use App\Models\Tenant;
|
||||||
use App\Services\Evidence\EvidenceSnapshotService;
|
use App\Services\Evidence\EvidenceSnapshotService;
|
||||||
use App\Services\ReviewPackService;
|
use App\Services\ReviewPackService;
|
||||||
use App\Services\Settings\SettingsWriter;
|
|
||||||
use App\Support\Auth\UiTooltips;
|
use App\Support\Auth\UiTooltips;
|
||||||
use App\Support\Evidence\EvidenceSnapshotStatus;
|
use App\Support\Evidence\EvidenceSnapshotStatus;
|
||||||
use App\Support\OperationRunLinks;
|
use App\Support\OperationRunLinks;
|
||||||
use App\Support\ReviewPackStatus;
|
use App\Support\ReviewPackStatus;
|
||||||
use Filament\Actions\Action;
|
|
||||||
use Filament\Actions\ActionGroup;
|
use Filament\Actions\ActionGroup;
|
||||||
use Filament\Facades\Filament;
|
use Filament\Facades\Filament;
|
||||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||||
use Illuminate\Support\Facades\Queue;
|
use Illuminate\Support\Facades\Queue;
|
||||||
use Illuminate\Support\Facades\Storage;
|
use Illuminate\Support\Facades\Storage;
|
||||||
use Livewire\Livewire;
|
use Livewire\Livewire;
|
||||||
use Livewire\Features\SupportTesting\Testable;
|
|
||||||
use Tests\Feature\Concerns\BuildsGovernanceArtifactTruthFixtures;
|
use Tests\Feature\Concerns\BuildsGovernanceArtifactTruthFixtures;
|
||||||
|
|
||||||
uses(RefreshDatabase::class, BuildsGovernanceArtifactTruthFixtures::class);
|
uses(RefreshDatabase::class, BuildsGovernanceArtifactTruthFixtures::class);
|
||||||
@ -34,31 +31,6 @@
|
|||||||
Storage::fake('exports');
|
Storage::fake('exports');
|
||||||
});
|
});
|
||||||
|
|
||||||
function getReviewPackEmptyStateAction(Testable $component, string $name): ?Action
|
|
||||||
{
|
|
||||||
foreach ($component->instance()->getTable()->getEmptyStateActions() as $action) {
|
|
||||||
if ($action instanceof Action && $action->getName() === $name) {
|
|
||||||
return $action;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
function getReviewPackHeaderAction(Testable $component, string $name): ?Action
|
|
||||||
{
|
|
||||||
$instance = $component->instance();
|
|
||||||
$instance->cacheInteractsWithHeaderActions();
|
|
||||||
|
|
||||||
foreach ($instance->getCachedHeaderActions() as $action) {
|
|
||||||
if ($action instanceof Action && $action->getName() === $name) {
|
|
||||||
return $action;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
function seedReviewPackEvidence(Tenant $tenant): EvidenceSnapshot
|
function seedReviewPackEvidence(Tenant $tenant): EvidenceSnapshot
|
||||||
{
|
{
|
||||||
StoredReport::factory()->create([
|
StoredReport::factory()->create([
|
||||||
@ -158,7 +130,8 @@ function seedReviewPackEvidence(Tenant $tenant): EvidenceSnapshot
|
|||||||
'tenant_id' => (int) $otherTenant->getKey(),
|
'tenant_id' => (int) $otherTenant->getKey(),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
setTenantPanelContext($tenant);
|
$tenant->makeCurrent();
|
||||||
|
Filament::setTenant($tenant, true);
|
||||||
|
|
||||||
Livewire::actingAs($user)
|
Livewire::actingAs($user)
|
||||||
->test(ListReviewPacks::class)
|
->test(ListReviewPacks::class)
|
||||||
@ -177,112 +150,32 @@ function seedReviewPackEvidence(Tenant $tenant): EvidenceSnapshot
|
|||||||
->assertSee('No review packs yet');
|
->assertSee('No review packs yet');
|
||||||
});
|
});
|
||||||
|
|
||||||
// ─── List Page Start CTA Placement ───────────────────────────
|
// ─── List Page Header Action ─────────────────────────────────
|
||||||
|
|
||||||
it('shows generate only in the empty state when no review packs exist', function (): void {
|
it('shows the generate_pack header action for a MANAGE user', function (): void {
|
||||||
$tenant = Tenant::factory()->create();
|
$tenant = Tenant::factory()->create();
|
||||||
[$user, $tenant] = createUserWithTenant(tenant: $tenant, role: 'owner');
|
[$user, $tenant] = createUserWithTenant(tenant: $tenant, role: 'owner');
|
||||||
|
|
||||||
$tenant->makeCurrent();
|
$tenant->makeCurrent();
|
||||||
Filament::setTenant($tenant, true);
|
Filament::setTenant($tenant, true);
|
||||||
|
|
||||||
$component = Livewire::actingAs($user)
|
|
||||||
->test(ListReviewPacks::class)
|
|
||||||
->assertTableEmptyStateActionsExistInOrder(['generate_first']);
|
|
||||||
|
|
||||||
$emptyStateAction = getReviewPackEmptyStateAction($component, 'generate_first');
|
|
||||||
$headerAction = getReviewPackHeaderAction($component, 'generate_pack');
|
|
||||||
|
|
||||||
expect($emptyStateAction)->not->toBeNull()
|
|
||||||
->and($emptyStateAction?->getLabel())->toBe('Generate first pack')
|
|
||||||
->and($headerAction)->not->toBeNull()
|
|
||||||
->and($headerAction?->isVisible())->toBeFalse();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('shows generate in the header once review packs exist', function (): void {
|
|
||||||
$tenant = Tenant::factory()->create();
|
|
||||||
[$user, $tenant] = createUserWithTenant(tenant: $tenant, role: 'owner');
|
|
||||||
|
|
||||||
ReviewPack::factory()->ready()->create([
|
|
||||||
'tenant_id' => (int) $tenant->getKey(),
|
|
||||||
'workspace_id' => (int) $tenant->workspace_id,
|
|
||||||
'initiated_by_user_id' => (int) $user->getKey(),
|
|
||||||
]);
|
|
||||||
|
|
||||||
$tenant->makeCurrent();
|
|
||||||
Filament::setTenant($tenant, true);
|
|
||||||
|
|
||||||
Livewire::actingAs($user)
|
Livewire::actingAs($user)
|
||||||
->test(ListReviewPacks::class)
|
->test(ListReviewPacks::class)
|
||||||
->assertActionVisible('generate_pack');
|
->assertActionVisible('generate_pack');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('disables the generate_first action for a readonly user in the empty state', function (): void {
|
it('disables the generate_pack action for a readonly user', function (): void {
|
||||||
$tenant = Tenant::factory()->create();
|
$tenant = Tenant::factory()->create();
|
||||||
[$user, $tenant] = createUserWithTenant(tenant: $tenant, role: 'readonly');
|
[$user, $tenant] = createUserWithTenant(tenant: $tenant, role: 'readonly');
|
||||||
|
|
||||||
$tenant->makeCurrent();
|
$tenant->makeCurrent();
|
||||||
Filament::setTenant($tenant, true);
|
Filament::setTenant($tenant, true);
|
||||||
|
|
||||||
$component = Livewire::actingAs($user)
|
|
||||||
->test(ListReviewPacks::class)
|
|
||||||
->assertTableEmptyStateActionsExistInOrder(['generate_first']);
|
|
||||||
|
|
||||||
$emptyStateAction = getReviewPackEmptyStateAction($component, 'generate_first');
|
|
||||||
|
|
||||||
expect($emptyStateAction)->not->toBeNull()
|
|
||||||
->and($emptyStateAction?->isDisabled())->toBeTrue()
|
|
||||||
->and($emptyStateAction?->getTooltip())->toBe(UiTooltips::insufficientPermission());
|
|
||||||
});
|
|
||||||
|
|
||||||
it('disables review pack generation actions when the workspace entitlement blocks them', function (): void {
|
|
||||||
$tenant = Tenant::factory()->create();
|
|
||||||
[$user, $tenant] = createUserWithTenant(tenant: $tenant, role: 'owner');
|
|
||||||
|
|
||||||
app(SettingsWriter::class)->updateWorkspaceSetting(
|
|
||||||
actor: $user,
|
|
||||||
workspace: $tenant->workspace,
|
|
||||||
domain: 'entitlements',
|
|
||||||
key: 'review_pack_generation_override_value',
|
|
||||||
value: false,
|
|
||||||
);
|
|
||||||
|
|
||||||
app(SettingsWriter::class)->updateWorkspaceSetting(
|
|
||||||
actor: $user,
|
|
||||||
workspace: $tenant->workspace,
|
|
||||||
domain: 'entitlements',
|
|
||||||
key: 'review_pack_generation_override_reason',
|
|
||||||
value: 'Workspace is temporarily limited to manual reporting only',
|
|
||||||
);
|
|
||||||
|
|
||||||
$tenant->makeCurrent();
|
|
||||||
Filament::setTenant($tenant, true);
|
|
||||||
|
|
||||||
expect(ReviewPackResource::reviewPackGenerationActionTooltip($tenant))
|
|
||||||
->toBe('Review pack generation is disabled by workspace override. Reason: Workspace is temporarily limited to manual reporting only');
|
|
||||||
|
|
||||||
$listPage = Livewire::actingAs($user)
|
|
||||||
->test(ListReviewPacks::class)
|
|
||||||
->assertTableEmptyStateActionsExistInOrder(['generate_first']);
|
|
||||||
|
|
||||||
$emptyStateAction = getReviewPackEmptyStateAction($listPage, 'generate_first');
|
|
||||||
$headerAction = getReviewPackHeaderAction($listPage, 'generate_pack');
|
|
||||||
|
|
||||||
expect($emptyStateAction)->not->toBeNull()
|
|
||||||
->and($emptyStateAction?->isDisabled())->toBeTrue()
|
|
||||||
->and($headerAction)->not->toBeNull()
|
|
||||||
->and($headerAction?->isVisible())->toBeFalse();
|
|
||||||
|
|
||||||
$pack = ReviewPack::factory()->ready()->create([
|
|
||||||
'tenant_id' => (int) $tenant->getKey(),
|
|
||||||
'workspace_id' => (int) $tenant->workspace_id,
|
|
||||||
'initiated_by_user_id' => (int) $user->getKey(),
|
|
||||||
]);
|
|
||||||
|
|
||||||
Livewire::actingAs($user)
|
Livewire::actingAs($user)
|
||||||
->test(ViewReviewPack::class, ['record' => $pack->getKey()])
|
->test(ListReviewPacks::class)
|
||||||
->assertActionVisible('regenerate')
|
->assertActionVisible('generate_pack')
|
||||||
->assertActionDisabled('regenerate');
|
->assertActionDisabled('generate_pack')
|
||||||
|
->assertActionExists('generate_pack', fn ($action): bool => $action->getTooltip() === UiTooltips::insufficientPermission());
|
||||||
});
|
});
|
||||||
|
|
||||||
it('reuses an existing ready pack instead of starting a new run', function (): void {
|
it('reuses an existing ready pack instead of starting a new run', function (): void {
|
||||||
@ -332,12 +225,6 @@ function seedReviewPackEvidence(Tenant $tenant): EvidenceSnapshot
|
|||||||
$tenant = Tenant::factory()->create();
|
$tenant = Tenant::factory()->create();
|
||||||
[$user, $tenant] = createUserWithTenant(tenant: $tenant, role: 'owner');
|
[$user, $tenant] = createUserWithTenant(tenant: $tenant, role: 'owner');
|
||||||
|
|
||||||
ReviewPack::factory()->failed()->create([
|
|
||||||
'tenant_id' => (int) $tenant->getKey(),
|
|
||||||
'workspace_id' => (int) $tenant->workspace_id,
|
|
||||||
'initiated_by_user_id' => (int) $user->getKey(),
|
|
||||||
]);
|
|
||||||
|
|
||||||
$tenant->makeCurrent();
|
$tenant->makeCurrent();
|
||||||
Filament::setTenant($tenant, true);
|
Filament::setTenant($tenant, true);
|
||||||
|
|
||||||
@ -349,7 +236,7 @@ function seedReviewPackEvidence(Tenant $tenant): EvidenceSnapshot
|
|||||||
])
|
])
|
||||||
->assertNotified();
|
->assertNotified();
|
||||||
|
|
||||||
expect(ReviewPack::query()->count())->toBe(1);
|
expect(ReviewPack::query()->count())->toBe(0);
|
||||||
Queue::assertNothingPushed();
|
Queue::assertNothingPushed();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@ -11,9 +11,6 @@
|
|||||||
use App\Models\Tenant;
|
use App\Models\Tenant;
|
||||||
use App\Services\Evidence\EvidenceSnapshotService;
|
use App\Services\Evidence\EvidenceSnapshotService;
|
||||||
use App\Support\Evidence\EvidenceSnapshotStatus;
|
use App\Support\Evidence\EvidenceSnapshotStatus;
|
||||||
use App\Support\OperationRunOutcome;
|
|
||||||
use App\Support\OperationRunStatus;
|
|
||||||
use App\Support\OperationRunType;
|
|
||||||
use App\Support\Providers\ProviderReasonCodes;
|
use App\Support\Providers\ProviderReasonCodes;
|
||||||
use App\Support\ReasonTranslation\ReasonPresenter;
|
use App\Support\ReasonTranslation\ReasonPresenter;
|
||||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||||
@ -51,13 +48,7 @@ function seedWidgetReviewPackSnapshot(Tenant $tenant): EvidenceSnapshot
|
|||||||
'finding_type' => Finding::FINDING_TYPE_DRIFT,
|
'finding_type' => Finding::FINDING_TYPE_DRIFT,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
OperationRun::factory()->forTenant($tenant)->create([
|
OperationRun::factory()->forTenant($tenant)->create();
|
||||||
'type' => OperationRunType::TenantReviewCompose->value,
|
|
||||||
'status' => OperationRunStatus::Completed->value,
|
|
||||||
'outcome' => OperationRunOutcome::Succeeded->value,
|
|
||||||
'started_at' => now()->subMinute(),
|
|
||||||
'completed_at' => now(),
|
|
||||||
]);
|
|
||||||
|
|
||||||
/** @var EvidenceSnapshotService $service */
|
/** @var EvidenceSnapshotService $service */
|
||||||
$service = app(EvidenceSnapshotService::class);
|
$service = app(EvidenceSnapshotService::class);
|
||||||
|
|||||||
@ -1,66 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
use App\Filament\Pages\Reviews\CustomerReviewWorkspace;
|
|
||||||
use App\Models\Tenant;
|
|
||||||
use App\Models\User;
|
|
||||||
use App\Models\Workspace;
|
|
||||||
use App\Models\WorkspaceMembership;
|
|
||||||
use App\Support\Workspaces\WorkspaceContext;
|
|
||||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
|
||||||
|
|
||||||
uses(RefreshDatabase::class);
|
|
||||||
|
|
||||||
it('returns 404 for users outside the active workspace on the customer review workspace', function (): void {
|
|
||||||
$workspace = Workspace::factory()->create();
|
|
||||||
$user = User::factory()->create();
|
|
||||||
|
|
||||||
$this->actingAs($user)
|
|
||||||
->withSession([WorkspaceContext::SESSION_KEY => (int) $workspace->getKey()])
|
|
||||||
->get(CustomerReviewWorkspace::getUrl(panel: 'admin'))
|
|
||||||
->assertNotFound();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('returns 404 for workspace members that have no tenant review visibility in the active workspace', function (): void {
|
|
||||||
$workspace = Workspace::factory()->create();
|
|
||||||
$user = User::factory()->create();
|
|
||||||
|
|
||||||
WorkspaceMembership::factory()->create([
|
|
||||||
'workspace_id' => (int) $workspace->getKey(),
|
|
||||||
'user_id' => (int) $user->getKey(),
|
|
||||||
'role' => 'owner',
|
|
||||||
]);
|
|
||||||
|
|
||||||
$this->actingAs($user)
|
|
||||||
->withSession([WorkspaceContext::SESSION_KEY => (int) $workspace->getKey()])
|
|
||||||
->get(CustomerReviewWorkspace::getUrl(panel: 'admin'))
|
|
||||||
->assertNotFound();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('allows entitled workspace members to access the customer review workspace', function (): void {
|
|
||||||
$tenant = Tenant::factory()->create();
|
|
||||||
[$user, $tenant] = createUserWithTenant(tenant: $tenant, role: 'readonly');
|
|
||||||
|
|
||||||
$this->actingAs($user)
|
|
||||||
->withSession([WorkspaceContext::SESSION_KEY => (int) $tenant->workspace_id])
|
|
||||||
->get(CustomerReviewWorkspace::getUrl(panel: 'admin'))
|
|
||||||
->assertOk();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('returns 404 for explicit out-of-scope tenant targeting on the customer review workspace', function (): void {
|
|
||||||
$tenantAllowed = Tenant::factory()->create(['name' => 'Allowed Tenant']);
|
|
||||||
[$user, $tenantAllowed] = createUserWithTenant(tenant: $tenantAllowed, role: 'readonly');
|
|
||||||
|
|
||||||
$tenantDenied = Tenant::factory()->create([
|
|
||||||
'workspace_id' => (int) $tenantAllowed->workspace_id,
|
|
||||||
'name' => 'Denied Tenant',
|
|
||||||
]);
|
|
||||||
$otherOwner = User::factory()->create();
|
|
||||||
createUserWithTenant(tenant: $tenantDenied, user: $otherOwner, role: 'owner');
|
|
||||||
|
|
||||||
$this->actingAs($user)
|
|
||||||
->withSession([WorkspaceContext::SESSION_KEY => (int) $tenantAllowed->workspace_id])
|
|
||||||
->get(CustomerReviewWorkspace::getUrl(panel: 'admin').'?tenant='.(string) $tenantDenied->getKey())
|
|
||||||
->assertNotFound();
|
|
||||||
});
|
|
||||||
@ -1,160 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
use App\Filament\Pages\Reviews\CustomerReviewWorkspace;
|
|
||||||
use App\Filament\Resources\EvidenceSnapshotResource;
|
|
||||||
use App\Filament\Resources\ReviewPackResource;
|
|
||||||
use App\Filament\Resources\TenantReviewResource\Pages\ViewTenantReview;
|
|
||||||
use App\Filament\Resources\TenantReviewResource;
|
|
||||||
use App\Filament\Widgets\Tenant\TenantReviewPackCard;
|
|
||||||
use App\Models\AuditLog;
|
|
||||||
use App\Models\EvidenceSnapshot;
|
|
||||||
use App\Models\ReviewPack;
|
|
||||||
use App\Models\Tenant;
|
|
||||||
use App\Models\TenantReview;
|
|
||||||
use App\Support\Audit\AuditActionId;
|
|
||||||
use App\Support\Evidence\EvidenceSnapshotStatus;
|
|
||||||
use App\Support\TenantReviewStatus;
|
|
||||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
|
||||||
use Illuminate\Support\Facades\Storage;
|
|
||||||
use Livewire\Livewire;
|
|
||||||
|
|
||||||
uses(RefreshDatabase::class);
|
|
||||||
|
|
||||||
beforeEach(function (): void {
|
|
||||||
Storage::fake('exports');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('renders a customer workspace link from tenant review detail context', function (): void {
|
|
||||||
$tenant = Tenant::factory()->create();
|
|
||||||
[$user, $tenant] = createUserWithTenant(tenant: $tenant, role: 'owner');
|
|
||||||
$snapshot = seedTenantReviewEvidence($tenant);
|
|
||||||
|
|
||||||
$review = composeTenantReviewForTest($tenant, $user, $snapshot);
|
|
||||||
$review->forceFill([
|
|
||||||
'status' => TenantReviewStatus::Published->value,
|
|
||||||
'published_at' => now(),
|
|
||||||
'published_by_user_id' => (int) $user->getKey(),
|
|
||||||
])->save();
|
|
||||||
|
|
||||||
$this->actingAs($user)
|
|
||||||
->get(TenantReviewResource::tenantScopedUrl('view', ['record' => $review], $tenant))
|
|
||||||
->assertOk()
|
|
||||||
->assertSee(CustomerReviewWorkspace::tenantPrefilterUrl($tenant), false);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('adds a customer workspace entry to evidence snapshot related context', function (): void {
|
|
||||||
$tenant = Tenant::factory()->create();
|
|
||||||
|
|
||||||
$snapshot = EvidenceSnapshot::query()->create([
|
|
||||||
'tenant_id' => (int) $tenant->getKey(),
|
|
||||||
'workspace_id' => (int) $tenant->workspace_id,
|
|
||||||
'status' => EvidenceSnapshotStatus::Active->value,
|
|
||||||
'summary' => [],
|
|
||||||
'generated_at' => now(),
|
|
||||||
]);
|
|
||||||
|
|
||||||
$entry = collect(EvidenceSnapshotResource::relatedContextEntries($snapshot))
|
|
||||||
->firstWhere('key', 'customer_review_workspace');
|
|
||||||
|
|
||||||
expect($entry)->not->toBeNull()
|
|
||||||
->and($entry['targetUrl'] ?? null)->toBe(CustomerReviewWorkspace::tenantPrefilterUrl($tenant));
|
|
||||||
});
|
|
||||||
|
|
||||||
it('renders a customer workspace link from review pack detail context', function (): void {
|
|
||||||
$tenant = Tenant::factory()->create();
|
|
||||||
[$user, $tenant] = createUserWithTenant(tenant: $tenant, role: 'owner');
|
|
||||||
$snapshot = seedTenantReviewEvidence($tenant);
|
|
||||||
|
|
||||||
$review = composeTenantReviewForTest($tenant, $user, $snapshot);
|
|
||||||
$review->forceFill([
|
|
||||||
'status' => TenantReviewStatus::Published->value,
|
|
||||||
'published_at' => now(),
|
|
||||||
'published_by_user_id' => (int) $user->getKey(),
|
|
||||||
])->save();
|
|
||||||
|
|
||||||
Storage::disk('exports')->put('review-packs/customer-workspace-link.zip', 'PK-test');
|
|
||||||
|
|
||||||
$pack = ReviewPack::factory()->ready()->create([
|
|
||||||
'tenant_id' => (int) $tenant->getKey(),
|
|
||||||
'workspace_id' => (int) $tenant->workspace_id,
|
|
||||||
'tenant_review_id' => (int) $review->getKey(),
|
|
||||||
'evidence_snapshot_id' => (int) $snapshot->getKey(),
|
|
||||||
'initiated_by_user_id' => (int) $user->getKey(),
|
|
||||||
'file_path' => 'review-packs/customer-workspace-link.zip',
|
|
||||||
'file_disk' => 'exports',
|
|
||||||
]);
|
|
||||||
|
|
||||||
$this->actingAs($user)
|
|
||||||
->get(ReviewPackResource::getUrl('view', ['record' => $pack], tenant: $tenant, panel: 'tenant'))
|
|
||||||
->assertOk()
|
|
||||||
->assertSee(CustomerReviewWorkspace::tenantPrefilterUrl($tenant), false);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('renders a customer workspace launch button on the tenant review pack widget', function (): void {
|
|
||||||
$tenant = Tenant::factory()->create();
|
|
||||||
[$user, $tenant] = createUserWithTenant(tenant: $tenant, role: 'owner');
|
|
||||||
$snapshot = seedTenantReviewEvidence($tenant);
|
|
||||||
|
|
||||||
$review = composeTenantReviewForTest($tenant, $user, $snapshot);
|
|
||||||
$review->forceFill([
|
|
||||||
'status' => TenantReviewStatus::Published->value,
|
|
||||||
'published_at' => now(),
|
|
||||||
'published_by_user_id' => (int) $user->getKey(),
|
|
||||||
])->save();
|
|
||||||
|
|
||||||
Storage::disk('exports')->put('review-packs/widget-customer-workspace.zip', 'PK-test');
|
|
||||||
|
|
||||||
ReviewPack::factory()->ready()->create([
|
|
||||||
'tenant_id' => (int) $tenant->getKey(),
|
|
||||||
'workspace_id' => (int) $tenant->workspace_id,
|
|
||||||
'tenant_review_id' => (int) $review->getKey(),
|
|
||||||
'evidence_snapshot_id' => (int) $snapshot->getKey(),
|
|
||||||
'initiated_by_user_id' => (int) $user->getKey(),
|
|
||||||
'file_path' => 'review-packs/widget-customer-workspace.zip',
|
|
||||||
'file_disk' => 'exports',
|
|
||||||
]);
|
|
||||||
|
|
||||||
setTenantPanelContext($tenant);
|
|
||||||
|
|
||||||
Livewire::actingAs($user)
|
|
||||||
->test(TenantReviewPackCard::class, ['record' => $tenant])
|
|
||||||
->assertSee('Customer workspace')
|
|
||||||
->assertSee(CustomerReviewWorkspace::tenantPrefilterUrl($tenant), false);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('keeps the linked tenant review detail read-only for a readonly-capable actor', function (): void {
|
|
||||||
$tenant = Tenant::factory()->create();
|
|
||||||
[$user, $tenant] = createUserWithTenant(tenant: $tenant, role: 'readonly');
|
|
||||||
$snapshot = seedTenantReviewEvidence($tenant);
|
|
||||||
|
|
||||||
$review = composeTenantReviewForTest($tenant, $user, $snapshot);
|
|
||||||
$review->forceFill([
|
|
||||||
'status' => TenantReviewStatus::Published->value,
|
|
||||||
'published_at' => now(),
|
|
||||||
'published_by_user_id' => (int) $user->getKey(),
|
|
||||||
])->save();
|
|
||||||
|
|
||||||
setTenantPanelContext($tenant);
|
|
||||||
|
|
||||||
Livewire::withQueryParams([CustomerReviewWorkspace::DETAIL_CONTEXT_QUERY_KEY => 1])
|
|
||||||
->actingAs($user)
|
|
||||||
->test(ViewTenantReview::class, ['record' => $review->getKey()])
|
|
||||||
->assertSee('Outcome summary')
|
|
||||||
->assertActionDoesNotExist('publish_review')
|
|
||||||
->assertActionDoesNotExist('refresh_review')
|
|
||||||
->assertActionDoesNotExist('create_next_review')
|
|
||||||
->assertActionDoesNotExist('export_executive_pack')
|
|
||||||
->assertActionHidden('archive_review');
|
|
||||||
|
|
||||||
$audit = AuditLog::query()
|
|
||||||
->where('action', AuditActionId::TenantReviewOpened->value)
|
|
||||||
->latest('id')
|
|
||||||
->first();
|
|
||||||
|
|
||||||
expect($audit)->not->toBeNull()
|
|
||||||
->and($audit?->resource_type)->toBe('tenant_review')
|
|
||||||
->and(data_get($audit?->metadata, 'review_id'))->toBe((int) $review->getKey())
|
|
||||||
->and(data_get($audit?->metadata, 'source_surface'))->toBe('customer_review_workspace');
|
|
||||||
});
|
|
||||||
@ -1,156 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
use App\Filament\Pages\Reviews\CustomerReviewWorkspace;
|
|
||||||
use App\Models\PlatformUser;
|
|
||||||
use App\Models\ReviewPack;
|
|
||||||
use App\Models\Tenant;
|
|
||||||
use App\Services\Entitlements\WorkspaceCommercialLifecycleResolver;
|
|
||||||
use App\Services\Settings\SettingsWriter;
|
|
||||||
use App\Support\Auth\PlatformCapabilities;
|
|
||||||
use App\Support\TenantReviewStatus;
|
|
||||||
use App\Support\Workspaces\WorkspaceContext;
|
|
||||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
|
||||||
use Livewire\Livewire;
|
|
||||||
|
|
||||||
uses(RefreshDatabase::class);
|
|
||||||
|
|
||||||
function suspendCustomerReviewWorkspacePackAccessWorkspace(Tenant $tenant): void
|
|
||||||
{
|
|
||||||
app(SettingsWriter::class)->updateWorkspaceCommercialLifecycle(
|
|
||||||
actor: PlatformUser::factory()->create([
|
|
||||||
'capabilities' => [
|
|
||||||
PlatformCapabilities::ACCESS_SYSTEM_PANEL,
|
|
||||||
PlatformCapabilities::DIRECTORY_VIEW,
|
|
||||||
PlatformCapabilities::COMMERCIAL_LIFECYCLE_MANAGE,
|
|
||||||
],
|
|
||||||
'is_active' => true,
|
|
||||||
]),
|
|
||||||
workspace: $tenant->workspace,
|
|
||||||
state: WorkspaceCommercialLifecycleResolver::STATE_SUSPENDED_READ_ONLY,
|
|
||||||
reason: 'Customer review workspace suspended read-only test',
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
it('shows the ready review-pack action for the latest published review', function (): void {
|
|
||||||
$tenant = Tenant::factory()->create();
|
|
||||||
[$user, $tenant] = createUserWithTenant(tenant: $tenant, role: 'readonly');
|
|
||||||
$snapshot = seedTenantReviewEvidence($tenant);
|
|
||||||
|
|
||||||
$review = composeTenantReviewForTest($tenant, $user, $snapshot);
|
|
||||||
$review->forceFill([
|
|
||||||
'status' => TenantReviewStatus::Published->value,
|
|
||||||
'published_at' => now(),
|
|
||||||
'published_by_user_id' => (int) $user->getKey(),
|
|
||||||
])->save();
|
|
||||||
|
|
||||||
$pack = ReviewPack::factory()->ready()->create([
|
|
||||||
'tenant_id' => (int) $tenant->getKey(),
|
|
||||||
'workspace_id' => (int) $tenant->workspace_id,
|
|
||||||
'tenant_review_id' => (int) $review->getKey(),
|
|
||||||
'evidence_snapshot_id' => (int) $snapshot->getKey(),
|
|
||||||
'initiated_by_user_id' => (int) $user->getKey(),
|
|
||||||
'expires_at' => now()->addDay(),
|
|
||||||
]);
|
|
||||||
|
|
||||||
$review->forceFill([
|
|
||||||
'current_export_review_pack_id' => (int) $pack->getKey(),
|
|
||||||
])->save();
|
|
||||||
|
|
||||||
$this->actingAs($user);
|
|
||||||
setAdminPanelContext();
|
|
||||||
session()->put(WorkspaceContext::SESSION_KEY, (int) $tenant->workspace_id);
|
|
||||||
|
|
||||||
Livewire::actingAs($user)
|
|
||||||
->test(CustomerReviewWorkspace::class)
|
|
||||||
->assertTableActionVisible('open_latest_review', $tenant)
|
|
||||||
->assertTableActionVisible('download_review_pack', $tenant)
|
|
||||||
->assertSee('Available');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('keeps customer review workspace and pack actions visible while suspended read-only', function (): void {
|
|
||||||
$tenant = Tenant::factory()->create();
|
|
||||||
[$user, $tenant] = createUserWithTenant(tenant: $tenant, role: 'readonly');
|
|
||||||
$snapshot = seedTenantReviewEvidence($tenant);
|
|
||||||
|
|
||||||
$review = composeTenantReviewForTest($tenant, $user, $snapshot);
|
|
||||||
$review->forceFill([
|
|
||||||
'status' => TenantReviewStatus::Published->value,
|
|
||||||
'published_at' => now(),
|
|
||||||
'published_by_user_id' => (int) $user->getKey(),
|
|
||||||
])->save();
|
|
||||||
|
|
||||||
$pack = ReviewPack::factory()->ready()->create([
|
|
||||||
'tenant_id' => (int) $tenant->getKey(),
|
|
||||||
'workspace_id' => (int) $tenant->workspace_id,
|
|
||||||
'tenant_review_id' => (int) $review->getKey(),
|
|
||||||
'evidence_snapshot_id' => (int) $snapshot->getKey(),
|
|
||||||
'initiated_by_user_id' => (int) $user->getKey(),
|
|
||||||
'expires_at' => now()->addDay(),
|
|
||||||
]);
|
|
||||||
|
|
||||||
$review->forceFill([
|
|
||||||
'current_export_review_pack_id' => (int) $pack->getKey(),
|
|
||||||
])->save();
|
|
||||||
|
|
||||||
suspendCustomerReviewWorkspacePackAccessWorkspace($tenant);
|
|
||||||
|
|
||||||
$this->actingAs($user);
|
|
||||||
setAdminPanelContext();
|
|
||||||
session()->put(WorkspaceContext::SESSION_KEY, (int) $tenant->workspace_id);
|
|
||||||
|
|
||||||
Livewire::actingAs($user)
|
|
||||||
->test(CustomerReviewWorkspace::class)
|
|
||||||
->assertTableActionVisible('open_latest_review', $tenant)
|
|
||||||
->assertTableActionVisible('download_review_pack', $tenant)
|
|
||||||
->assertSee('Available');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('shows an unavailable pack state and hides the download action when no current review pack exists', function (): void {
|
|
||||||
$tenant = Tenant::factory()->create();
|
|
||||||
[$user, $tenant] = createUserWithTenant(tenant: $tenant, role: 'readonly');
|
|
||||||
$snapshot = seedTenantReviewEvidence($tenant);
|
|
||||||
|
|
||||||
$review = composeTenantReviewForTest($tenant, $user, $snapshot);
|
|
||||||
$review->forceFill([
|
|
||||||
'status' => TenantReviewStatus::Published->value,
|
|
||||||
'published_at' => now(),
|
|
||||||
'published_by_user_id' => (int) $user->getKey(),
|
|
||||||
'current_export_review_pack_id' => null,
|
|
||||||
])->save();
|
|
||||||
|
|
||||||
$this->actingAs($user);
|
|
||||||
setAdminPanelContext();
|
|
||||||
session()->put(WorkspaceContext::SESSION_KEY, (int) $tenant->workspace_id);
|
|
||||||
|
|
||||||
Livewire::actingAs($user)
|
|
||||||
->test(CustomerReviewWorkspace::class)
|
|
||||||
->assertTableActionVisible('open_latest_review', $tenant)
|
|
||||||
->assertTableActionHidden('download_review_pack', $tenant)
|
|
||||||
->assertSee('Unavailable');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('hides review and pack actions for tenants without a published review', function (): void {
|
|
||||||
$tenant = Tenant::factory()->create();
|
|
||||||
[$user, $tenant] = createUserWithTenant(tenant: $tenant, role: 'readonly');
|
|
||||||
$snapshot = seedTenantReviewEvidence($tenant);
|
|
||||||
|
|
||||||
$review = composeTenantReviewForTest($tenant, $user, $snapshot);
|
|
||||||
$review->forceFill([
|
|
||||||
'status' => TenantReviewStatus::Ready->value,
|
|
||||||
'published_at' => null,
|
|
||||||
'published_by_user_id' => null,
|
|
||||||
'current_export_review_pack_id' => null,
|
|
||||||
])->save();
|
|
||||||
|
|
||||||
$this->actingAs($user);
|
|
||||||
setAdminPanelContext();
|
|
||||||
session()->put(WorkspaceContext::SESSION_KEY, (int) $tenant->workspace_id);
|
|
||||||
|
|
||||||
Livewire::actingAs($user)
|
|
||||||
->test(CustomerReviewWorkspace::class)
|
|
||||||
->assertTableActionHidden('open_latest_review', $tenant)
|
|
||||||
->assertTableActionHidden('download_review_pack', $tenant)
|
|
||||||
->assertSee('No published review available yet');
|
|
||||||
});
|
|
||||||
@ -1,222 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
use App\Filament\Pages\Reviews\CustomerReviewWorkspace;
|
|
||||||
use App\Filament\Resources\TenantReviewResource;
|
|
||||||
use App\Models\Tenant;
|
|
||||||
use App\Models\User;
|
|
||||||
use App\Support\TenantReviewStatus;
|
|
||||||
use App\Support\Workspaces\WorkspaceContext;
|
|
||||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
|
||||||
use Livewire\Livewire;
|
|
||||||
|
|
||||||
uses(RefreshDatabase::class);
|
|
||||||
|
|
||||||
it('lists only the latest published review per entitled tenant on the customer review workspace', function (): void {
|
|
||||||
$tenantA = Tenant::factory()->create(['name' => 'Alpha Tenant']);
|
|
||||||
[$user, $tenantA] = createUserWithTenant(tenant: $tenantA, role: 'readonly');
|
|
||||||
|
|
||||||
$tenantB = Tenant::factory()->create([
|
|
||||||
'workspace_id' => (int) $tenantA->workspace_id,
|
|
||||||
'name' => 'Beta Tenant',
|
|
||||||
]);
|
|
||||||
createUserWithTenant(tenant: $tenantB, user: $user, role: 'readonly');
|
|
||||||
|
|
||||||
$tenantDenied = Tenant::factory()->create([
|
|
||||||
'workspace_id' => (int) $tenantA->workspace_id,
|
|
||||||
'name' => 'Denied Tenant',
|
|
||||||
]);
|
|
||||||
$otherOwner = User::factory()->create();
|
|
||||||
createUserWithTenant(tenant: $tenantDenied, user: $otherOwner, role: 'owner');
|
|
||||||
|
|
||||||
$tenantASnapshot = seedTenantReviewEvidence($tenantA);
|
|
||||||
$tenantBSnapshot = seedTenantReviewEvidence($tenantB);
|
|
||||||
$tenantDeniedSnapshot = seedTenantReviewEvidence($tenantDenied);
|
|
||||||
|
|
||||||
$olderPublishedReview = composeTenantReviewForTest($tenantA, $user, $tenantASnapshot);
|
|
||||||
$olderPublishedReview->forceFill([
|
|
||||||
'status' => TenantReviewStatus::Published->value,
|
|
||||||
'generated_at' => now()->subDays(3),
|
|
||||||
'published_at' => now()->subDays(3),
|
|
||||||
'published_by_user_id' => (int) $user->getKey(),
|
|
||||||
])->save();
|
|
||||||
|
|
||||||
$newerInternalReview = $olderPublishedReview->replicate();
|
|
||||||
$newerInternalReview->forceFill([
|
|
||||||
'tenant_id' => (int) $tenantA->getKey(),
|
|
||||||
'workspace_id' => (int) $tenantA->workspace_id,
|
|
||||||
'evidence_snapshot_id' => (int) $tenantASnapshot->getKey(),
|
|
||||||
'status' => TenantReviewStatus::Ready->value,
|
|
||||||
'generated_at' => now()->subDay(),
|
|
||||||
'published_at' => null,
|
|
||||||
'published_by_user_id' => null,
|
|
||||||
])->save();
|
|
||||||
|
|
||||||
$latestPublishedReview = $olderPublishedReview->replicate();
|
|
||||||
$latestPublishedReview->forceFill([
|
|
||||||
'tenant_id' => (int) $tenantA->getKey(),
|
|
||||||
'workspace_id' => (int) $tenantA->workspace_id,
|
|
||||||
'evidence_snapshot_id' => (int) $tenantASnapshot->getKey(),
|
|
||||||
'status' => TenantReviewStatus::Published->value,
|
|
||||||
'generated_at' => now(),
|
|
||||||
'published_at' => now(),
|
|
||||||
'published_by_user_id' => (int) $user->getKey(),
|
|
||||||
])->save();
|
|
||||||
|
|
||||||
$betaPublishedReview = composeTenantReviewForTest($tenantB, $user, $tenantBSnapshot);
|
|
||||||
$betaPublishedReview->forceFill([
|
|
||||||
'status' => TenantReviewStatus::Published->value,
|
|
||||||
'generated_at' => now()->subHours(2),
|
|
||||||
'published_at' => now()->subHours(2),
|
|
||||||
'published_by_user_id' => (int) $user->getKey(),
|
|
||||||
])->save();
|
|
||||||
|
|
||||||
$deniedPublishedReview = composeTenantReviewForTest($tenantDenied, $otherOwner, $tenantDeniedSnapshot);
|
|
||||||
$deniedPublishedReview->forceFill([
|
|
||||||
'status' => TenantReviewStatus::Published->value,
|
|
||||||
'generated_at' => now()->subHours(3),
|
|
||||||
'published_at' => now()->subHours(3),
|
|
||||||
'published_by_user_id' => (int) $otherOwner->getKey(),
|
|
||||||
])->save();
|
|
||||||
|
|
||||||
$this->actingAs($user);
|
|
||||||
setAdminPanelContext();
|
|
||||||
session()->put(WorkspaceContext::SESSION_KEY, (int) $tenantA->workspace_id);
|
|
||||||
|
|
||||||
Livewire::actingAs($user)
|
|
||||||
->test(CustomerReviewWorkspace::class)
|
|
||||||
->assertCanSeeTableRecords([$tenantA->fresh(), $tenantB->fresh()])
|
|
||||||
->assertCanNotSeeTableRecords([$tenantDenied->fresh()])
|
|
||||||
->assertSee(TenantReviewResource::tenantScopedUrl('view', ['record' => $latestPublishedReview->fresh()], $tenantA), false)
|
|
||||||
->assertSee(TenantReviewResource::tenantScopedUrl('view', ['record' => $betaPublishedReview->fresh()], $tenantB), false)
|
|
||||||
->assertDontSee(TenantReviewResource::tenantScopedUrl('view', ['record' => $olderPublishedReview->fresh()], $tenantA), false)
|
|
||||||
->assertDontSee(TenantReviewResource::tenantScopedUrl('view', ['record' => $newerInternalReview->fresh()], $tenantA), false)
|
|
||||||
->assertDontSee('Publish review')
|
|
||||||
->assertDontSee('Refresh review')
|
|
||||||
->assertDontSee('Create next review')
|
|
||||||
->assertDontSee('Regenerate')
|
|
||||||
->assertDontSee('Expire snapshot')
|
|
||||||
->assertDontSee(TenantReviewResource::tenantScopedUrl('view', ['record' => $deniedPublishedReview->fresh()], $tenantDenied), false);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('shows entitled tenants without a published review as calm absence rows', function (): void {
|
|
||||||
$tenantPublished = Tenant::factory()->create(['name' => 'Published Tenant']);
|
|
||||||
[$user, $tenantPublished] = createUserWithTenant(tenant: $tenantPublished, role: 'readonly');
|
|
||||||
|
|
||||||
$tenantWithoutPublished = Tenant::factory()->create([
|
|
||||||
'workspace_id' => (int) $tenantPublished->workspace_id,
|
|
||||||
'name' => 'No Published Tenant',
|
|
||||||
]);
|
|
||||||
createUserWithTenant(tenant: $tenantWithoutPublished, user: $user, role: 'readonly');
|
|
||||||
|
|
||||||
$publishedSnapshot = seedTenantReviewEvidence($tenantPublished);
|
|
||||||
$noPublishedSnapshot = seedTenantReviewEvidence($tenantWithoutPublished);
|
|
||||||
|
|
||||||
$publishedReview = composeTenantReviewForTest($tenantPublished, $user, $publishedSnapshot);
|
|
||||||
$publishedReview->forceFill([
|
|
||||||
'status' => TenantReviewStatus::Published->value,
|
|
||||||
'published_at' => now()->subHour(),
|
|
||||||
'published_by_user_id' => (int) $user->getKey(),
|
|
||||||
])->save();
|
|
||||||
|
|
||||||
$internalOnlyReview = composeTenantReviewForTest($tenantWithoutPublished, $user, $noPublishedSnapshot);
|
|
||||||
$internalOnlyReview->forceFill([
|
|
||||||
'status' => TenantReviewStatus::Ready->value,
|
|
||||||
'published_at' => null,
|
|
||||||
'published_by_user_id' => null,
|
|
||||||
])->save();
|
|
||||||
|
|
||||||
$this->actingAs($user);
|
|
||||||
setAdminPanelContext();
|
|
||||||
session()->put(WorkspaceContext::SESSION_KEY, (int) $tenantPublished->workspace_id);
|
|
||||||
|
|
||||||
Livewire::actingAs($user)
|
|
||||||
->test(CustomerReviewWorkspace::class)
|
|
||||||
->assertCanSeeTableRecords([$tenantPublished->fresh(), $tenantWithoutPublished->fresh()])
|
|
||||||
->assertSee('No published review')
|
|
||||||
->assertSee('No published review available yet')
|
|
||||||
->assertDontSee(TenantReviewResource::tenantScopedUrl('view', ['record' => $internalOnlyReview->fresh()], $tenantWithoutPublished), false)
|
|
||||||
->assertSee(TenantReviewResource::tenantScopedUrl('view', ['record' => $publishedReview->fresh()], $tenantPublished), false);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('defaults the customer review workspace to the remembered tenant when tenant context is available', function (): void {
|
|
||||||
$tenantA = Tenant::factory()->create(['name' => 'Alpha Tenant']);
|
|
||||||
[$user, $tenantA] = createUserWithTenant(tenant: $tenantA, role: 'readonly');
|
|
||||||
|
|
||||||
$tenantB = Tenant::factory()->create([
|
|
||||||
'workspace_id' => (int) $tenantA->workspace_id,
|
|
||||||
'name' => 'Beta Tenant',
|
|
||||||
]);
|
|
||||||
createUserWithTenant(tenant: $tenantB, user: $user, role: 'readonly');
|
|
||||||
|
|
||||||
$snapshotA = seedTenantReviewEvidence($tenantA);
|
|
||||||
$snapshotB = seedTenantReviewEvidence($tenantB);
|
|
||||||
|
|
||||||
$reviewA = composeTenantReviewForTest($tenantA, $user, $snapshotA);
|
|
||||||
$reviewA->forceFill([
|
|
||||||
'status' => TenantReviewStatus::Published->value,
|
|
||||||
'published_at' => now()->subDay(),
|
|
||||||
'published_by_user_id' => (int) $user->getKey(),
|
|
||||||
])->save();
|
|
||||||
|
|
||||||
$reviewB = composeTenantReviewForTest($tenantB, $user, $snapshotB);
|
|
||||||
$reviewB->forceFill([
|
|
||||||
'status' => TenantReviewStatus::Published->value,
|
|
||||||
'published_at' => now(),
|
|
||||||
'published_by_user_id' => (int) $user->getKey(),
|
|
||||||
])->save();
|
|
||||||
|
|
||||||
$this->actingAs($user);
|
|
||||||
setAdminPanelContext();
|
|
||||||
session()->put(WorkspaceContext::SESSION_KEY, (int) $tenantA->workspace_id);
|
|
||||||
session()->put(WorkspaceContext::LAST_TENANT_IDS_SESSION_KEY, [
|
|
||||||
(string) $tenantA->workspace_id => (int) $tenantB->getKey(),
|
|
||||||
]);
|
|
||||||
|
|
||||||
Livewire::actingAs($user)
|
|
||||||
->test(CustomerReviewWorkspace::class)
|
|
||||||
->assertSet('tableFilters.tenant_id.value', (string) $tenantB->getKey())
|
|
||||||
->filterTable('tenant_id', (string) $tenantB->getKey())
|
|
||||||
->assertCanSeeTableRecords([$tenantB->fresh()])
|
|
||||||
->assertCanNotSeeTableRecords([$tenantA->fresh()]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('prefilters the customer review workspace from an explicit tenant query parameter and accepts external tenant identifiers', function (): void {
|
|
||||||
$tenantA = Tenant::factory()->create(['name' => 'Alpha Tenant']);
|
|
||||||
[$user, $tenantA] = createUserWithTenant(tenant: $tenantA, role: 'readonly');
|
|
||||||
|
|
||||||
$tenantB = Tenant::factory()->create([
|
|
||||||
'workspace_id' => (int) $tenantA->workspace_id,
|
|
||||||
'name' => 'Beta Tenant',
|
|
||||||
]);
|
|
||||||
createUserWithTenant(tenant: $tenantB, user: $user, role: 'readonly');
|
|
||||||
|
|
||||||
$snapshotA = seedTenantReviewEvidence($tenantA);
|
|
||||||
$snapshotB = seedTenantReviewEvidence($tenantB);
|
|
||||||
|
|
||||||
$reviewA = composeTenantReviewForTest($tenantA, $user, $snapshotA);
|
|
||||||
$reviewA->forceFill([
|
|
||||||
'status' => TenantReviewStatus::Published->value,
|
|
||||||
'published_at' => now(),
|
|
||||||
'published_by_user_id' => (int) $user->getKey(),
|
|
||||||
])->save();
|
|
||||||
|
|
||||||
$reviewB = composeTenantReviewForTest($tenantB, $user, $snapshotB);
|
|
||||||
$reviewB->forceFill([
|
|
||||||
'status' => TenantReviewStatus::Published->value,
|
|
||||||
'published_at' => now()->subDay(),
|
|
||||||
'published_by_user_id' => (int) $user->getKey(),
|
|
||||||
])->save();
|
|
||||||
|
|
||||||
$this->actingAs($user);
|
|
||||||
setAdminPanelContext();
|
|
||||||
session()->put(WorkspaceContext::SESSION_KEY, (int) $tenantA->workspace_id);
|
|
||||||
|
|
||||||
Livewire::withQueryParams(['tenant' => (string) $tenantA->external_id])
|
|
||||||
->test(CustomerReviewWorkspace::class)
|
|
||||||
->assertSet('tableFilters.tenant_id.value', (string) $tenantA->getKey())
|
|
||||||
->filterTable('tenant_id', (string) $tenantA->getKey())
|
|
||||||
->assertCanSeeTableRecords([$tenantA->fresh()])
|
|
||||||
->assertCanNotSeeTableRecords([$tenantB->fresh()]);
|
|
||||||
});
|
|
||||||
@ -1,66 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
use App\Filament\Pages\Settings\WorkspaceSettings;
|
|
||||||
use App\Models\User;
|
|
||||||
use App\Models\Workspace;
|
|
||||||
use App\Models\WorkspaceMembership;
|
|
||||||
use App\Services\Settings\SettingsResolver;
|
|
||||||
use App\Support\Workspaces\WorkspaceContext;
|
|
||||||
use Livewire\Livewire;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @return array{0: Workspace, 1: User}
|
|
||||||
*/
|
|
||||||
function workspaceAiPolicyManager(): array
|
|
||||||
{
|
|
||||||
$workspace = Workspace::factory()->create();
|
|
||||||
$user = User::factory()->create();
|
|
||||||
|
|
||||||
WorkspaceMembership::factory()->create([
|
|
||||||
'workspace_id' => (int) $workspace->getKey(),
|
|
||||||
'user_id' => (int) $user->getKey(),
|
|
||||||
'role' => 'manager',
|
|
||||||
]);
|
|
||||||
|
|
||||||
session()->put(WorkspaceContext::SESSION_KEY, (int) $workspace->getKey());
|
|
||||||
|
|
||||||
return [$workspace, $user];
|
|
||||||
}
|
|
||||||
|
|
||||||
it('renders the workspace ai policy section and lets managers save and reset the ai posture', function (): void {
|
|
||||||
[$workspace, $user] = workspaceAiPolicyManager();
|
|
||||||
|
|
||||||
$this->actingAs($user)
|
|
||||||
->get(WorkspaceSettings::getUrl(panel: 'admin'))
|
|
||||||
->assertSuccessful()
|
|
||||||
->assertSee('Workspace AI policy')
|
|
||||||
->assertSee('Disabled')
|
|
||||||
->assertSee('Private only')
|
|
||||||
->assertSee('Approved use cases')
|
|
||||||
->assertSee('Blocked data classifications');
|
|
||||||
|
|
||||||
expect(app(SettingsResolver::class)->resolveValue($workspace, 'ai', 'policy_mode'))
|
|
||||||
->toBe('disabled');
|
|
||||||
|
|
||||||
$component = Livewire::actingAs($user)
|
|
||||||
->test(WorkspaceSettings::class)
|
|
||||||
->assertSet('data.ai_policy_mode', null)
|
|
||||||
->set('data.ai_policy_mode', 'private_only')
|
|
||||||
->callAction('save')
|
|
||||||
->assertHasNoErrors()
|
|
||||||
->assertSet('data.ai_policy_mode', 'private_only');
|
|
||||||
|
|
||||||
expect(app(SettingsResolver::class)->resolveValue($workspace, 'ai', 'policy_mode'))
|
|
||||||
->toBe('private_only');
|
|
||||||
|
|
||||||
$component
|
|
||||||
->mountFormComponentAction('ai_policy_mode', 'reset_ai_policy_mode', [], 'content')
|
|
||||||
->callMountedFormComponentAction()
|
|
||||||
->assertHasNoErrors()
|
|
||||||
->assertSet('data.ai_policy_mode', null);
|
|
||||||
|
|
||||||
expect(app(SettingsResolver::class)->resolveValue($workspace, 'ai', 'policy_mode'))
|
|
||||||
->toBe('disabled');
|
|
||||||
});
|
|
||||||
@ -79,76 +79,3 @@
|
|||||||
->and(data_get($audit?->metadata, 'before_value'))->toBe(48)
|
->and(data_get($audit?->metadata, 'before_value'))->toBe(48)
|
||||||
->and(data_get($audit?->metadata, 'after_value'))->toBe(30);
|
->and(data_get($audit?->metadata, 'after_value'))->toBe(30);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('writes a workspace-scoped audit entry when ai policy mode is updated', function (): void {
|
|
||||||
$workspace = Workspace::factory()->create();
|
|
||||||
$user = User::factory()->create();
|
|
||||||
|
|
||||||
WorkspaceMembership::factory()->create([
|
|
||||||
'workspace_id' => (int) $workspace->getKey(),
|
|
||||||
'user_id' => (int) $user->getKey(),
|
|
||||||
'role' => 'manager',
|
|
||||||
]);
|
|
||||||
|
|
||||||
app(SettingsWriter::class)->updateWorkspaceSetting(
|
|
||||||
actor: $user,
|
|
||||||
workspace: $workspace,
|
|
||||||
domain: 'ai',
|
|
||||||
key: 'policy_mode',
|
|
||||||
value: 'private_only',
|
|
||||||
);
|
|
||||||
|
|
||||||
$audit = AuditLog::query()->latest('id')->first();
|
|
||||||
|
|
||||||
expect($audit)->not->toBeNull()
|
|
||||||
->and($audit?->workspace_id)->toBe((int) $workspace->getKey())
|
|
||||||
->and($audit?->tenant_id)->toBeNull()
|
|
||||||
->and($audit?->action)->toBe(AuditActionId::WorkspaceSettingUpdated->value)
|
|
||||||
->and(data_get($audit?->metadata, 'domain'))->toBe('ai')
|
|
||||||
->and(data_get($audit?->metadata, 'key'))->toBe('policy_mode')
|
|
||||||
->and(data_get($audit?->metadata, 'scope'))->toBe('workspace')
|
|
||||||
->and(data_get($audit?->metadata, 'before_value'))->toBeNull()
|
|
||||||
->and(data_get($audit?->metadata, 'after_value'))->toBe('private_only');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('writes a workspace-scoped audit entry when ai policy mode is reset', function (): void {
|
|
||||||
$workspace = Workspace::factory()->create();
|
|
||||||
$user = User::factory()->create();
|
|
||||||
|
|
||||||
WorkspaceMembership::factory()->create([
|
|
||||||
'workspace_id' => (int) $workspace->getKey(),
|
|
||||||
'user_id' => (int) $user->getKey(),
|
|
||||||
'role' => 'manager',
|
|
||||||
]);
|
|
||||||
|
|
||||||
$writer = app(SettingsWriter::class);
|
|
||||||
|
|
||||||
$writer->updateWorkspaceSetting(
|
|
||||||
actor: $user,
|
|
||||||
workspace: $workspace,
|
|
||||||
domain: 'ai',
|
|
||||||
key: 'policy_mode',
|
|
||||||
value: 'private_only',
|
|
||||||
);
|
|
||||||
|
|
||||||
$writer->resetWorkspaceSetting(
|
|
||||||
actor: $user,
|
|
||||||
workspace: $workspace,
|
|
||||||
domain: 'ai',
|
|
||||||
key: 'policy_mode',
|
|
||||||
);
|
|
||||||
|
|
||||||
$audit = AuditLog::query()
|
|
||||||
->where('action', AuditActionId::WorkspaceSettingReset->value)
|
|
||||||
->latest('id')
|
|
||||||
->first();
|
|
||||||
|
|
||||||
expect($audit)->not->toBeNull()
|
|
||||||
->and($audit?->workspace_id)->toBe((int) $workspace->getKey())
|
|
||||||
->and($audit?->tenant_id)->toBeNull()
|
|
||||||
->and(data_get($audit?->metadata, 'domain'))->toBe('ai')
|
|
||||||
->and(data_get($audit?->metadata, 'key'))->toBe('policy_mode')
|
|
||||||
->and(data_get($audit?->metadata, 'scope'))->toBe('workspace')
|
|
||||||
->and(data_get($audit?->metadata, 'before_value'))->toBe('private_only')
|
|
||||||
->and(data_get($audit?->metadata, 'after_value'))->toBe('disabled');
|
|
||||||
});
|
|
||||||
|
|||||||
@ -44,7 +44,6 @@ function workspaceManagerUser(): array
|
|||||||
|
|
||||||
$component = Livewire::actingAs($user)
|
$component = Livewire::actingAs($user)
|
||||||
->test(WorkspaceSettings::class)
|
->test(WorkspaceSettings::class)
|
||||||
->assertSet('data.ai_policy_mode', null)
|
|
||||||
->assertSet('data.backup_retention_keep_last_default', null)
|
->assertSet('data.backup_retention_keep_last_default', null)
|
||||||
->assertSet('data.backup_retention_min_floor', null)
|
->assertSet('data.backup_retention_min_floor', null)
|
||||||
->assertSet('data.drift_severity_mapping', [])
|
->assertSet('data.drift_severity_mapping', [])
|
||||||
@ -59,7 +58,6 @@ function workspaceManagerUser(): array
|
|||||||
->assertSet('data.findings_sla_low', null)
|
->assertSet('data.findings_sla_low', null)
|
||||||
->assertSet('data.operations_operation_run_retention_days', null)
|
->assertSet('data.operations_operation_run_retention_days', null)
|
||||||
->assertSet('data.operations_stuck_run_threshold_minutes', null)
|
->assertSet('data.operations_stuck_run_threshold_minutes', null)
|
||||||
->set('data.ai_policy_mode', 'private_only')
|
|
||||||
->set('data.backup_retention_keep_last_default', 55)
|
->set('data.backup_retention_keep_last_default', 55)
|
||||||
->set('data.backup_retention_min_floor', 12)
|
->set('data.backup_retention_min_floor', 12)
|
||||||
->set('data.drift_severity_mapping', ['drift' => 'critical'])
|
->set('data.drift_severity_mapping', ['drift' => 'critical'])
|
||||||
@ -76,7 +74,6 @@ function workspaceManagerUser(): array
|
|||||||
->set('data.operations_stuck_run_threshold_minutes', 60)
|
->set('data.operations_stuck_run_threshold_minutes', 60)
|
||||||
->callAction('save')
|
->callAction('save')
|
||||||
->assertHasNoErrors()
|
->assertHasNoErrors()
|
||||||
->assertSet('data.ai_policy_mode', 'private_only')
|
|
||||||
->assertSet('data.backup_retention_keep_last_default', 55)
|
->assertSet('data.backup_retention_keep_last_default', 55)
|
||||||
->assertSet('data.backup_retention_min_floor', 12)
|
->assertSet('data.backup_retention_min_floor', 12)
|
||||||
->assertSet('data.baseline_severity_missing_policy', 'critical')
|
->assertSet('data.baseline_severity_missing_policy', 'critical')
|
||||||
@ -100,9 +97,6 @@ function workspaceManagerUser(): array
|
|||||||
expect(app(SettingsResolver::class)->resolveValue($workspace, 'backup', 'retention_keep_last_default'))
|
expect(app(SettingsResolver::class)->resolveValue($workspace, 'backup', 'retention_keep_last_default'))
|
||||||
->toBe(55);
|
->toBe(55);
|
||||||
|
|
||||||
expect(app(SettingsResolver::class)->resolveValue($workspace, 'ai', 'policy_mode'))
|
|
||||||
->toBe('private_only');
|
|
||||||
|
|
||||||
expect(app(SettingsResolver::class)->resolveValue($workspace, 'backup', 'retention_min_floor'))
|
expect(app(SettingsResolver::class)->resolveValue($workspace, 'backup', 'retention_min_floor'))
|
||||||
->toBe(12);
|
->toBe(12);
|
||||||
|
|
||||||
@ -148,18 +142,6 @@ function workspaceManagerUser(): array
|
|||||||
->where('key', 'retention_keep_last_default')
|
->where('key', 'retention_keep_last_default')
|
||||||
->exists())->toBeFalse();
|
->exists())->toBeFalse();
|
||||||
|
|
||||||
$component
|
|
||||||
->mountFormComponentAction('ai_policy_mode', 'reset_ai_policy_mode', [], 'content')
|
|
||||||
->callMountedFormComponentAction()
|
|
||||||
->assertHasNoErrors()
|
|
||||||
->assertSet('data.ai_policy_mode', null);
|
|
||||||
|
|
||||||
expect(WorkspaceSetting::query()
|
|
||||||
->where('workspace_id', (int) $workspace->getKey())
|
|
||||||
->where('domain', 'ai')
|
|
||||||
->where('key', 'policy_mode')
|
|
||||||
->exists())->toBeFalse();
|
|
||||||
|
|
||||||
$component
|
$component
|
||||||
->mountFormComponentAction('operations_operation_run_retention_days', 'reset_operations_operation_run_retention_days', [], 'content')
|
->mountFormComponentAction('operations_operation_run_retention_days', 'reset_operations_operation_run_retention_days', [], 'content')
|
||||||
->callMountedFormComponentAction()
|
->callMountedFormComponentAction()
|
||||||
|
|||||||
@ -5,7 +5,6 @@
|
|||||||
use App\Filament\Pages\Settings\WorkspaceSettings;
|
use App\Filament\Pages\Settings\WorkspaceSettings;
|
||||||
use App\Models\User;
|
use App\Models\User;
|
||||||
use App\Models\Workspace;
|
use App\Models\Workspace;
|
||||||
use App\Models\WorkspaceSetting;
|
|
||||||
use App\Support\Workspaces\WorkspaceContext;
|
use App\Support\Workspaces\WorkspaceContext;
|
||||||
use Livewire\Livewire;
|
use Livewire\Livewire;
|
||||||
|
|
||||||
@ -13,14 +12,6 @@
|
|||||||
$workspace = Workspace::factory()->create();
|
$workspace = Workspace::factory()->create();
|
||||||
$user = User::factory()->create();
|
$user = User::factory()->create();
|
||||||
|
|
||||||
WorkspaceSetting::factory()->create([
|
|
||||||
'workspace_id' => (int) $workspace->getKey(),
|
|
||||||
'domain' => 'ai',
|
|
||||||
'key' => 'policy_mode',
|
|
||||||
'value' => 'private_only',
|
|
||||||
'updated_by_user_id' => null,
|
|
||||||
]);
|
|
||||||
|
|
||||||
session()->put(WorkspaceContext::SESSION_KEY, (int) $workspace->getKey());
|
session()->put(WorkspaceContext::SESSION_KEY, (int) $workspace->getKey());
|
||||||
|
|
||||||
$this->actingAs($user)
|
$this->actingAs($user)
|
||||||
|
|||||||
@ -30,14 +30,6 @@
|
|||||||
'updated_by_user_id' => null,
|
'updated_by_user_id' => null,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
WorkspaceSetting::factory()->create([
|
|
||||||
'workspace_id' => (int) $workspace->getKey(),
|
|
||||||
'domain' => 'ai',
|
|
||||||
'key' => 'policy_mode',
|
|
||||||
'value' => 'private_only',
|
|
||||||
'updated_by_user_id' => null,
|
|
||||||
]);
|
|
||||||
|
|
||||||
session()->put(WorkspaceContext::SESSION_KEY, (int) $workspace->getKey());
|
session()->put(WorkspaceContext::SESSION_KEY, (int) $workspace->getKey());
|
||||||
|
|
||||||
$this->actingAs($user)
|
$this->actingAs($user)
|
||||||
@ -46,7 +38,6 @@
|
|||||||
|
|
||||||
Livewire::actingAs($user)
|
Livewire::actingAs($user)
|
||||||
->test(WorkspaceSettings::class)
|
->test(WorkspaceSettings::class)
|
||||||
->assertSet('data.ai_policy_mode', 'private_only')
|
|
||||||
->assertSet('data.backup_retention_keep_last_default', 27)
|
->assertSet('data.backup_retention_keep_last_default', 27)
|
||||||
->assertSet('data.backup_retention_min_floor', null)
|
->assertSet('data.backup_retention_min_floor', null)
|
||||||
->assertSet('data.drift_severity_mapping', [])
|
->assertSet('data.drift_severity_mapping', [])
|
||||||
@ -65,8 +56,6 @@
|
|||||||
->assertActionDisabled('save')
|
->assertActionDisabled('save')
|
||||||
->assertFormComponentActionVisible('backup_retention_keep_last_default', 'reset_backup_retention_keep_last_default', [], 'content')
|
->assertFormComponentActionVisible('backup_retention_keep_last_default', 'reset_backup_retention_keep_last_default', [], 'content')
|
||||||
->assertFormComponentActionDisabled('backup_retention_keep_last_default', 'reset_backup_retention_keep_last_default', [], 'content')
|
->assertFormComponentActionDisabled('backup_retention_keep_last_default', 'reset_backup_retention_keep_last_default', [], 'content')
|
||||||
->assertFormComponentActionVisible('ai_policy_mode', 'reset_ai_policy_mode', [], 'content')
|
|
||||||
->assertFormComponentActionDisabled('ai_policy_mode', 'reset_ai_policy_mode', [], 'content')
|
|
||||||
->assertFormComponentActionVisible('backup_retention_min_floor', 'reset_backup_retention_min_floor', [], 'content')
|
->assertFormComponentActionVisible('backup_retention_min_floor', 'reset_backup_retention_min_floor', [], 'content')
|
||||||
->assertFormComponentActionDisabled('backup_retention_min_floor', 'reset_backup_retention_min_floor', [], 'content')
|
->assertFormComponentActionDisabled('backup_retention_min_floor', 'reset_backup_retention_min_floor', [], 'content')
|
||||||
->assertFormComponentActionVisible('drift_severity_mapping', 'reset_drift_severity_mapping', [], 'content')
|
->assertFormComponentActionVisible('drift_severity_mapping', 'reset_drift_severity_mapping', [], 'content')
|
||||||
@ -86,11 +75,6 @@
|
|||||||
->call('save')
|
->call('save')
|
||||||
->assertStatus(403);
|
->assertStatus(403);
|
||||||
|
|
||||||
Livewire::actingAs($user)
|
|
||||||
->test(WorkspaceSettings::class)
|
|
||||||
->call('resetSetting', 'ai_policy_mode')
|
|
||||||
->assertStatus(403);
|
|
||||||
|
|
||||||
Livewire::actingAs($user)
|
Livewire::actingAs($user)
|
||||||
->test(WorkspaceSettings::class)
|
->test(WorkspaceSettings::class)
|
||||||
->call('resetSetting', 'backup_retention_keep_last_default')
|
->call('resetSetting', 'backup_retention_keep_last_default')
|
||||||
@ -104,12 +88,5 @@
|
|||||||
->where('key', 'retention_keep_last_default')
|
->where('key', 'retention_keep_last_default')
|
||||||
->first();
|
->first();
|
||||||
|
|
||||||
$aiSetting = WorkspaceSetting::query()
|
expect($setting)->not->toBeNull();
|
||||||
->where('workspace_id', (int) $workspace->getKey())
|
|
||||||
->where('domain', 'ai')
|
|
||||||
->where('key', 'policy_mode')
|
|
||||||
->first();
|
|
||||||
|
|
||||||
expect($setting)->not->toBeNull()
|
|
||||||
->and($aiSetting)->not->toBeNull();
|
|
||||||
});
|
});
|
||||||
|
|||||||
@ -1,109 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
use App\Filament\System\Pages\Ops\Controls;
|
|
||||||
use App\Models\AuditLog;
|
|
||||||
use App\Models\OperationalControlActivation;
|
|
||||||
use App\Models\PlatformUser;
|
|
||||||
use App\Models\Tenant;
|
|
||||||
use App\Models\Workspace;
|
|
||||||
use App\Support\Audit\AuditActionId;
|
|
||||||
use App\Support\Auth\PlatformCapabilities;
|
|
||||||
use Filament\Actions\Action;
|
|
||||||
use Filament\Facades\Filament;
|
|
||||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
|
||||||
use Livewire\Livewire;
|
|
||||||
|
|
||||||
uses(RefreshDatabase::class);
|
|
||||||
|
|
||||||
beforeEach(function (): void {
|
|
||||||
Filament::setCurrentPanel('system');
|
|
||||||
Filament::bootCurrentPanel();
|
|
||||||
});
|
|
||||||
|
|
||||||
function makeAiControlsManager(): PlatformUser
|
|
||||||
{
|
|
||||||
return PlatformUser::factory()->create([
|
|
||||||
'capabilities' => [
|
|
||||||
PlatformCapabilities::ACCESS_SYSTEM_PANEL,
|
|
||||||
PlatformCapabilities::OPS_CONTROLS_MANAGE,
|
|
||||||
],
|
|
||||||
'is_active' => true,
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
|
|
||||||
it('pauses and resumes ai execution through the global-only controls card', function (): void {
|
|
||||||
$workspaceA = Workspace::factory()->create(['name' => 'Acme']);
|
|
||||||
$workspaceB = Workspace::factory()->create(['name' => 'Bravo']);
|
|
||||||
|
|
||||||
Tenant::factory()->count(2)->create(['workspace_id' => (int) $workspaceA->getKey()]);
|
|
||||||
Tenant::factory()->count(1)->create(['workspace_id' => (int) $workspaceB->getKey()]);
|
|
||||||
|
|
||||||
$user = makeAiControlsManager();
|
|
||||||
$this->actingAs($user, 'platform');
|
|
||||||
|
|
||||||
$this->get(Controls::getUrl(panel: 'system'))
|
|
||||||
->assertSuccessful()
|
|
||||||
->assertSee("mountAction('pause_ai_execution')", escape: false);
|
|
||||||
|
|
||||||
$component = Livewire::test(Controls::class)
|
|
||||||
->assertActionExists('pause_ai_execution', fn (Action $action): bool => $action->isConfirmationRequired())
|
|
||||||
->assertActionExists('resume_ai_execution', fn (Action $action): bool => $action->isConfirmationRequired())
|
|
||||||
->assertActionExists('view_history_ai_execution', fn (Action $action): bool => $action->getLabel() === 'View AI execution history');
|
|
||||||
|
|
||||||
$summary = $component->instance()->controlSummary('ai.execution');
|
|
||||||
$preview = $component->instance()->scopeImpactPreview('ai.execution', 'global', null);
|
|
||||||
|
|
||||||
expect($summary['label'])->toBe('AI execution')
|
|
||||||
->and($summary['supported_scopes'])->toBe(['global'])
|
|
||||||
->and($summary['effective_state'])->toBe('enabled')
|
|
||||||
->and($preview['summary'])->toContain('AI execution')
|
|
||||||
->and($preview['workspace_count'])->toBe(2)
|
|
||||||
->and($preview['tenant_count'])->toBe(3);
|
|
||||||
|
|
||||||
$component
|
|
||||||
->callAction('pause_ai_execution', data: [
|
|
||||||
'scope_type' => 'global',
|
|
||||||
'reason_text' => 'Paused for AI rollout review.',
|
|
||||||
'expires_at' => now()->addDay()->toDateTimeString(),
|
|
||||||
])
|
|
||||||
->assertNotified('AI execution paused');
|
|
||||||
|
|
||||||
$activation = OperationalControlActivation::query()
|
|
||||||
->forControl('ai.execution')
|
|
||||||
->forGlobalScope()
|
|
||||||
->first();
|
|
||||||
|
|
||||||
expect($activation)->not->toBeNull()
|
|
||||||
->and($activation?->reason_text)->toBe('Paused for AI rollout review.');
|
|
||||||
|
|
||||||
$pausedSummary = $component->instance()->controlSummary('ai.execution');
|
|
||||||
|
|
||||||
expect($pausedSummary['effective_state'])->toBe('paused')
|
|
||||||
->and($pausedSummary['state_label'])->toBe('Paused globally');
|
|
||||||
|
|
||||||
$component
|
|
||||||
->callAction('resume_ai_execution', data: [
|
|
||||||
'scope_type' => 'global',
|
|
||||||
])
|
|
||||||
->assertNotified('AI execution resumed');
|
|
||||||
|
|
||||||
expect(OperationalControlActivation::query()
|
|
||||||
->forControl('ai.execution')
|
|
||||||
->forGlobalScope()
|
|
||||||
->count())->toBe(0);
|
|
||||||
|
|
||||||
$audits = AuditLog::query()
|
|
||||||
->whereIn('action', [
|
|
||||||
AuditActionId::OperationalControlPaused->value,
|
|
||||||
AuditActionId::OperationalControlResumed->value,
|
|
||||||
])
|
|
||||||
->where('metadata->control_key', 'ai.execution')
|
|
||||||
->orderBy('id')
|
|
||||||
->get();
|
|
||||||
|
|
||||||
expect($audits)->toHaveCount(2)
|
|
||||||
->and($audits[0]->workspace_id)->toBeNull()
|
|
||||||
->and($audits[1]->workspace_id)->toBeNull();
|
|
||||||
});
|
|
||||||
@ -5,9 +5,7 @@
|
|||||||
use App\Models\OperationRun;
|
use App\Models\OperationRun;
|
||||||
use App\Models\PlatformUser;
|
use App\Models\PlatformUser;
|
||||||
use App\Models\User;
|
use App\Models\User;
|
||||||
use App\Models\Workspace;
|
|
||||||
use App\Support\Auth\PlatformCapabilities;
|
use App\Support\Auth\PlatformCapabilities;
|
||||||
use App\Support\System\SystemDirectoryLinks;
|
|
||||||
use App\Support\System\SystemOperationRunLinks;
|
use App\Support\System\SystemOperationRunLinks;
|
||||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||||
|
|
||||||
@ -121,38 +119,3 @@
|
|||||||
->get('/system/ops/runbooks')
|
->get('/system/ops/runbooks')
|
||||||
->assertSuccessful();
|
->assertSuccessful();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('keeps system workspace detail route semantics separate from commercial business-state blocks', function (): void {
|
|
||||||
$workspace = Workspace::factory()->create();
|
|
||||||
|
|
||||||
$this->actingAs(User::factory()->create())
|
|
||||||
->get(SystemDirectoryLinks::workspaceDetail($workspace))
|
|
||||||
->assertNotFound();
|
|
||||||
|
|
||||||
auth()->guard('web')->logout();
|
|
||||||
|
|
||||||
$platformWithoutDirectoryView = PlatformUser::factory()->create([
|
|
||||||
'capabilities' => [
|
|
||||||
PlatformCapabilities::ACCESS_SYSTEM_PANEL,
|
|
||||||
],
|
|
||||||
'is_active' => true,
|
|
||||||
]);
|
|
||||||
|
|
||||||
$this->actingAs($platformWithoutDirectoryView, 'platform')
|
|
||||||
->get(SystemDirectoryLinks::workspaceDetail($workspace))
|
|
||||||
->assertForbidden();
|
|
||||||
|
|
||||||
$directoryViewer = PlatformUser::factory()->create([
|
|
||||||
'capabilities' => [
|
|
||||||
PlatformCapabilities::ACCESS_SYSTEM_PANEL,
|
|
||||||
PlatformCapabilities::DIRECTORY_VIEW,
|
|
||||||
],
|
|
||||||
'is_active' => true,
|
|
||||||
]);
|
|
||||||
|
|
||||||
$this->actingAs($directoryViewer, 'platform')
|
|
||||||
->get(SystemDirectoryLinks::workspaceDetail($workspace))
|
|
||||||
->assertSuccessful()
|
|
||||||
->assertSee('Commercial lifecycle')
|
|
||||||
->assertDontSee('Change commercial state');
|
|
||||||
});
|
|
||||||
|
|||||||
@ -1,192 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
use App\Filament\System\Pages\Directory\ViewWorkspace;
|
|
||||||
use App\Models\AuditLog;
|
|
||||||
use App\Models\PlatformUser;
|
|
||||||
use App\Models\Tenant;
|
|
||||||
use App\Models\User;
|
|
||||||
use App\Models\Workspace;
|
|
||||||
use App\Models\WorkspaceMembership;
|
|
||||||
use App\Models\WorkspaceSetting;
|
|
||||||
use App\Services\Entitlements\WorkspaceCommercialLifecycleResolver;
|
|
||||||
use App\Services\Settings\SettingsWriter;
|
|
||||||
use App\Support\Audit\AuditActionId;
|
|
||||||
use App\Support\Auth\PlatformCapabilities;
|
|
||||||
use Filament\Actions\Action;
|
|
||||||
use Filament\Facades\Filament;
|
|
||||||
use Livewire\Livewire;
|
|
||||||
|
|
||||||
beforeEach(function (): void {
|
|
||||||
Filament::setCurrentPanel('system');
|
|
||||||
Filament::bootCurrentPanel();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('renders the read-only workspace entitlement summary on the system workspace detail page', function (): void {
|
|
||||||
$workspace = Workspace::factory()->create(['name' => 'Acme Workspace']);
|
|
||||||
$manager = User::factory()->create(['name' => 'Workspace Manager']);
|
|
||||||
|
|
||||||
WorkspaceMembership::factory()->create([
|
|
||||||
'workspace_id' => (int) $workspace->getKey(),
|
|
||||||
'user_id' => (int) $manager->getKey(),
|
|
||||||
'role' => 'manager',
|
|
||||||
]);
|
|
||||||
|
|
||||||
Tenant::factory()->count(2)->create([
|
|
||||||
'workspace_id' => (int) $workspace->getKey(),
|
|
||||||
'status' => Tenant::STATUS_ACTIVE,
|
|
||||||
]);
|
|
||||||
|
|
||||||
$writer = app(SettingsWriter::class);
|
|
||||||
$writer->updateWorkspaceSetting(
|
|
||||||
actor: $manager,
|
|
||||||
workspace: $workspace,
|
|
||||||
domain: 'entitlements',
|
|
||||||
key: 'plan_profile',
|
|
||||||
value: 'starter',
|
|
||||||
);
|
|
||||||
$writer->updateWorkspaceSetting(
|
|
||||||
actor: $manager,
|
|
||||||
workspace: $workspace,
|
|
||||||
domain: 'entitlements',
|
|
||||||
key: 'managed_tenant_limit_override_value',
|
|
||||||
value: 2,
|
|
||||||
);
|
|
||||||
$writer->updateWorkspaceSetting(
|
|
||||||
actor: $manager,
|
|
||||||
workspace: $workspace,
|
|
||||||
domain: 'entitlements',
|
|
||||||
key: 'managed_tenant_limit_override_reason',
|
|
||||||
value: 'Pilot workspace',
|
|
||||||
);
|
|
||||||
$writer->updateWorkspaceSetting(
|
|
||||||
actor: $manager,
|
|
||||||
workspace: $workspace,
|
|
||||||
domain: 'entitlements',
|
|
||||||
key: 'review_pack_generation_override_value',
|
|
||||||
value: false,
|
|
||||||
);
|
|
||||||
$writer->updateWorkspaceSetting(
|
|
||||||
actor: $manager,
|
|
||||||
workspace: $workspace,
|
|
||||||
domain: 'entitlements',
|
|
||||||
key: 'review_pack_generation_override_reason',
|
|
||||||
value: 'Escalation only',
|
|
||||||
);
|
|
||||||
|
|
||||||
$platformUser = PlatformUser::factory()->create([
|
|
||||||
'capabilities' => [
|
|
||||||
PlatformCapabilities::ACCESS_SYSTEM_PANEL,
|
|
||||||
PlatformCapabilities::DIRECTORY_VIEW,
|
|
||||||
],
|
|
||||||
'is_active' => true,
|
|
||||||
]);
|
|
||||||
|
|
||||||
$this->actingAs($platformUser, 'platform')
|
|
||||||
->get(ViewWorkspace::getUrl(panel: 'system', parameters: ['workspace' => $workspace]))
|
|
||||||
->assertSuccessful()
|
|
||||||
->assertSee('Workspace entitlements')
|
|
||||||
->assertSee('Starter')
|
|
||||||
->assertSee('Pilot workspace')
|
|
||||||
->assertSee('Escalation only')
|
|
||||||
->assertSee('workspace override')
|
|
||||||
->assertSee('Commercial lifecycle')
|
|
||||||
->assertSee('Active paid')
|
|
||||||
->assertSee('default active paid')
|
|
||||||
->assertDontSee('Save');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('gates the commercial lifecycle mutation action behind a dedicated platform capability', function (): void {
|
|
||||||
$workspace = Workspace::factory()->create();
|
|
||||||
|
|
||||||
$viewer = PlatformUser::factory()->create([
|
|
||||||
'capabilities' => [
|
|
||||||
PlatformCapabilities::ACCESS_SYSTEM_PANEL,
|
|
||||||
PlatformCapabilities::DIRECTORY_VIEW,
|
|
||||||
],
|
|
||||||
'is_active' => true,
|
|
||||||
]);
|
|
||||||
|
|
||||||
Livewire::actingAs($viewer, 'platform')
|
|
||||||
->test(ViewWorkspace::class, ['workspace' => $workspace])
|
|
||||||
->assertActionHidden('change_commercial_state');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('changes commercial lifecycle state through the confirmed system action and records audit truth', function (): void {
|
|
||||||
$workspace = Workspace::factory()->create(['name' => 'Lifecycle Workspace']);
|
|
||||||
$operator = PlatformUser::factory()->create([
|
|
||||||
'name' => 'Platform Operator',
|
|
||||||
'capabilities' => [
|
|
||||||
PlatformCapabilities::ACCESS_SYSTEM_PANEL,
|
|
||||||
PlatformCapabilities::DIRECTORY_VIEW,
|
|
||||||
PlatformCapabilities::COMMERCIAL_LIFECYCLE_MANAGE,
|
|
||||||
],
|
|
||||||
'is_active' => true,
|
|
||||||
]);
|
|
||||||
|
|
||||||
Livewire::actingAs($operator, 'platform')
|
|
||||||
->test(ViewWorkspace::class, ['workspace' => $workspace])
|
|
||||||
->assertActionVisible('change_commercial_state')
|
|
||||||
->assertActionExists('change_commercial_state', fn (Action $action): bool => $action->getLabel() === 'Change commercial state'
|
|
||||||
&& $action->isConfirmationRequired())
|
|
||||||
->callAction('change_commercial_state', data: [
|
|
||||||
'state' => WorkspaceCommercialLifecycleResolver::STATE_SUSPENDED_READ_ONLY,
|
|
||||||
'reason' => 'Commercial suspension approved by support',
|
|
||||||
])
|
|
||||||
->assertNotified('Commercial state updated');
|
|
||||||
|
|
||||||
expect(WorkspaceSetting::query()
|
|
||||||
->where('workspace_id', (int) $workspace->getKey())
|
|
||||||
->where('domain', WorkspaceCommercialLifecycleResolver::SETTING_DOMAIN)
|
|
||||||
->where('key', WorkspaceCommercialLifecycleResolver::SETTING_COMMERCIAL_LIFECYCLE_STATE)
|
|
||||||
->value('value'))->toBe(WorkspaceCommercialLifecycleResolver::STATE_SUSPENDED_READ_ONLY)
|
|
||||||
->and(WorkspaceSetting::query()
|
|
||||||
->where('workspace_id', (int) $workspace->getKey())
|
|
||||||
->where('domain', WorkspaceCommercialLifecycleResolver::SETTING_DOMAIN)
|
|
||||||
->where('key', WorkspaceCommercialLifecycleResolver::SETTING_COMMERCIAL_LIFECYCLE_REASON)
|
|
||||||
->value('value'))->toBe('Commercial suspension approved by support');
|
|
||||||
|
|
||||||
$audit = AuditLog::query()
|
|
||||||
->where('workspace_id', (int) $workspace->getKey())
|
|
||||||
->where('action', AuditActionId::WorkspaceSettingUpdated->value)
|
|
||||||
->where('resource_id', WorkspaceCommercialLifecycleResolver::SETTING_DOMAIN.'.'.WorkspaceCommercialLifecycleResolver::SETTING_COMMERCIAL_LIFECYCLE_STATE)
|
|
||||||
->latest('id')
|
|
||||||
->first();
|
|
||||||
|
|
||||||
expect($audit)->not->toBeNull()
|
|
||||||
->and($audit?->actor_name)->toBe('Platform Operator')
|
|
||||||
->and($audit?->metadata['before_state'] ?? null)->toBeNull()
|
|
||||||
->and($audit?->metadata['after_state'] ?? null)->toBe(WorkspaceCommercialLifecycleResolver::STATE_SUSPENDED_READ_ONLY)
|
|
||||||
->and($audit?->metadata['after_reason'] ?? null)->toBe('Commercial suspension approved by support');
|
|
||||||
|
|
||||||
$summary = app(WorkspaceCommercialLifecycleResolver::class)->summary($workspace);
|
|
||||||
|
|
||||||
expect($summary)
|
|
||||||
->toMatchArray([
|
|
||||||
'state' => WorkspaceCommercialLifecycleResolver::STATE_SUSPENDED_READ_ONLY,
|
|
||||||
'source' => WorkspaceCommercialLifecycleResolver::SOURCE_WORKSPACE_SETTING,
|
|
||||||
'rationale' => 'Commercial suspension approved by support',
|
|
||||||
'last_changed_by' => 'Platform Operator',
|
|
||||||
]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('requires a rationale before changing commercial lifecycle state', function (): void {
|
|
||||||
$workspace = Workspace::factory()->create();
|
|
||||||
$operator = PlatformUser::factory()->create([
|
|
||||||
'capabilities' => [
|
|
||||||
PlatformCapabilities::ACCESS_SYSTEM_PANEL,
|
|
||||||
PlatformCapabilities::DIRECTORY_VIEW,
|
|
||||||
PlatformCapabilities::COMMERCIAL_LIFECYCLE_MANAGE,
|
|
||||||
],
|
|
||||||
'is_active' => true,
|
|
||||||
]);
|
|
||||||
|
|
||||||
Livewire::actingAs($operator, 'platform')
|
|
||||||
->test(ViewWorkspace::class, ['workspace' => $workspace])
|
|
||||||
->callAction('change_commercial_state', data: [
|
|
||||||
'state' => WorkspaceCommercialLifecycleResolver::STATE_GRACE,
|
|
||||||
'reason' => '',
|
|
||||||
])
|
|
||||||
->assertHasActionErrors(['reason']);
|
|
||||||
});
|
|
||||||
@ -1,20 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
use App\Services\Entitlements\WorkspaceCommercialLifecycleResolver;
|
|
||||||
use App\Support\Badges\BadgeCatalog;
|
|
||||||
use App\Support\Badges\BadgeDomain;
|
|
||||||
|
|
||||||
it('maps commercial lifecycle states through the shared badge catalog', function (string $state, string $label, string $color): void {
|
|
||||||
$spec = BadgeCatalog::spec(BadgeDomain::CommercialLifecycleState, $state);
|
|
||||||
|
|
||||||
expect($spec->label)->toBe($label)
|
|
||||||
->and($spec->color)->toBe($color)
|
|
||||||
->and($spec->icon)->not->toBeNull();
|
|
||||||
})->with([
|
|
||||||
'trial' => [WorkspaceCommercialLifecycleResolver::STATE_TRIAL, 'Trial', 'info'],
|
|
||||||
'grace' => [WorkspaceCommercialLifecycleResolver::STATE_GRACE, 'Grace', 'warning'],
|
|
||||||
'active paid' => [WorkspaceCommercialLifecycleResolver::STATE_ACTIVE_PAID, 'Active paid', 'success'],
|
|
||||||
'suspended read-only' => [WorkspaceCommercialLifecycleResolver::STATE_SUSPENDED_READ_ONLY, 'Suspended / read-only', 'danger'],
|
|
||||||
]);
|
|
||||||
@ -1,199 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
use App\Models\PlatformUser;
|
|
||||||
use App\Models\Tenant;
|
|
||||||
use App\Models\User;
|
|
||||||
use App\Models\Workspace;
|
|
||||||
use App\Models\WorkspaceMembership;
|
|
||||||
use App\Services\Entitlements\WorkspaceCommercialLifecycleResolver;
|
|
||||||
use App\Services\Entitlements\WorkspaceEntitlementResolver;
|
|
||||||
use App\Services\Settings\SettingsWriter;
|
|
||||||
use App\Support\Auth\PlatformCapabilities;
|
|
||||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
|
||||||
|
|
||||||
uses(RefreshDatabase::class);
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @return array{0: Workspace, 1: User}
|
|
||||||
*/
|
|
||||||
function commercialLifecycleWorkspaceManager(): array
|
|
||||||
{
|
|
||||||
$workspace = Workspace::factory()->create();
|
|
||||||
$user = User::factory()->create();
|
|
||||||
|
|
||||||
WorkspaceMembership::factory()->create([
|
|
||||||
'workspace_id' => (int) $workspace->getKey(),
|
|
||||||
'user_id' => (int) $user->getKey(),
|
|
||||||
'role' => 'manager',
|
|
||||||
]);
|
|
||||||
|
|
||||||
return [$workspace, $user];
|
|
||||||
}
|
|
||||||
|
|
||||||
function commercialLifecyclePlatformOperator(): PlatformUser
|
|
||||||
{
|
|
||||||
return PlatformUser::factory()->create([
|
|
||||||
'capabilities' => [
|
|
||||||
PlatformCapabilities::ACCESS_SYSTEM_PANEL,
|
|
||||||
PlatformCapabilities::DIRECTORY_VIEW,
|
|
||||||
PlatformCapabilities::COMMERCIAL_LIFECYCLE_MANAGE,
|
|
||||||
],
|
|
||||||
'is_active' => true,
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
|
|
||||||
function setCommercialLifecycleState(Workspace $workspace, string $state, string $reason = 'Unit test commercial state change'): void
|
|
||||||
{
|
|
||||||
app(SettingsWriter::class)->updateWorkspaceCommercialLifecycle(
|
|
||||||
actor: commercialLifecyclePlatformOperator(),
|
|
||||||
workspace: $workspace,
|
|
||||||
state: $state,
|
|
||||||
reason: $reason,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
it('falls back to active paid when no explicit commercial lifecycle setting exists', function (): void {
|
|
||||||
[$workspace] = commercialLifecycleWorkspaceManager();
|
|
||||||
|
|
||||||
$summary = app(WorkspaceCommercialLifecycleResolver::class)->summary($workspace);
|
|
||||||
|
|
||||||
expect($summary)
|
|
||||||
->toMatchArray([
|
|
||||||
'state' => WorkspaceCommercialLifecycleResolver::STATE_ACTIVE_PAID,
|
|
||||||
'state_label' => 'Active paid',
|
|
||||||
'source' => WorkspaceCommercialLifecycleResolver::SOURCE_DEFAULT_ACTIVE_PAID,
|
|
||||||
'source_label' => 'default active paid',
|
|
||||||
'rationale' => null,
|
|
||||||
])
|
|
||||||
->and($summary['last_changed_at'])->toBeNull()
|
|
||||||
->and($summary['last_changed_by'])->toBeNull()
|
|
||||||
->and($summary['action_decisions'][WorkspaceCommercialLifecycleResolver::ACTION_MANAGED_TENANT_ACTIVATION]['outcome'])
|
|
||||||
->toBe(WorkspaceCommercialLifecycleResolver::OUTCOME_ALLOW)
|
|
||||||
->and($summary['action_decisions'][WorkspaceCommercialLifecycleResolver::ACTION_REVIEW_PACK_START]['outcome'])
|
|
||||||
->toBe(WorkspaceCommercialLifecycleResolver::OUTCOME_ALLOW);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('resolves explicit stored commercial lifecycle states with source rationale and platform attribution', function (string $state, string $expectedLabel): void {
|
|
||||||
[$workspace] = commercialLifecycleWorkspaceManager();
|
|
||||||
$operator = commercialLifecyclePlatformOperator();
|
|
||||||
|
|
||||||
app(SettingsWriter::class)->updateWorkspaceCommercialLifecycle(
|
|
||||||
actor: $operator,
|
|
||||||
workspace: $workspace,
|
|
||||||
state: $state,
|
|
||||||
reason: 'Support approved commercial lifecycle transition',
|
|
||||||
);
|
|
||||||
|
|
||||||
$summary = app(WorkspaceCommercialLifecycleResolver::class)->summary($workspace);
|
|
||||||
|
|
||||||
expect($summary)
|
|
||||||
->toMatchArray([
|
|
||||||
'state' => $state,
|
|
||||||
'state_label' => $expectedLabel,
|
|
||||||
'source' => WorkspaceCommercialLifecycleResolver::SOURCE_WORKSPACE_SETTING,
|
|
||||||
'source_label' => 'workspace setting',
|
|
||||||
'rationale' => 'Support approved commercial lifecycle transition',
|
|
||||||
'last_changed_by' => $operator->name,
|
|
||||||
])
|
|
||||||
->and($summary['last_changed_at'])->not->toBeNull();
|
|
||||||
})->with([
|
|
||||||
'trial' => [WorkspaceCommercialLifecycleResolver::STATE_TRIAL, 'Trial'],
|
|
||||||
'grace' => [WorkspaceCommercialLifecycleResolver::STATE_GRACE, 'Grace'],
|
|
||||||
'active paid' => [WorkspaceCommercialLifecycleResolver::STATE_ACTIVE_PAID, 'Active paid'],
|
|
||||||
'suspended read-only' => [WorkspaceCommercialLifecycleResolver::STATE_SUSPENDED_READ_ONLY, 'Suspended / read-only'],
|
|
||||||
]);
|
|
||||||
|
|
||||||
it('blocks activation but warns review pack starts during grace', function (): void {
|
|
||||||
[$workspace] = commercialLifecycleWorkspaceManager();
|
|
||||||
setCommercialLifecycleState($workspace, WorkspaceCommercialLifecycleResolver::STATE_GRACE, 'Payment collection pending');
|
|
||||||
|
|
||||||
$resolver = app(WorkspaceCommercialLifecycleResolver::class);
|
|
||||||
$activation = $resolver->actionDecision($workspace, WorkspaceCommercialLifecycleResolver::ACTION_MANAGED_TENANT_ACTIVATION);
|
|
||||||
$reviewPackStart = $resolver->actionDecision($workspace, WorkspaceCommercialLifecycleResolver::ACTION_REVIEW_PACK_START);
|
|
||||||
|
|
||||||
expect($activation)
|
|
||||||
->toMatchArray([
|
|
||||||
'outcome' => WorkspaceCommercialLifecycleResolver::OUTCOME_BLOCK,
|
|
||||||
'is_blocked' => true,
|
|
||||||
'reason_family' => WorkspaceCommercialLifecycleResolver::REASON_FAMILY_COMMERCIAL_LIFECYCLE,
|
|
||||||
'state' => WorkspaceCommercialLifecycleResolver::STATE_GRACE,
|
|
||||||
])
|
|
||||||
->and($activation['block_reason'])->toContain('grace')
|
|
||||||
->and($reviewPackStart)
|
|
||||||
->toMatchArray([
|
|
||||||
'outcome' => WorkspaceCommercialLifecycleResolver::OUTCOME_WARN,
|
|
||||||
'is_blocked' => false,
|
|
||||||
'is_warning' => true,
|
|
||||||
'reason_family' => WorkspaceCommercialLifecycleResolver::REASON_FAMILY_COMMERCIAL_LIFECYCLE,
|
|
||||||
'state' => WorkspaceCommercialLifecycleResolver::STATE_GRACE,
|
|
||||||
])
|
|
||||||
->and($reviewPackStart['warning_reason'])->toContain('grace');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('blocks new starts but allows read-only history during suspended read-only', function (): void {
|
|
||||||
[$workspace] = commercialLifecycleWorkspaceManager();
|
|
||||||
setCommercialLifecycleState($workspace, WorkspaceCommercialLifecycleResolver::STATE_SUSPENDED_READ_ONLY, 'Commercial suspension');
|
|
||||||
|
|
||||||
$resolver = app(WorkspaceCommercialLifecycleResolver::class);
|
|
||||||
|
|
||||||
expect($resolver->actionDecision($workspace, WorkspaceCommercialLifecycleResolver::ACTION_MANAGED_TENANT_ACTIVATION))
|
|
||||||
->toMatchArray([
|
|
||||||
'outcome' => WorkspaceCommercialLifecycleResolver::OUTCOME_BLOCK,
|
|
||||||
'is_blocked' => true,
|
|
||||||
'reason_family' => WorkspaceCommercialLifecycleResolver::REASON_FAMILY_COMMERCIAL_LIFECYCLE,
|
|
||||||
])
|
|
||||||
->and($resolver->actionDecision($workspace, WorkspaceCommercialLifecycleResolver::ACTION_REVIEW_PACK_START))
|
|
||||||
->toMatchArray([
|
|
||||||
'outcome' => WorkspaceCommercialLifecycleResolver::OUTCOME_BLOCK,
|
|
||||||
'is_blocked' => true,
|
|
||||||
'reason_family' => WorkspaceCommercialLifecycleResolver::REASON_FAMILY_COMMERCIAL_LIFECYCLE,
|
|
||||||
])
|
|
||||||
->and($resolver->actionDecision($workspace, WorkspaceCommercialLifecycleResolver::ACTION_EVIDENCE_READ))
|
|
||||||
->toMatchArray([
|
|
||||||
'outcome' => WorkspaceCommercialLifecycleResolver::OUTCOME_ALLOW_READ_ONLY,
|
|
||||||
'is_blocked' => false,
|
|
||||||
'reason_family' => WorkspaceCommercialLifecycleResolver::REASON_FAMILY_COMMERCIAL_LIFECYCLE,
|
|
||||||
]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('preserves entitlement substrate blocks ahead of lifecycle outcomes', function (): void {
|
|
||||||
[$workspace, $manager] = commercialLifecycleWorkspaceManager();
|
|
||||||
setCommercialLifecycleState($workspace, WorkspaceCommercialLifecycleResolver::STATE_GRACE, 'Grace should not bypass substrate');
|
|
||||||
|
|
||||||
Tenant::factory()->create([
|
|
||||||
'workspace_id' => (int) $workspace->getKey(),
|
|
||||||
'status' => Tenant::STATUS_ACTIVE,
|
|
||||||
]);
|
|
||||||
|
|
||||||
app(SettingsWriter::class)->updateWorkspaceSetting(
|
|
||||||
actor: $manager,
|
|
||||||
workspace: $workspace,
|
|
||||||
domain: WorkspaceEntitlementResolver::SETTING_DOMAIN,
|
|
||||||
key: WorkspaceEntitlementResolver::SETTING_MANAGED_TENANT_LIMIT_OVERRIDE_VALUE,
|
|
||||||
value: 1,
|
|
||||||
);
|
|
||||||
|
|
||||||
app(SettingsWriter::class)->updateWorkspaceSetting(
|
|
||||||
actor: $manager,
|
|
||||||
workspace: $workspace,
|
|
||||||
domain: WorkspaceEntitlementResolver::SETTING_DOMAIN,
|
|
||||||
key: WorkspaceEntitlementResolver::SETTING_REVIEW_PACK_GENERATION_OVERRIDE_VALUE,
|
|
||||||
value: false,
|
|
||||||
);
|
|
||||||
|
|
||||||
$resolver = app(WorkspaceCommercialLifecycleResolver::class);
|
|
||||||
|
|
||||||
expect($resolver->actionDecision($workspace, WorkspaceCommercialLifecycleResolver::ACTION_MANAGED_TENANT_ACTIVATION))
|
|
||||||
->toMatchArray([
|
|
||||||
'outcome' => WorkspaceCommercialLifecycleResolver::OUTCOME_BLOCK,
|
|
||||||
'reason_family' => WorkspaceCommercialLifecycleResolver::REASON_FAMILY_ENTITLEMENT_SUBSTRATE,
|
|
||||||
])
|
|
||||||
->and($resolver->actionDecision($workspace, WorkspaceCommercialLifecycleResolver::ACTION_REVIEW_PACK_START))
|
|
||||||
->toMatchArray([
|
|
||||||
'outcome' => WorkspaceCommercialLifecycleResolver::OUTCOME_BLOCK,
|
|
||||||
'reason_family' => WorkspaceCommercialLifecycleResolver::REASON_FAMILY_ENTITLEMENT_SUBSTRATE,
|
|
||||||
'is_warning' => false,
|
|
||||||
]);
|
|
||||||
});
|
|
||||||
@ -1,155 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
use App\Models\Tenant;
|
|
||||||
use App\Models\User;
|
|
||||||
use App\Models\Workspace;
|
|
||||||
use App\Models\WorkspaceMembership;
|
|
||||||
use App\Services\Entitlements\WorkspaceEntitlementResolver;
|
|
||||||
use App\Services\Settings\SettingsWriter;
|
|
||||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
|
||||||
|
|
||||||
uses(RefreshDatabase::class);
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @return array{0: Workspace, 1: User}
|
|
||||||
*/
|
|
||||||
function entitledWorkspaceManager(): array
|
|
||||||
{
|
|
||||||
$workspace = Workspace::factory()->create();
|
|
||||||
$user = User::factory()->create();
|
|
||||||
|
|
||||||
WorkspaceMembership::factory()->create([
|
|
||||||
'workspace_id' => (int) $workspace->getKey(),
|
|
||||||
'user_id' => (int) $user->getKey(),
|
|
||||||
'role' => 'manager',
|
|
||||||
]);
|
|
||||||
|
|
||||||
return [$workspace, $user];
|
|
||||||
}
|
|
||||||
|
|
||||||
it('falls back to the default plan profile when a workspace has no entitlement settings', function (): void {
|
|
||||||
[$workspace] = entitledWorkspaceManager();
|
|
||||||
|
|
||||||
$resolver = app(WorkspaceEntitlementResolver::class);
|
|
||||||
|
|
||||||
$managedTenantLimit = $resolver->resolve($workspace, WorkspaceEntitlementResolver::KEY_MANAGED_TENANT_ACTIVATION_LIMIT);
|
|
||||||
$reviewPackGeneration = $resolver->resolve($workspace, WorkspaceEntitlementResolver::KEY_REVIEW_PACK_GENERATION_ENABLED);
|
|
||||||
|
|
||||||
expect($managedTenantLimit)
|
|
||||||
->toMatchArray([
|
|
||||||
'plan_profile_id' => 'standard',
|
|
||||||
'key' => WorkspaceEntitlementResolver::KEY_MANAGED_TENANT_ACTIVATION_LIMIT,
|
|
||||||
'effective_value' => 25,
|
|
||||||
'source' => 'plan_profile_default',
|
|
||||||
'current_usage' => 0,
|
|
||||||
'remaining_capacity' => 25,
|
|
||||||
'is_blocked' => false,
|
|
||||||
])
|
|
||||||
->and($managedTenantLimit['rationale'])->toBe('Balanced defaults for most managed workspaces.')
|
|
||||||
->and($managedTenantLimit['last_changed_at'])->toBeNull()
|
|
||||||
->and($managedTenantLimit['last_changed_by'])->toBeNull();
|
|
||||||
|
|
||||||
expect($reviewPackGeneration)
|
|
||||||
->toMatchArray([
|
|
||||||
'plan_profile_id' => 'standard',
|
|
||||||
'key' => WorkspaceEntitlementResolver::KEY_REVIEW_PACK_GENERATION_ENABLED,
|
|
||||||
'effective_value' => true,
|
|
||||||
'source' => 'plan_profile_default',
|
|
||||||
'is_blocked' => false,
|
|
||||||
]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('applies the selected plan profile defaults when no explicit override is set', function (): void {
|
|
||||||
[$workspace, $user] = entitledWorkspaceManager();
|
|
||||||
|
|
||||||
app(SettingsWriter::class)->updateWorkspaceSetting(
|
|
||||||
actor: $user,
|
|
||||||
workspace: $workspace,
|
|
||||||
domain: WorkspaceEntitlementResolver::SETTING_DOMAIN,
|
|
||||||
key: WorkspaceEntitlementResolver::SETTING_PLAN_PROFILE,
|
|
||||||
value: 'starter',
|
|
||||||
);
|
|
||||||
|
|
||||||
$resolver = app(WorkspaceEntitlementResolver::class);
|
|
||||||
|
|
||||||
$managedTenantLimit = $resolver->resolve($workspace, WorkspaceEntitlementResolver::KEY_MANAGED_TENANT_ACTIVATION_LIMIT);
|
|
||||||
$reviewPackGeneration = $resolver->resolve($workspace, WorkspaceEntitlementResolver::KEY_REVIEW_PACK_GENERATION_ENABLED);
|
|
||||||
|
|
||||||
expect($managedTenantLimit)
|
|
||||||
->toMatchArray([
|
|
||||||
'plan_profile_id' => 'starter',
|
|
||||||
'effective_value' => 1,
|
|
||||||
'source' => 'plan_profile_default',
|
|
||||||
'current_usage' => 0,
|
|
||||||
'remaining_capacity' => 1,
|
|
||||||
'is_blocked' => false,
|
|
||||||
])
|
|
||||||
->and($managedTenantLimit['last_changed_by'])->toBe($user->name)
|
|
||||||
->and($managedTenantLimit['last_changed_at'])->not->toBeNull();
|
|
||||||
|
|
||||||
expect($reviewPackGeneration)
|
|
||||||
->toMatchArray([
|
|
||||||
'plan_profile_id' => 'starter',
|
|
||||||
'effective_value' => false,
|
|
||||||
'source' => 'plan_profile_default',
|
|
||||||
'is_blocked' => true,
|
|
||||||
])
|
|
||||||
->and($reviewPackGeneration['block_reason'])->toContain('Starter');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('applies workspace override values, rationale, and usage-aware blocking', function (): void {
|
|
||||||
[$workspace, $user] = entitledWorkspaceManager();
|
|
||||||
|
|
||||||
$writer = app(SettingsWriter::class);
|
|
||||||
|
|
||||||
$writer->updateWorkspaceSetting(
|
|
||||||
actor: $user,
|
|
||||||
workspace: $workspace,
|
|
||||||
domain: WorkspaceEntitlementResolver::SETTING_DOMAIN,
|
|
||||||
key: WorkspaceEntitlementResolver::SETTING_PLAN_PROFILE,
|
|
||||||
value: 'starter',
|
|
||||||
);
|
|
||||||
|
|
||||||
$writer->updateWorkspaceSetting(
|
|
||||||
actor: $user,
|
|
||||||
workspace: $workspace,
|
|
||||||
domain: WorkspaceEntitlementResolver::SETTING_DOMAIN,
|
|
||||||
key: WorkspaceEntitlementResolver::SETTING_MANAGED_TENANT_LIMIT_OVERRIDE_VALUE,
|
|
||||||
value: 2,
|
|
||||||
);
|
|
||||||
|
|
||||||
$writer->updateWorkspaceSetting(
|
|
||||||
actor: $user,
|
|
||||||
workspace: $workspace,
|
|
||||||
domain: WorkspaceEntitlementResolver::SETTING_DOMAIN,
|
|
||||||
key: WorkspaceEntitlementResolver::SETTING_MANAGED_TENANT_LIMIT_OVERRIDE_REASON,
|
|
||||||
value: 'Temporary support-approved exception',
|
|
||||||
);
|
|
||||||
|
|
||||||
Tenant::factory()->count(2)->create([
|
|
||||||
'workspace_id' => (int) $workspace->getKey(),
|
|
||||||
'status' => Tenant::STATUS_ACTIVE,
|
|
||||||
]);
|
|
||||||
|
|
||||||
$decision = app(WorkspaceEntitlementResolver::class)->resolve(
|
|
||||||
$workspace,
|
|
||||||
WorkspaceEntitlementResolver::KEY_MANAGED_TENANT_ACTIVATION_LIMIT,
|
|
||||||
);
|
|
||||||
|
|
||||||
expect($decision)
|
|
||||||
->toMatchArray([
|
|
||||||
'plan_profile_id' => 'starter',
|
|
||||||
'effective_value' => 2,
|
|
||||||
'source' => 'workspace_override',
|
|
||||||
'rationale' => 'Temporary support-approved exception',
|
|
||||||
'current_usage' => 2,
|
|
||||||
'remaining_capacity' => 0,
|
|
||||||
'is_blocked' => true,
|
|
||||||
'last_changed_by' => $user->name,
|
|
||||||
])
|
|
||||||
->and($decision['last_changed_at'])->not->toBeNull()
|
|
||||||
->and($decision['block_reason'])->toContain('workspace override')
|
|
||||||
->and($decision['block_reason'])->toContain('Temporary support-approved exception');
|
|
||||||
});
|
|
||||||
@ -1,34 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
use App\Services\Entitlements\WorkspacePlanProfileCatalog;
|
|
||||||
|
|
||||||
it('exposes a bounded profile catalog with exactly one default profile', function (): void {
|
|
||||||
$catalog = app(WorkspacePlanProfileCatalog::class);
|
|
||||||
$profiles = $catalog->all();
|
|
||||||
|
|
||||||
expect($profiles)
|
|
||||||
->toHaveCount(3)
|
|
||||||
->and(collect($profiles)->where('is_default', true))->toHaveCount(1)
|
|
||||||
->and(WorkspacePlanProfileCatalog::defaultProfileId())->toBe('standard')
|
|
||||||
->and($catalog->default()['label'])->toBe('Standard');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('resolves known profiles and falls back to the default for unknown identifiers', function (): void {
|
|
||||||
$catalog = app(WorkspacePlanProfileCatalog::class);
|
|
||||||
|
|
||||||
expect($catalog->resolve('starter'))
|
|
||||||
->toMatchArray([
|
|
||||||
'id' => 'starter',
|
|
||||||
'managed_tenant_limit_default' => 1,
|
|
||||||
'review_pack_generation_default' => false,
|
|
||||||
])
|
|
||||||
->and($catalog->resolve('missing-profile')['id'])->toBe('standard')
|
|
||||||
->and($catalog->optionLabels())
|
|
||||||
->toMatchArray([
|
|
||||||
'starter' => 'Starter',
|
|
||||||
'standard' => 'Standard',
|
|
||||||
'scale' => 'Scale',
|
|
||||||
]);
|
|
||||||
});
|
|
||||||
@ -1,54 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
use App\Models\Tenant;
|
|
||||||
use App\Models\Workspace;
|
|
||||||
use App\Support\Ai\AiDataClassification;
|
|
||||||
use App\Support\ProductKnowledge\ContextualHelpResolver;
|
|
||||||
use App\Support\SupportDiagnostics\SupportDiagnosticBundleBuilder;
|
|
||||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
|
||||||
|
|
||||||
uses(RefreshDatabase::class);
|
|
||||||
|
|
||||||
it('exposes only the approved product knowledge source input for ai answer drafts', function (): void {
|
|
||||||
$source = app(ContextualHelpResolver::class)->aiProductKnowledgeAnswerDraftSource();
|
|
||||||
|
|
||||||
expect($source)->toMatchArray([
|
|
||||||
'use_case_key' => 'product_knowledge.answer_draft',
|
|
||||||
'source_family' => 'product_knowledge',
|
|
||||||
'data_classifications' => [
|
|
||||||
AiDataClassification::ProductKnowledge->value,
|
|
||||||
AiDataClassification::OperationalMetadata->value,
|
|
||||||
],
|
|
||||||
])
|
|
||||||
->and($source['topics'])->not->toBeEmpty()
|
|
||||||
->and($source['operational_metadata'])->toHaveKeys(['version', 'topic_count'])
|
|
||||||
->and($source)->not->toHaveKeys(['tenant', 'tenant_id', 'workspace', 'workspace_id']);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('exposes only the approved redacted support summary input for ai diagnostic drafts', function (): void {
|
|
||||||
$workspace = Workspace::factory()->create();
|
|
||||||
$tenant = Tenant::factory()->create([
|
|
||||||
'workspace_id' => (int) $workspace->getKey(),
|
|
||||||
]);
|
|
||||||
|
|
||||||
$source = app(SupportDiagnosticBundleBuilder::class)->aiSupportDiagnosticsSummaryDraftSource($tenant);
|
|
||||||
|
|
||||||
expect($source)->toMatchArray([
|
|
||||||
'use_case_key' => 'support_diagnostics.summary_draft',
|
|
||||||
'source_family' => 'support_diagnostics',
|
|
||||||
'data_classifications' => [
|
|
||||||
AiDataClassification::RedactedSupportSummary->value,
|
|
||||||
],
|
|
||||||
])
|
|
||||||
->and($source['summary'])->toHaveKeys([
|
|
||||||
'headline',
|
|
||||||
'dominant_issue',
|
|
||||||
'freshness_state',
|
|
||||||
'redaction_note',
|
|
||||||
'generated_from',
|
|
||||||
])
|
|
||||||
->and(data_get($source, 'redaction.mode'))->toBe('default_redacted')
|
|
||||||
->and($source)->not->toHaveKeys(['sections', 'context', 'tenant', 'workspace', 'operation_run']);
|
|
||||||
});
|
|
||||||
@ -1,67 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
use App\Models\Tenant;
|
|
||||||
use App\Models\Workspace;
|
|
||||||
use App\Support\Ai\AiDataClassification;
|
|
||||||
use App\Support\Ai\AiDecisionAuditMetadataFactory;
|
|
||||||
use App\Support\Ai\AiDecisionReasonCode;
|
|
||||||
use App\Support\Ai\AiExecutionDecision;
|
|
||||||
use App\Support\Ai\AiExecutionRequest;
|
|
||||||
use App\Support\Ai\AiProviderClass;
|
|
||||||
use App\Support\Audit\AuditActionId;
|
|
||||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
|
||||||
|
|
||||||
uses(RefreshDatabase::class);
|
|
||||||
|
|
||||||
it('builds bounded decision metadata without raw prompt, source, provider, or output payloads', function (): void {
|
|
||||||
$workspace = Workspace::factory()->create();
|
|
||||||
$tenant = Tenant::factory()->create(['workspace_id' => (int) $workspace->getKey()]);
|
|
||||||
|
|
||||||
$request = new AiExecutionRequest(
|
|
||||||
workspace: $workspace,
|
|
||||||
tenant: $tenant,
|
|
||||||
actor: null,
|
|
||||||
useCaseKey: 'support_diagnostics.summary_draft',
|
|
||||||
requestedProviderClass: AiProviderClass::LocalPrivate->value,
|
|
||||||
dataClassifications: [AiDataClassification::RedactedSupportSummary->value],
|
|
||||||
sourceFamily: 'support_diagnostics',
|
|
||||||
callerSurface: 'support_diagnostics',
|
|
||||||
contextFingerprint: 'support_diagnostics:summary:v1',
|
|
||||||
);
|
|
||||||
|
|
||||||
$decision = new AiExecutionDecision(
|
|
||||||
outcome: 'blocked',
|
|
||||||
reasonCode: AiDecisionReasonCode::DataClassificationBlocked,
|
|
||||||
workspaceAiPolicyMode: 'private_only',
|
|
||||||
matchedOperationalControlScope: null,
|
|
||||||
useCaseKey: 'support_diagnostics.summary_draft',
|
|
||||||
requestedProviderClass: AiProviderClass::LocalPrivate->value,
|
|
||||||
dataClassifications: [AiDataClassification::RedactedSupportSummary->value],
|
|
||||||
sourceFamily: 'support_diagnostics',
|
|
||||||
auditAction: AuditActionId::AiExecutionDecisionEvaluated,
|
|
||||||
auditMetadata: [],
|
|
||||||
);
|
|
||||||
|
|
||||||
$metadata = app(AiDecisionAuditMetadataFactory::class)->make($request, $decision);
|
|
||||||
|
|
||||||
expect($metadata)->toMatchArray([
|
|
||||||
'use_case_key' => 'support_diagnostics.summary_draft',
|
|
||||||
'decision_outcome' => 'blocked',
|
|
||||||
'decision_reason' => AiDecisionReasonCode::DataClassificationBlocked->value,
|
|
||||||
'workspace_ai_policy_mode' => 'private_only',
|
|
||||||
'requested_provider_class' => 'local_private',
|
|
||||||
'data_classifications' => ['redacted_support_summary'],
|
|
||||||
'source_family' => 'support_diagnostics',
|
|
||||||
'workspace_id' => (int) $workspace->getKey(),
|
|
||||||
'tenant_id' => (int) $tenant->getKey(),
|
|
||||||
'context_fingerprint' => 'support_diagnostics:summary:v1',
|
|
||||||
])
|
|
||||||
->and($metadata)->not->toHaveKeys([
|
|
||||||
'prompt_text',
|
|
||||||
'source_payload',
|
|
||||||
'provider_payload',
|
|
||||||
'output_text',
|
|
||||||
]);
|
|
||||||
});
|
|
||||||
@ -1,48 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
use App\Support\Ai\AiDataClassification;
|
|
||||||
use App\Support\Ai\AiPolicyMode;
|
|
||||||
use App\Support\Ai\AiUseCaseCatalog;
|
|
||||||
|
|
||||||
it('locks the first slice to the two approved private-only use cases', function (): void {
|
|
||||||
$definitions = app(AiUseCaseCatalog::class)->all();
|
|
||||||
|
|
||||||
expect($definitions)->toHaveCount(2)
|
|
||||||
->and($definitions[0])->toMatchArray([
|
|
||||||
'key' => 'product_knowledge.answer_draft',
|
|
||||||
'label' => 'Product knowledge answer draft',
|
|
||||||
'future_consumer' => 'ContextualHelpResolver',
|
|
||||||
'source_family' => 'product_knowledge',
|
|
||||||
'tenant_context_permitted' => false,
|
|
||||||
])
|
|
||||||
->and($definitions[0]['allowed_provider_classes'])->toBe(['local_private'])
|
|
||||||
->and($definitions[0]['allowed_data_classifications'])->toBe([
|
|
||||||
'product_knowledge',
|
|
||||||
'operational_metadata',
|
|
||||||
])
|
|
||||||
->and($definitions[1])->toMatchArray([
|
|
||||||
'key' => 'support_diagnostics.summary_draft',
|
|
||||||
'label' => 'Support diagnostics summary draft',
|
|
||||||
'future_consumer' => 'SupportDiagnosticBundleBuilder',
|
|
||||||
'source_family' => 'support_diagnostics',
|
|
||||||
'tenant_context_permitted' => true,
|
|
||||||
])
|
|
||||||
->and($definitions[1]['allowed_provider_classes'])->toBe(['local_private'])
|
|
||||||
->and($definitions[1]['allowed_data_classifications'])->toBe([
|
|
||||||
'redacted_support_summary',
|
|
||||||
]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('derives provider and blocked-data summaries from the catalog for the workspace policy surface', function (): void {
|
|
||||||
$catalog = app(AiUseCaseCatalog::class);
|
|
||||||
|
|
||||||
expect($catalog->allowedProviderClassLabelsForMode(AiPolicyMode::Disabled))->toBe([])
|
|
||||||
->and($catalog->allowedProviderClassLabelsForMode(AiPolicyMode::PrivateOnly))->toBe(['Local private'])
|
|
||||||
->and($catalog->blockedDataClassificationLabels())->toBe([
|
|
||||||
AiDataClassification::PersonalData->label(),
|
|
||||||
AiDataClassification::CustomerConfidential->label(),
|
|
||||||
AiDataClassification::RawProviderPayload->label(),
|
|
||||||
]);
|
|
||||||
});
|
|
||||||
@ -1,172 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
use App\Models\AuditLog;
|
|
||||||
use App\Models\OperationalControlActivation;
|
|
||||||
use App\Models\Tenant;
|
|
||||||
use App\Models\User;
|
|
||||||
use App\Models\Workspace;
|
|
||||||
use App\Models\WorkspaceMembership;
|
|
||||||
use App\Models\WorkspaceSetting;
|
|
||||||
use App\Support\Ai\AiDataClassification;
|
|
||||||
use App\Support\Ai\AiDecisionReasonCode;
|
|
||||||
use App\Support\Ai\AiExecutionRequest;
|
|
||||||
use App\Support\Ai\AiProviderClass;
|
|
||||||
use App\Support\Ai\GovernedAiExecutionBoundary;
|
|
||||||
use App\Support\Audit\AuditActionId;
|
|
||||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
|
||||||
|
|
||||||
uses(RefreshDatabase::class);
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @return array{0: Workspace, 1: User}
|
|
||||||
*/
|
|
||||||
function aiPolicyWorkspace(string $policyMode = 'private_only'): array
|
|
||||||
{
|
|
||||||
$workspace = Workspace::factory()->create();
|
|
||||||
$user = User::factory()->create();
|
|
||||||
|
|
||||||
WorkspaceMembership::factory()->create([
|
|
||||||
'workspace_id' => (int) $workspace->getKey(),
|
|
||||||
'user_id' => (int) $user->getKey(),
|
|
||||||
'role' => 'manager',
|
|
||||||
]);
|
|
||||||
|
|
||||||
WorkspaceSetting::query()->create([
|
|
||||||
'workspace_id' => (int) $workspace->getKey(),
|
|
||||||
'domain' => 'ai',
|
|
||||||
'key' => 'policy_mode',
|
|
||||||
'value' => $policyMode,
|
|
||||||
'updated_by_user_id' => (int) $user->getKey(),
|
|
||||||
]);
|
|
||||||
|
|
||||||
return [$workspace, $user];
|
|
||||||
}
|
|
||||||
|
|
||||||
it('allows approved local-private support-diagnostics requests and writes bounded audit metadata', function (): void {
|
|
||||||
[$workspace, $user] = aiPolicyWorkspace();
|
|
||||||
$tenant = Tenant::factory()->create(['workspace_id' => (int) $workspace->getKey()]);
|
|
||||||
|
|
||||||
$decision = assertNoOutboundHttp(fn () => app(GovernedAiExecutionBoundary::class)->evaluate(new AiExecutionRequest(
|
|
||||||
workspace: $workspace,
|
|
||||||
tenant: $tenant,
|
|
||||||
actor: $user,
|
|
||||||
useCaseKey: 'support_diagnostics.summary_draft',
|
|
||||||
requestedProviderClass: AiProviderClass::LocalPrivate->value,
|
|
||||||
dataClassifications: [AiDataClassification::RedactedSupportSummary->value],
|
|
||||||
sourceFamily: 'support_diagnostics',
|
|
||||||
callerSurface: 'support_diagnostics',
|
|
||||||
contextFingerprint: 'support_diagnostics:summary:v1',
|
|
||||||
)));
|
|
||||||
|
|
||||||
expect($decision->isAllowed())->toBeTrue()
|
|
||||||
->and($decision->reasonCode)->toBe(AiDecisionReasonCode::Allowed)
|
|
||||||
->and($decision->workspaceAiPolicyMode)->toBe('private_only')
|
|
||||||
->and($decision->matchedOperationalControlScope)->toBeNull();
|
|
||||||
|
|
||||||
$audit = AuditLog::query()->latest('id')->first();
|
|
||||||
|
|
||||||
expect($audit)->not->toBeNull()
|
|
||||||
->and($audit?->action)->toBe(AuditActionId::AiExecutionDecisionEvaluated->value)
|
|
||||||
->and($audit?->workspace_id)->toBe((int) $workspace->getKey())
|
|
||||||
->and($audit?->tenant_id)->toBe((int) $tenant->getKey())
|
|
||||||
->and(data_get($audit?->metadata, 'decision_outcome'))->toBe('allowed')
|
|
||||||
->and(data_get($audit?->metadata, 'decision_reason'))->toBe(AiDecisionReasonCode::Allowed->value)
|
|
||||||
->and(data_get($audit?->metadata, 'use_case_key'))->toBe('support_diagnostics.summary_draft')
|
|
||||||
->and(data_get($audit?->metadata, 'requested_provider_class'))->toBe('local_private')
|
|
||||||
->and(data_get($audit?->metadata, 'data_classifications'))->toBe(['redacted_support_summary'])
|
|
||||||
->and(data_get($audit?->metadata, 'context_fingerprint'))->toBe('support_diagnostics:summary:v1')
|
|
||||||
->and(data_get($audit?->metadata, 'prompt_text'))->toBeNull()
|
|
||||||
->and(data_get($audit?->metadata, 'output_text'))->toBeNull();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('blocks external-public provider classes before any provider resolution', function (): void {
|
|
||||||
[$workspace, $user] = aiPolicyWorkspace();
|
|
||||||
|
|
||||||
$decision = assertNoOutboundHttp(fn () => app(GovernedAiExecutionBoundary::class)->evaluate(new AiExecutionRequest(
|
|
||||||
workspace: $workspace,
|
|
||||||
tenant: null,
|
|
||||||
actor: $user,
|
|
||||||
useCaseKey: 'product_knowledge.answer_draft',
|
|
||||||
requestedProviderClass: AiProviderClass::ExternalPublic->value,
|
|
||||||
dataClassifications: [
|
|
||||||
AiDataClassification::ProductKnowledge->value,
|
|
||||||
AiDataClassification::OperationalMetadata->value,
|
|
||||||
],
|
|
||||||
sourceFamily: 'product_knowledge',
|
|
||||||
callerSurface: 'product_knowledge',
|
|
||||||
contextFingerprint: 'product_knowledge:answer:v1',
|
|
||||||
)));
|
|
||||||
|
|
||||||
expect($decision->isBlocked())->toBeTrue()
|
|
||||||
->and($decision->reasonCode)->toBe(AiDecisionReasonCode::ProviderClassBlocked)
|
|
||||||
->and($decision->matchedOperationalControlScope)->toBeNull();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('blocks disallowed data classifications before any provider resolution', function (): void {
|
|
||||||
[$workspace, $user] = aiPolicyWorkspace();
|
|
||||||
$tenant = Tenant::factory()->create(['workspace_id' => (int) $workspace->getKey()]);
|
|
||||||
|
|
||||||
$decision = assertNoOutboundHttp(fn () => app(GovernedAiExecutionBoundary::class)->evaluate(new AiExecutionRequest(
|
|
||||||
workspace: $workspace,
|
|
||||||
tenant: $tenant,
|
|
||||||
actor: $user,
|
|
||||||
useCaseKey: 'support_diagnostics.summary_draft',
|
|
||||||
requestedProviderClass: AiProviderClass::LocalPrivate->value,
|
|
||||||
dataClassifications: [AiDataClassification::RawProviderPayload->value],
|
|
||||||
sourceFamily: 'support_diagnostics',
|
|
||||||
callerSurface: 'support_diagnostics',
|
|
||||||
contextFingerprint: 'support_diagnostics:raw:v1',
|
|
||||||
)));
|
|
||||||
|
|
||||||
expect($decision->isBlocked())->toBeTrue()
|
|
||||||
->and($decision->reasonCode)->toBe(AiDecisionReasonCode::DataClassificationBlocked);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('blocks unregistered use cases', function (): void {
|
|
||||||
[$workspace, $user] = aiPolicyWorkspace();
|
|
||||||
|
|
||||||
$decision = assertNoOutboundHttp(fn () => app(GovernedAiExecutionBoundary::class)->evaluate(new AiExecutionRequest(
|
|
||||||
workspace: $workspace,
|
|
||||||
tenant: null,
|
|
||||||
actor: $user,
|
|
||||||
useCaseKey: 'customer_email.reply',
|
|
||||||
requestedProviderClass: AiProviderClass::LocalPrivate->value,
|
|
||||||
dataClassifications: [AiDataClassification::ProductKnowledge->value],
|
|
||||||
sourceFamily: 'product_knowledge',
|
|
||||||
callerSurface: 'product_knowledge',
|
|
||||||
contextFingerprint: 'customer_email:reply:v1',
|
|
||||||
)));
|
|
||||||
|
|
||||||
expect($decision->isBlocked())->toBeTrue()
|
|
||||||
->and($decision->reasonCode)->toBe(AiDecisionReasonCode::UnregisteredUseCase);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('lets the ai execution operational control override an otherwise valid request', function (): void {
|
|
||||||
[$workspace, $user] = aiPolicyWorkspace();
|
|
||||||
|
|
||||||
OperationalControlActivation::factory()->forGlobalScope()->create([
|
|
||||||
'control_key' => 'ai.execution',
|
|
||||||
'reason_text' => 'Paused for AI rollout review.',
|
|
||||||
]);
|
|
||||||
|
|
||||||
$decision = assertNoOutboundHttp(fn () => app(GovernedAiExecutionBoundary::class)->evaluate(new AiExecutionRequest(
|
|
||||||
workspace: $workspace,
|
|
||||||
tenant: null,
|
|
||||||
actor: $user,
|
|
||||||
useCaseKey: 'product_knowledge.answer_draft',
|
|
||||||
requestedProviderClass: AiProviderClass::LocalPrivate->value,
|
|
||||||
dataClassifications: [
|
|
||||||
AiDataClassification::ProductKnowledge->value,
|
|
||||||
AiDataClassification::OperationalMetadata->value,
|
|
||||||
],
|
|
||||||
sourceFamily: 'product_knowledge',
|
|
||||||
callerSurface: 'product_knowledge',
|
|
||||||
contextFingerprint: 'product_knowledge:answer:v1',
|
|
||||||
)));
|
|
||||||
|
|
||||||
expect($decision->isBlocked())->toBeTrue()
|
|
||||||
->and($decision->reasonCode)->toBe(AiDecisionReasonCode::OperationalControlPaused)
|
|
||||||
->and($decision->matchedOperationalControlScope)->toBe('global');
|
|
||||||
});
|
|
||||||
@ -1,197 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
use App\Models\AlertDelivery;
|
|
||||||
use App\Models\Finding;
|
|
||||||
use App\Models\OperationRun;
|
|
||||||
use App\Models\Tenant;
|
|
||||||
use App\Models\TenantTriageReview;
|
|
||||||
use App\Models\User;
|
|
||||||
use App\Models\Workspace;
|
|
||||||
use App\Support\Auth\Capabilities;
|
|
||||||
use App\Support\BackupHealth\TenantBackupHealthResolver;
|
|
||||||
use App\Support\GovernanceInbox\GovernanceInboxSectionBuilder;
|
|
||||||
use App\Support\Navigation\CanonicalNavigationContext;
|
|
||||||
use App\Support\OperationRunOutcome;
|
|
||||||
use App\Support\OperationRunStatus;
|
|
||||||
use App\Support\PortfolioTriage\PortfolioArrivalContextToken;
|
|
||||||
use App\Support\PortfolioTriage\TenantTriageReviewFingerprint;
|
|
||||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
|
||||||
|
|
||||||
uses(RefreshDatabase::class);
|
|
||||||
|
|
||||||
it('builds visible governance inbox sections in canonical order with source links', function (): void {
|
|
||||||
$workspace = Workspace::factory()->create();
|
|
||||||
$user = User::factory()->create();
|
|
||||||
|
|
||||||
$alphaTenant = Tenant::factory()->create([
|
|
||||||
'workspace_id' => (int) $workspace->getKey(),
|
|
||||||
'name' => 'Alpha Tenant',
|
|
||||||
'external_id' => 'alpha-tenant',
|
|
||||||
]);
|
|
||||||
$bravoTenant = Tenant::factory()->create([
|
|
||||||
'workspace_id' => (int) $workspace->getKey(),
|
|
||||||
'name' => 'Bravo Tenant',
|
|
||||||
'external_id' => 'bravo-tenant',
|
|
||||||
]);
|
|
||||||
|
|
||||||
Finding::factory()
|
|
||||||
->for($alphaTenant)
|
|
||||||
->assignedTo((int) $user->getKey())
|
|
||||||
->ownedBy((int) $user->getKey())
|
|
||||||
->overdueByHours()
|
|
||||||
->create([
|
|
||||||
'status' => Finding::STATUS_IN_PROGRESS,
|
|
||||||
'subject_external_id' => 'assigned-finding',
|
|
||||||
]);
|
|
||||||
|
|
||||||
Finding::factory()
|
|
||||||
->for($bravoTenant)
|
|
||||||
->reopened()
|
|
||||||
->create([
|
|
||||||
'subject_external_id' => 'intake-finding',
|
|
||||||
]);
|
|
||||||
|
|
||||||
OperationRun::factory()
|
|
||||||
->forTenant($alphaTenant)
|
|
||||||
->create([
|
|
||||||
'status' => OperationRunStatus::Completed->value,
|
|
||||||
'outcome' => OperationRunOutcome::Failed->value,
|
|
||||||
'completed_at' => now()->subMinute(),
|
|
||||||
]);
|
|
||||||
|
|
||||||
OperationRun::factory()
|
|
||||||
->forTenant($bravoTenant)
|
|
||||||
->create([
|
|
||||||
'status' => OperationRunStatus::Queued->value,
|
|
||||||
'outcome' => OperationRunOutcome::Pending->value,
|
|
||||||
'created_at' => now()->subMinutes(6),
|
|
||||||
]);
|
|
||||||
|
|
||||||
AlertDelivery::factory()->create([
|
|
||||||
'tenant_id' => null,
|
|
||||||
'workspace_id' => (int) $workspace->getKey(),
|
|
||||||
'status' => AlertDelivery::STATUS_FAILED,
|
|
||||||
'event_type' => 'alerts.failed_delivery',
|
|
||||||
'payload' => [
|
|
||||||
'title' => 'Delivery failed',
|
|
||||||
'body' => 'Alert delivery could not be completed.',
|
|
||||||
],
|
|
||||||
]);
|
|
||||||
|
|
||||||
$backupHealthResolver = app(TenantBackupHealthResolver::class);
|
|
||||||
$fingerprints = app(TenantTriageReviewFingerprint::class);
|
|
||||||
|
|
||||||
$alphaBackupFingerprint = $fingerprints->forBackupHealth($backupHealthResolver->assess($alphaTenant));
|
|
||||||
$bravoBackupFingerprint = $fingerprints->forBackupHealth($backupHealthResolver->assess($bravoTenant));
|
|
||||||
|
|
||||||
expect($alphaBackupFingerprint)->not->toBeNull()
|
|
||||||
->and($bravoBackupFingerprint)->not->toBeNull();
|
|
||||||
|
|
||||||
TenantTriageReview::factory()
|
|
||||||
->for($alphaTenant)
|
|
||||||
->followUpNeeded()
|
|
||||||
->create([
|
|
||||||
'workspace_id' => (int) $workspace->getKey(),
|
|
||||||
'reviewed_by_user_id' => (int) $user->getKey(),
|
|
||||||
'concern_family' => PortfolioArrivalContextToken::FAMILY_BACKUP_HEALTH,
|
|
||||||
'review_fingerprint' => $alphaBackupFingerprint['fingerprint'],
|
|
||||||
'review_snapshot' => $alphaBackupFingerprint['snapshot'],
|
|
||||||
'reviewed_at' => now()->subDay(),
|
|
||||||
]);
|
|
||||||
|
|
||||||
TenantTriageReview::factory()
|
|
||||||
->for($bravoTenant)
|
|
||||||
->reviewed()
|
|
||||||
->create([
|
|
||||||
'workspace_id' => (int) $workspace->getKey(),
|
|
||||||
'reviewed_by_user_id' => (int) $user->getKey(),
|
|
||||||
'concern_family' => PortfolioArrivalContextToken::FAMILY_BACKUP_HEALTH,
|
|
||||||
'review_fingerprint' => hash('sha256', 'stale-review-fingerprint'),
|
|
||||||
'review_snapshot' => $bravoBackupFingerprint['snapshot'],
|
|
||||||
'reviewed_at' => now()->subDays(2),
|
|
||||||
]);
|
|
||||||
|
|
||||||
$context = new CanonicalNavigationContext(
|
|
||||||
sourceSurface: 'governance.inbox',
|
|
||||||
canonicalRouteName: 'filament.admin.pages.governance.inbox',
|
|
||||||
backLinkLabel: 'Back to governance inbox',
|
|
||||||
backLinkUrl: '/admin/governance/inbox',
|
|
||||||
);
|
|
||||||
|
|
||||||
$payload = app(GovernanceInboxSectionBuilder::class)->build(
|
|
||||||
user: $user,
|
|
||||||
workspace: $workspace,
|
|
||||||
authorizedTenants: [$alphaTenant, $bravoTenant],
|
|
||||||
visibleFindingTenants: [$alphaTenant, $bravoTenant],
|
|
||||||
reviewTenants: [$alphaTenant, $bravoTenant],
|
|
||||||
canViewAlerts: true,
|
|
||||||
navigationContext: $context,
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(collect($payload['sections'])->pluck('key')->all())
|
|
||||||
->toBe([
|
|
||||||
'assigned_findings',
|
|
||||||
'intake_findings',
|
|
||||||
'stale_operations',
|
|
||||||
'alert_delivery_failures',
|
|
||||||
'review_follow_up',
|
|
||||||
])
|
|
||||||
->and($payload['family_counts'])->toMatchArray([
|
|
||||||
'assigned_findings' => 1,
|
|
||||||
'intake_findings' => 1,
|
|
||||||
'stale_operations' => 2,
|
|
||||||
'alert_delivery_failures' => 1,
|
|
||||||
'review_follow_up' => 2,
|
|
||||||
]);
|
|
||||||
|
|
||||||
$sections = collect($payload['sections'])->keyBy('key');
|
|
||||||
|
|
||||||
expect($sections['assigned_findings']['dominant_action_url'])
|
|
||||||
->toContain('/admin/findings/my-work')
|
|
||||||
->toContain('nav%5Bback_label%5D=Back+to+governance+inbox')
|
|
||||||
->and($sections['stale_operations']['dominant_action_label'])->toBe('Open terminal follow-up')
|
|
||||||
->and($sections['stale_operations']['dominant_action_url'])->toContain('problemClass=terminal_follow_up')
|
|
||||||
->and($sections['alert_delivery_failures']['dominant_action_url'])->toContain('tableFilters%5Bstatus%5D%5Bvalue%5D=failed')
|
|
||||||
->and(collect($sections['review_follow_up']['entries'])->pluck('status_label')->all())
|
|
||||||
->toBe(['Follow-up needed', 'Changed since review']);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('keeps an explicitly selected visible family with an honest empty state when tenant filtering removes every row', function (): void {
|
|
||||||
$workspace = Workspace::factory()->create();
|
|
||||||
$user = User::factory()->create();
|
|
||||||
|
|
||||||
$alphaTenant = Tenant::factory()->create([
|
|
||||||
'workspace_id' => (int) $workspace->getKey(),
|
|
||||||
'name' => 'Alpha Tenant',
|
|
||||||
'external_id' => 'alpha-tenant',
|
|
||||||
]);
|
|
||||||
$bravoTenant = Tenant::factory()->create([
|
|
||||||
'workspace_id' => (int) $workspace->getKey(),
|
|
||||||
'name' => 'Bravo Tenant',
|
|
||||||
'external_id' => 'bravo-tenant',
|
|
||||||
]);
|
|
||||||
|
|
||||||
AlertDelivery::factory()->create([
|
|
||||||
'tenant_id' => null,
|
|
||||||
'workspace_id' => (int) $workspace->getKey(),
|
|
||||||
'status' => AlertDelivery::STATUS_FAILED,
|
|
||||||
]);
|
|
||||||
|
|
||||||
$payload = app(GovernanceInboxSectionBuilder::class)->build(
|
|
||||||
user: $user,
|
|
||||||
workspace: $workspace,
|
|
||||||
authorizedTenants: [$alphaTenant, $bravoTenant],
|
|
||||||
visibleFindingTenants: [],
|
|
||||||
reviewTenants: [],
|
|
||||||
canViewAlerts: true,
|
|
||||||
selectedTenant: $alphaTenant,
|
|
||||||
selectedFamily: 'alert_delivery_failures',
|
|
||||||
);
|
|
||||||
|
|
||||||
expect($payload['sections'])->toHaveCount(1)
|
|
||||||
->and($payload['sections'][0]['key'])->toBe('alert_delivery_failures')
|
|
||||||
->and($payload['sections'][0]['count'])->toBe(0)
|
|
||||||
->and($payload['sections'][0]['empty_state'])->toContain('tenant filter');
|
|
||||||
});
|
|
||||||
@ -7,18 +7,12 @@
|
|||||||
it('exposes only active runtime controls in the bounded control catalog', function (): void {
|
it('exposes only active runtime controls in the bounded control catalog', function (): void {
|
||||||
$catalog = app(OperationalControlCatalog::class);
|
$catalog = app(OperationalControlCatalog::class);
|
||||||
|
|
||||||
expect($catalog->keys())->toBe(['restore.execute', 'ai.execution'])
|
expect($catalog->keys())->toBe(['restore.execute'])
|
||||||
->and($catalog->definition('restore.execute'))->toMatchArray([
|
->and($catalog->definition('restore.execute'))->toMatchArray([
|
||||||
'key' => 'restore.execute',
|
'key' => 'restore.execute',
|
||||||
'label' => 'Restore execution',
|
'label' => 'Restore execution',
|
||||||
'supported_scopes' => ['global', 'workspace'],
|
'supported_scopes' => ['global', 'workspace'],
|
||||||
'operation_types' => ['restore.execute'],
|
'operation_types' => ['restore.execute'],
|
||||||
])
|
|
||||||
->and($catalog->definition('ai.execution'))->toMatchArray([
|
|
||||||
'key' => 'ai.execution',
|
|
||||||
'label' => 'AI execution',
|
|
||||||
'supported_scopes' => ['global'],
|
|
||||||
'operation_types' => ['ai.execution'],
|
|
||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@ -1,52 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
use App\Models\Workspace;
|
|
||||||
use App\Support\Workspaces\WorkspaceResolver;
|
|
||||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
|
||||||
|
|
||||||
uses(RefreshDatabase::class);
|
|
||||||
|
|
||||||
it('resolves a workspace by slug or id', function (): void {
|
|
||||||
$workspace = Workspace::factory()->create([
|
|
||||||
'slug' => 'resolver-smoke-workspace',
|
|
||||||
]);
|
|
||||||
|
|
||||||
$resolver = app(WorkspaceResolver::class);
|
|
||||||
|
|
||||||
expect($resolver->resolve('resolver-smoke-workspace')?->is($workspace))->toBeTrue()
|
|
||||||
->and($resolver->resolve((string) $workspace->getKey())?->is($workspace))->toBeTrue();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('resolves a Livewire serialized workspace route parameter', function (): void {
|
|
||||||
$workspace = Workspace::factory()->create([
|
|
||||||
'slug' => 'serialized-route-workspace',
|
|
||||||
]);
|
|
||||||
|
|
||||||
$payload = json_encode([
|
|
||||||
'id' => $workspace->getKey(),
|
|
||||||
'name' => $workspace->name,
|
|
||||||
'slug' => $workspace->slug,
|
|
||||||
], JSON_THROW_ON_ERROR);
|
|
||||||
|
|
||||||
expect(app(WorkspaceResolver::class)->resolve($payload)?->is($workspace))->toBeTrue();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('falls back to serialized id when a Livewire route payload has no slug', function (): void {
|
|
||||||
$workspace = Workspace::factory()->create();
|
|
||||||
|
|
||||||
$payload = json_encode([
|
|
||||||
'id' => (string) $workspace->getKey(),
|
|
||||||
], JSON_THROW_ON_ERROR);
|
|
||||||
|
|
||||||
expect(app(WorkspaceResolver::class)->resolve($payload)?->is($workspace))->toBeTrue();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('returns null for an unsupported serialized route payload', function (): void {
|
|
||||||
$payload = json_encode([
|
|
||||||
'name' => 'Missing key',
|
|
||||||
], JSON_THROW_ON_ERROR);
|
|
||||||
|
|
||||||
expect(app(WorkspaceResolver::class)->resolve($payload))->toBeNull();
|
|
||||||
});
|
|
||||||
@ -62,7 +62,7 @@ services:
|
|||||||
- laravel.test
|
- laravel.test
|
||||||
- pgsql
|
- pgsql
|
||||||
- redis
|
- redis
|
||||||
command: php artisan queue:listen --tries=3 --timeout=300 --sleep=3
|
command: php artisan queue:work --tries=3 --timeout=300 --sleep=3 --max-jobs=1000
|
||||||
|
|
||||||
pgsql:
|
pgsql:
|
||||||
image: 'postgres:16'
|
image: 'postgres:16'
|
||||||
|
|||||||
@ -1,273 +0,0 @@
|
|||||||
# TenantPilot Implementation Ledger
|
|
||||||
|
|
||||||
## Purpose
|
|
||||||
|
|
||||||
Dieses Dokument beschreibt den aktuellen repo-basierten Implementierungsstand von TenantPilot. Es ergaenzt `roadmap.md` und `spec-candidates.md`, ersetzt sie aber nicht.
|
|
||||||
|
|
||||||
Bewertungsregeln fuer dieses Ledger:
|
|
||||||
|
|
||||||
- Repo-basiert only: Aussagen zaehlen nur, wenn Code, Datenmodell, Workflow, UI-Adoption oder Test-Artefakte im Repo belastbar darauf hinweisen.
|
|
||||||
- Keine Roadmap- oder Spec-Absicht ohne Repo-Evidence.
|
|
||||||
- `sellable` wird nur dort verwendet, wo UI, Workflow, Datenmodell, RBAC/Audit und passende Test-Artefakte plausibel zusammenpassen.
|
|
||||||
- Backend-only bleibt `foundation-only`.
|
|
||||||
- UI-only gilt nicht als fertig.
|
|
||||||
- Wenn Tests unten als vorhanden markiert sind, bedeutet das: passende Test-Dateien existieren im Repo. Sie wurden fuer dieses Ledger nicht ausgefuehrt.
|
|
||||||
|
|
||||||
## Current Product Position
|
|
||||||
|
|
||||||
TenantPilot ist aktuell ein starkes internes Governance- und Operations-Produkt mit belastbaren Foundations fuer Execution Truth, Baselines/Drift, Findings, Evidence, Reviews, Review Packs, Supportability, Telemetry und Safety Controls. Die Repo-Wahrheit liegt damit ueber einer simplen Lesart von "R1 done / R2 partial". Gleichzeitig ist das Produkt noch nicht voll als kundenseitig konsumierbare Review- und Portfolio-Plattform ausgereift: Customer-safe Review Consumption, Cross-Tenant-Workflows und kommerzielle Lifecycle-Reife sind noch unvollstaendig.
|
|
||||||
|
|
||||||
## Status Model
|
|
||||||
|
|
||||||
- `planned`: nur in Roadmap oder Kandidatenliste, ohne belastbare Repo-Evidence
|
|
||||||
- `specified`: als Spec oder Draft angelegt, aber nicht repo-verifiziert umgesetzt
|
|
||||||
- `implemented_partial`: Teilumsetzung vorhanden, aber noch nicht als fertig bewertbar
|
|
||||||
- `implemented_backend`: belastbare Backend- oder Modelllogik vorhanden, aber keine ausreichende UI-Adoption
|
|
||||||
- `implemented_ui`: sichtbare UI vorhanden, aber Workflow- oder Backend-Proof ist noch zu schwach
|
|
||||||
- `implemented_verified`: Code, Modell, Workflow und Test-Artefakte sind plausibel vorhanden
|
|
||||||
- `adopted`: implementiert und bereits in zentrale Produktoberflaechen oder Kernablaeufe uebernommen
|
|
||||||
- `deferred`: bewusst verschoben
|
|
||||||
- `obsolete`: durch neuere Repo-Realitaet oder andere Implementierung ueberholt
|
|
||||||
|
|
||||||
Evidence-Level im Dokument:
|
|
||||||
|
|
||||||
- `none`: keine belastbare Repo-Evidence
|
|
||||||
- `weak`: duenne Code- oder Doc-Spur, aber kein belastbarer Gesamtworkflow
|
|
||||||
- `medium`: mehrere Repo-Signale, aber noch nicht durchgaengig
|
|
||||||
- `strong`: Datenmodell, Workflow, UI- oder Test-Spur greifen konsistent ineinander
|
|
||||||
|
|
||||||
## Roadmap Coverage Summary
|
|
||||||
|
|
||||||
| Roadmap Area | Status | Evidence Level | UI Ready | Tested | Sellable | Notes |
|
|
||||||
|---|---|---:|---|---|---|---|
|
|
||||||
| R1 Golden Master Governance | adopted | strong | yes | repo tests, not run | yes | Baselines, Drift, Findings und OperationRun-Truth sind breit im Produkt verankert. |
|
|
||||||
| R2 Tenant Reviews, Evidence & Control Foundation | adopted | strong | yes | repo tests, not run | almost | Review-, Evidence- und Control-Foundations sind stark; Customer Review Workspace fehlt noch. |
|
|
||||||
| Alert escalation + notification routing | implemented_verified | strong | partial | repo tests, not run | yes | Alert-Regeln, Dispatch, Cooldown und Quiet Hours sind real. |
|
|
||||||
| Governance & Architecture Hardening | implemented_partial | strong | partial | repo tests, not run | foundation-only | Viele Hardening-Slices sind bereits im Code, die Lane bleibt aber aktiv. |
|
|
||||||
| UI & Product Maturity Polish | implemented_partial | medium | partial | partial repo tests, not run | no | Einzelne Polishing-Slices sind da, aber kein geschlossenes "fertig"-Signal auf Theme-Ebene. |
|
|
||||||
| Secret & Security Hardening | implemented_verified | strong | yes | repo tests, not run | almost | Provider-Verifikation, Permission-Diagnostics und Redaction sind belastbar. |
|
|
||||||
| Baseline Drift Engine (Cutover) | adopted | strong | yes | repo tests, not run | yes | Compare- und Drift-Workflow wirken als produktive Kernfunktion. |
|
|
||||||
| R1.9 Platform Localization v1 | planned | none | no | no | no | Keine belastbare Locale-Foundation im Repo gefunden. |
|
|
||||||
| Product Scalability & Self-Service Foundation | implemented_partial | strong | yes | repo tests, not run | almost | Onboarding, Support, Help und Entitlements sind weit; Billing, Trial und Demo-Reife fehlen. |
|
|
||||||
| R2.0 Canonical Control Catalog Foundation | implemented_verified | strong | partial | repo tests, not run | foundation-only | Bereits implementiert und in Evidence/Reviews referenziert, aber kein eigenstaendiger Kundennutzen-Surface. |
|
|
||||||
| R2 Completion: customer review, support, help | implemented_partial | strong | yes | repo tests, not run | almost | Support und Help sind real; kundensichere Review-Consumption ist noch offen. |
|
|
||||||
| Findings Workflow v2 / Execution Layer | implemented_partial | strong | yes | repo tests, not run | almost | Triage, Ownership, Alerts und Hygiene sind vorhanden; der naechste Operator-Layer fehlt. |
|
|
||||||
| Policy Lifecycle / Ghost Policies | specified | weak | no | no | no | Als Richtung sichtbar, aber nicht als repo-verifizierter Workflow. |
|
|
||||||
| Platform Operations Maturity | implemented_partial | strong | yes | repo tests, not run | almost | System Panel, Control Tower und Ops Controls sind real; CSV/Raw Drilldowns bleiben offen. |
|
|
||||||
| Product Usage, Customer Health & Operational Controls | adopted | strong | yes | repo tests, not run | almost | Diese Mid-term-Lane ist im Repo bereits substanziell vorhanden. |
|
|
||||||
| Private AI Execution & Usage Governance Foundation | planned | none | no | no | no | Keine belastbare AI-Governance-Foundation im Repo. |
|
|
||||||
| MSP Portfolio & Operations | implemented_partial | medium | partial | repo tests, not run | foundation-only | Portfolio-Triage ist da; Compare/Promotion und Decision Workboard fehlen. |
|
|
||||||
| Human-in-the-Loop Autonomous Governance | planned | none | no | no | no | Kein repo-verifizierter Decision-Pack- oder Approval-Workflow. |
|
|
||||||
| Drift & Change Governance | specified | weak | no | no | no | Einzelne Foundations existieren, die thematische Produkt-Lane aber nicht. |
|
|
||||||
| Standardization & Policy Quality | planned | none | no | no | no | Keine starke Repo-Evidence fuer eine Intune-Linting- oder Policy-Quality-Oberflaeche. |
|
|
||||||
| PSA / Ticketing Handoff | planned | none | no | no | no | Support Requests existieren, externe Handoff-Integration aber nicht. |
|
|
||||||
|
|
||||||
## Implemented Capabilities
|
|
||||||
|
|
||||||
| Capability | Status | Backend | UI | Tests | RBAC/Audit | Sellable | Evidence |
|
|
||||||
|---|---|---|---|---|---|---|---|
|
|
||||||
| OperationRun truth layer | implemented_verified | yes | partial | repo tests, not run | yes | foundation-only | `app/Models/OperationRun.php`; `tests/Feature/System/*`; `tests/Feature/ReviewPack/*` |
|
|
||||||
| Baseline profiles, snapshots and compare | implemented_verified | yes | yes | repo tests, not run | yes | yes | `app/Models/BaselineProfile.php`; `app/Models/BaselineSnapshot.php`; `app/Services/Baselines/BaselineCompareService.php` |
|
|
||||||
| Drift findings and governance pressure | adopted | yes | yes | repo tests, not run | yes | yes | `app/Models/Finding.php`; `app/Filament/Widgets/Dashboard/RecentDriftFindings.php`; `tests/Feature/Findings/*` |
|
|
||||||
| Restore workflow with safety gates | implemented_verified | yes | yes | repo tests, not run | yes | yes | `app/Models/OperationRun.php`; restore gates and tests in `tests/Feature/Restore/*` |
|
|
||||||
| Evidence snapshots | implemented_verified | yes | yes | repo tests, not run | yes | foundation-only | `app/Models/EvidenceSnapshot.php`; `app/Services/Evidence/EvidenceSnapshotService.php`; `tests/Feature/Evidence/*` |
|
|
||||||
| Tenant reviews | implemented_verified | yes | yes | repo tests, not run | yes | almost | `app/Models/TenantReview.php`; `app/Services/TenantReviews/TenantReviewService.php`; `tests/Feature/TenantReview/*` |
|
|
||||||
| Review pack generation and export | implemented_verified | yes | yes | repo tests, not run | yes | yes | `app/Models/ReviewPack.php`; `app/Services/ReviewPackService.php`; `tests/Feature/ReviewPack/*` |
|
|
||||||
| Alerts and notification routing | implemented_verified | yes | partial | repo tests, not run | yes | yes | `app/Services/Alerts/AlertDispatchService.php`; `tests/Feature/*Alert*` |
|
|
||||||
| Provider health, onboarding readiness and required permissions | adopted | yes | yes | repo tests, not run | yes | almost | `app/Jobs/ProviderConnectionHealthCheckJob.php`; `app/Services/Onboarding/OnboardingLifecycleService.php`; `app/Filament/Pages/TenantRequiredPermissions.php` |
|
|
||||||
| Permission posture reporting | implemented_verified | yes | yes | repo tests, not run | yes | yes | `app/Services/PermissionPosture/PermissionPostureFindingGenerator.php`; `tests/Feature/PermissionPosture/*` |
|
|
||||||
| Entra admin roles reporting | implemented_verified | yes | yes | repo tests, not run | yes | yes | `app/Services/EntraAdminRoles/EntraAdminRolesReportService.php`; `tests/Feature/EntraAdminRoles/*` |
|
|
||||||
| Stored reports substrate | implemented_verified | yes | partial | repo tests, not run | partial | foundation-only | `app/Models/StoredReport.php`; `tests/Feature/PermissionPosture/StoredReportModelTest.php`; `tests/Feature/EntraAdminRoles/StoredReportFingerprintTest.php` |
|
|
||||||
| Support diagnostics | adopted | yes | yes | repo tests, not run | yes | almost | `app/Support/SupportDiagnostics/SupportDiagnosticBundleBuilder.php`; `app/Filament/Pages/TenantDashboard.php`; `tests/Feature/SupportDiagnostics/*` |
|
|
||||||
| In-app support requests | implemented_verified | yes | yes | repo tests, not run | yes | almost | `app/Models/SupportRequest.php`; `app/Support/SupportRequests/*`; `tests/Feature/SupportRequests/*` |
|
|
||||||
| Product knowledge and contextual help | implemented_partial | yes | yes | repo tests, not run | partial | almost | `app/Support/ProductKnowledge/ContextualHelpCatalog.php`; `tests/Feature/Onboarding/ProductKnowledgeOnboardingHelpTest.php` |
|
|
||||||
| Product telemetry | implemented_verified | yes | yes | repo tests, not run | yes | almost | `app/Models/ProductUsageEvent.php`; `app/Filament/System/Widgets/ProductTelemetryKpis.php`; `tests/Feature/System/ProductTelemetry/*` |
|
|
||||||
| Customer health scoring | implemented_verified | yes | yes | repo tests, not run | partial | almost | `app/Filament/System/Widgets/CustomerHealthKpis.php`; `app/Filament/System/Widgets/CustomerHealthTopWorkspaces.php`; `tests/Feature/System/CustomerHealth/*` |
|
|
||||||
| Operational controls | implemented_verified | yes | yes | repo tests, not run | yes | almost | `app/Models/OperationalControlActivation.php`; `app/Support/OperationalControls/*`; `tests/Feature/System/OpsControls/*` |
|
|
||||||
| Workspace entitlements | implemented_verified | yes | yes | repo tests, not run | yes | foundation-only | `app/Services/Entitlements/WorkspaceEntitlementResolver.php`; `tests/Feature/Filament/Settings/WorkspaceEntitlementsSettingsPageTest.php` |
|
|
||||||
| Capability-first RBAC | adopted | yes | yes | repo tests, not run | yes | foundation-only | `app/Services/Auth/CapabilityResolver.php`; `app/Services/Auth/RoleCapabilityMap.php`; many `tests/Feature/Rbac/*` |
|
|
||||||
| Audit log foundation | adopted | yes | yes | repo tests, not run | yes | foundation-only | `app/Models/AuditLog.php`; `app/Services/Audit/WorkspaceAuditLogger.php`; many audit-focused feature tests |
|
|
||||||
| Canonical control catalog | implemented_verified | yes | partial | repo tests, not run | partial | foundation-only | `app/Support/Governance/Controls/CanonicalControlCatalog.php`; `config/canonical_controls.php`; `tests/Unit/Governance/*` |
|
|
||||||
| Portfolio triage continuity | implemented_verified | yes | yes | repo tests, not run | yes | foundation-only | `app/Services/PortfolioTriage/TenantTriageReviewService.php`; `app/Support/PortfolioTriage/*`; `tests/Feature/Filament/TenantRegistryTriageReviewStateTest.php` |
|
|
||||||
|
|
||||||
## Foundation-Only Capabilities
|
|
||||||
|
|
||||||
- OperationRun truth and canonical operation typing: starke Execution-Foundation, aber kein eigenstaendiger Kundennutzen-Surface.
|
|
||||||
- Audit log foundation: breit genutzt und wichtig fuer Governance, aber allein nicht verkaufbar.
|
|
||||||
- Capability-first RBAC: belastbar und testnah, bleibt aber Enablement-Layer.
|
|
||||||
- Workspace entitlements: reale Gate- und Override-Logik, aber noch keine volle Commercial Lifecycle Story.
|
|
||||||
- Canonical control catalog: starke semantische Foundation fuer Evidence, Findings und Reviews.
|
|
||||||
- Stored reports substrate: wichtig fuer Reports, Evidence und Diagnostics, aber kein eigenstaendiges Produktversprechen.
|
|
||||||
- Evidence snapshot substrate: tragende technische Basis fuer Reviews und Exports.
|
|
||||||
- Operational control registry and evaluator: starke Safety-Control-Foundation, primar operatorseitig.
|
|
||||||
- Customer health scoring: reale interne SaaS-Operations-Layer, aber noch keine eigenstaendige Kundenoberflaeche.
|
|
||||||
- Portfolio triage continuity: sinnvoller Multi-Tenant-Unterbau, aber noch kein vollstaendiges Portfolio-Produkt.
|
|
||||||
|
|
||||||
## Partial Capabilities
|
|
||||||
|
|
||||||
- Customer-facing review consumption: Tenant Reviews, Evidence Snapshots und Review Packs sind stark, aber ein repo-verifizierter Customer Review Workspace fehlt.
|
|
||||||
- Findings Workflow v2: Triage, Assignment, Hygiene und Notifications sind vorhanden, aber kein konsolidierter Decision-/Inbox-Layer.
|
|
||||||
- Product scalability and self-service: Onboarding, Support, Help und Entitlements sind weit, Billing-, Trial- und Demo-Reife aber nicht.
|
|
||||||
- MSP portfolio operations: Portfolio-Triage ist vorhanden, Cross-Tenant Compare und Promotion fehlen.
|
|
||||||
- Platform operations maturity: Control Tower und Ops Controls sind stark, aber einige geplante operatorseitige Drilldowns/Exports fehlen noch.
|
|
||||||
- Product knowledge rollout: Help-Katalog und Resolver sind real, aber noch nicht breit genug adoptiert fuer "fertig".
|
|
||||||
|
|
||||||
## Planned But Not Implemented
|
|
||||||
|
|
||||||
- Platform Localization v1
|
|
||||||
- Private AI Execution & Usage Governance Foundation
|
|
||||||
- Human-in-the-Loop Autonomous Governance
|
|
||||||
- Standardization & Policy Quality / Intune Linting
|
|
||||||
- PSA / Ticketing Handoff
|
|
||||||
- Customer Review Workspace v1
|
|
||||||
- Cross-Tenant Compare and Promotion v1
|
|
||||||
- Later compliance overlays beyond the current control/evidence foundation
|
|
||||||
|
|
||||||
## Release Readiness
|
|
||||||
|
|
||||||
| Release / Theme | Readiness | Notes |
|
|
||||||
|---|---|---|
|
|
||||||
| R1 Golden Master Governance | implemented | Die zentrale Governance- und Execution-Layer ist repo-verifiziert und breit adoptiert. |
|
|
||||||
| R2 Tenant Reviews & Evidence Packs | partially implemented | Reviews, Evidence Snapshots und Review Packs sind stark; kundensichere Consumption fehlt noch. |
|
|
||||||
| R3 MSP Portfolio OS | foundation only | Portfolio-Triage ist da, aber Compare/Promotion und Decision Workflows fehlen. |
|
|
||||||
| Later Compliance Light | foundation only | Canonical Controls, Evidence und Exceptions existieren als Grundlage; ein Compliance-Produkt ist nicht repo-proven. |
|
|
||||||
|
|
||||||
## Commercial Readiness
|
|
||||||
|
|
||||||
### Demo-ready
|
|
||||||
|
|
||||||
- Baseline compare and drift walkthroughs
|
|
||||||
- Review pack generation and export
|
|
||||||
- Provider health, onboarding readiness and required permissions
|
|
||||||
- Support diagnostics
|
|
||||||
- Permission posture and Entra admin roles reporting
|
|
||||||
|
|
||||||
### Almost sellable
|
|
||||||
|
|
||||||
- Review-driven governance workflow around tenant reviews and review packs
|
|
||||||
- Baseline drift and restore governance
|
|
||||||
- Alerting and run visibility for governance operations
|
|
||||||
- Support requests with contextual diagnostics
|
|
||||||
- Provider readiness and permission posture reporting
|
|
||||||
|
|
||||||
### Foundation-only
|
|
||||||
|
|
||||||
- OperationRun truth layer
|
|
||||||
- Audit foundation
|
|
||||||
- Capability-first RBAC
|
|
||||||
- Workspace entitlements
|
|
||||||
- Canonical control catalog
|
|
||||||
- Stored reports substrate
|
|
||||||
- Evidence snapshot substrate
|
|
||||||
- Product telemetry
|
|
||||||
- Customer health scoring
|
|
||||||
- Operational controls
|
|
||||||
- Portfolio triage continuity
|
|
||||||
|
|
||||||
### Not sellable yet
|
|
||||||
|
|
||||||
- Customer Review Workspace v1
|
|
||||||
- Cross-Tenant Compare and Promotion v1
|
|
||||||
- Localization v1
|
|
||||||
- Private AI Execution Governance Foundation
|
|
||||||
- External Support Desk / PSA Handoff
|
|
||||||
- Compliance Light product layer
|
|
||||||
|
|
||||||
## Open Gaps & Blockers
|
|
||||||
|
|
||||||
| Gap | Type | Impact | Roadmap Area | Recommended Spec |
|
|
||||||
|---|---|---|---|---|
|
|
||||||
| Customer-safe review workspace is missing | Release blocker | Existing review and evidence assets cannot yet be consumed as a clear customer-facing surface | R2 completion / Tenant Reviews | P0 Customer Review Workspace v1 |
|
|
||||||
| No consolidated operator decision inbox | UX blocker | Operators still move between findings, runs, alerts and portfolio surfaces to act | Findings Workflow / MSP Portfolio | P0 Decision-Based Governance Inbox v1 |
|
|
||||||
| Cross-tenant compare and promotion is not repo-proven | Release blocker | MSP portfolio story remains partial | MSP Portfolio & Operations | P1 Cross-Tenant Compare and Promotion v1 |
|
|
||||||
| Localization foundation is absent | UX blocker | Product polish and DACH-readiness remain limited | R1.9 Platform Localization v1 | P1 Localization v1 |
|
|
||||||
| Entitlements stop short of full commercial lifecycle | Commercialization blocker | Plan gating exists, but trial, grace and suspension semantics remain incomplete | Product Scalability & Self-Service Foundation | P2 Commercial Entitlements and Billing-State Maturity |
|
|
||||||
| Support requests do not hand off to an external desk | Commercialization blocker | Support operations still depend on manual follow-through outside the product | R2 completion / Support | P2 External Support Desk / PSA Handoff |
|
|
||||||
| AI governance foundation is absent | Architecture blocker | Future AI features would risk trust and policy drift if added directly | Private AI Execution & Usage Governance | P3 Private AI Execution Governance Foundation |
|
|
||||||
| Roadmap understates current repo truth | Architecture blocker | Prioritization can drift because strategy docs lag implementation | Product planning / roadmap maintenance | none - docs alignment |
|
|
||||||
| Test files were not executed for this ledger update | Testing blocker | This document relies on code plus test presence, not live runtime validation | all areas | none - run targeted suites |
|
|
||||||
|
|
||||||
## Recommended Next Specs
|
|
||||||
|
|
||||||
- `P0 Customer Review Workspace v1`: turns existing reviews, evidence and review-pack outputs into a customer-safe read-only product surface.
|
|
||||||
- `P0 Decision-Based Governance Inbox v1`: consolidates existing findings, runs, alerts and triage signals into one operator work surface.
|
|
||||||
- `P1 Cross-Tenant Compare and Promotion v1`: needed to move from portfolio visibility to portfolio action.
|
|
||||||
- `P1 Localization v1`: still absent in repo and becomes more expensive the later it lands.
|
|
||||||
- `P2 Commercial Entitlements and Billing-State Maturity`: extends the already real entitlement substrate into a usable commercial lifecycle.
|
|
||||||
- `P2 External Support Desk / PSA Handoff`: extends support requests beyond internal persistence.
|
|
||||||
- `P3 Private AI Execution Governance Foundation`: should exist before feature-level AI adoption, not after it.
|
|
||||||
|
|
||||||
## Roadmap Drift Notes
|
|
||||||
|
|
||||||
- `roadmap.md` understates the current R2 control foundation. Canonical controls, stored reports, permission posture and Entra admin roles are already repo-real, not just near-term ideas.
|
|
||||||
- `roadmap.md` understates product supportability. Support diagnostics, in-app support requests and contextual help already exist in the repo.
|
|
||||||
- `roadmap.md` understates operational maturity. Product telemetry, customer health and operational controls are already implemented and wired into the system panel.
|
|
||||||
- `roadmap.md` understates commercial foundations. A workspace entitlement resolver, plan profiles and enforcement points already exist, even though full billing-state maturity does not.
|
|
||||||
- The roadmap is stronger at describing missing customer-facing consumption than missing backend foundations. Customer Review Workspace v1, Cross-Tenant Compare and Promotion, Localization and AI Governance still look genuinely unimplemented.
|
|
||||||
- The main drift pattern is underestimation, not overestimation. The only place where optimism should still be resisted is customer-facing review maturity: internal review and evidence foundations are strong, but the repo does not yet prove a finished customer review workspace.
|
|
||||||
|
|
||||||
## Evidence Sources
|
|
||||||
|
|
||||||
Wichtigste Strategie- und Scope-Quellen:
|
|
||||||
|
|
||||||
- `docs/product/roadmap.md`
|
|
||||||
- `docs/product/spec-candidates.md`
|
|
||||||
|
|
||||||
Wichtige Plattform- und UI-Anker:
|
|
||||||
|
|
||||||
- `apps/platform/bootstrap/providers.php`
|
|
||||||
- `apps/platform/app/Providers/Filament/AdminPanelProvider.php`
|
|
||||||
- `apps/platform/app/Providers/Filament/SystemPanelProvider.php`
|
|
||||||
- `apps/platform/app/Filament/Pages/TenantDashboard.php`
|
|
||||||
- `apps/platform/app/Filament/System/Pages/Dashboard.php`
|
|
||||||
- `apps/platform/app/Filament/Pages/TenantRequiredPermissions.php`
|
|
||||||
|
|
||||||
Wichtige Models:
|
|
||||||
|
|
||||||
- `apps/platform/app/Models/OperationRun.php`
|
|
||||||
- `apps/platform/app/Models/Finding.php`
|
|
||||||
- `apps/platform/app/Models/FindingException.php`
|
|
||||||
- `apps/platform/app/Models/BaselineProfile.php`
|
|
||||||
- `apps/platform/app/Models/BaselineSnapshot.php`
|
|
||||||
- `apps/platform/app/Models/EvidenceSnapshot.php`
|
|
||||||
- `apps/platform/app/Models/TenantReview.php`
|
|
||||||
- `apps/platform/app/Models/ReviewPack.php`
|
|
||||||
- `apps/platform/app/Models/StoredReport.php`
|
|
||||||
- `apps/platform/app/Models/SupportRequest.php`
|
|
||||||
- `apps/platform/app/Models/ProductUsageEvent.php`
|
|
||||||
- `apps/platform/app/Models/OperationalControlActivation.php`
|
|
||||||
- `apps/platform/app/Models/AuditLog.php`
|
|
||||||
|
|
||||||
Wichtige Services und Jobs:
|
|
||||||
|
|
||||||
- `apps/platform/app/Services/ReviewPackService.php`
|
|
||||||
- `apps/platform/app/Services/TenantReviews/TenantReviewService.php`
|
|
||||||
- `apps/platform/app/Services/Evidence/EvidenceSnapshotService.php`
|
|
||||||
- `apps/platform/app/Services/Baselines/BaselineCompareService.php`
|
|
||||||
- `apps/platform/app/Services/Alerts/AlertDispatchService.php`
|
|
||||||
- `apps/platform/app/Jobs/ProviderConnectionHealthCheckJob.php`
|
|
||||||
- `apps/platform/app/Services/Onboarding/OnboardingLifecycleService.php`
|
|
||||||
- `apps/platform/app/Services/Entitlements/WorkspaceEntitlementResolver.php`
|
|
||||||
- `apps/platform/app/Services/PortfolioTriage/TenantTriageReviewService.php`
|
|
||||||
- `apps/platform/app/Support/Governance/Controls/CanonicalControlCatalog.php`
|
|
||||||
- `apps/platform/app/Services/Audit/WorkspaceAuditLogger.php`
|
|
||||||
- `apps/platform/app/Services/Auth/CapabilityResolver.php`
|
|
||||||
|
|
||||||
Wichtige Test-Anker im Repo:
|
|
||||||
|
|
||||||
- `apps/platform/tests/Feature/ReviewPack/*`
|
|
||||||
- `apps/platform/tests/Feature/Evidence/*`
|
|
||||||
- `apps/platform/tests/Feature/PermissionPosture/*`
|
|
||||||
- `apps/platform/tests/Feature/EntraAdminRoles/*`
|
|
||||||
- `apps/platform/tests/Feature/SupportDiagnostics/*`
|
|
||||||
- `apps/platform/tests/Feature/SupportRequests/*`
|
|
||||||
- `apps/platform/tests/Feature/System/CustomerHealth/*`
|
|
||||||
- `apps/platform/tests/Feature/System/ProductTelemetry/*`
|
|
||||||
- `apps/platform/tests/Feature/System/OpsControls/*`
|
|
||||||
- `apps/platform/tests/Feature/Filament/TenantRegistryTriageReviewStateTest.php`
|
|
||||||
- `apps/platform/tests/Unit/Governance/*`
|
|
||||||
- `apps/platform/tests/Unit/Entitlements/*`
|
|
||||||
|
|
||||||
## Last Updated
|
|
||||||
|
|
||||||
2026-04-27 on branch `248-private-ai-policy-foundation`
|
|
||||||
@ -104,52 +104,6 @@ ### Data minimization & safe logging
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Governance & Decision Model
|
|
||||||
|
|
||||||
### Decision-first surfaces (non-negotiable)
|
|
||||||
Every operator-facing surface must default to:
|
|
||||||
- Decision
|
|
||||||
- Reason
|
|
||||||
- Impact
|
|
||||||
- One primary next action
|
|
||||||
|
|
||||||
Diagnostics and evidence must be progressively disclosed.
|
|
||||||
|
|
||||||
### Surface layering (mandatory)
|
|
||||||
All operator surfaces must follow a strict layering model:
|
|
||||||
1. Decision layer (default-visible)
|
|
||||||
2. Diagnostic layer (expandable)
|
|
||||||
3. Evidence layer (deep, raw, or audit-level)
|
|
||||||
|
|
||||||
No surface may start at diagnostic or raw data level.
|
|
||||||
|
|
||||||
### Multiple truth layers (explicit separation)
|
|
||||||
The platform separates:
|
|
||||||
- **Execution truth** (OperationRun)
|
|
||||||
- **Artifact truth** (Reports, Evidence)
|
|
||||||
- **Backup truth** (Snapshots)
|
|
||||||
- **Governance truth** (Findings, Exceptions)
|
|
||||||
|
|
||||||
These layers must never be conflated or implicitly derived from each other.
|
|
||||||
|
|
||||||
### Governance-first model
|
|
||||||
The system models governance explicitly as:
|
|
||||||
- **Expected state** (Baselines)
|
|
||||||
- **Observed state** (Inventory / Evidence)
|
|
||||||
- **Deviations** (Findings)
|
|
||||||
- **Decisions** (Exceptions / Risk acceptance)
|
|
||||||
|
|
||||||
All governance workflows must align with this model.
|
|
||||||
|
|
||||||
### Baselines as reference truth
|
|
||||||
Baselines define the expected state.
|
|
||||||
All comparisons, drift detection, and governance decisions must reference an explicit baseline.
|
|
||||||
Implicit or “last state vs current state” comparisons are forbidden.
|
|
||||||
|
|
||||||
### No false calmness (strict)
|
|
||||||
Missing, stale, or partial data must be explicitly visible.
|
|
||||||
The system must never present a "healthy" or "complete" state without sufficient evidence.
|
|
||||||
|
|
||||||
## UI & Information Architecture
|
## UI & Information Architecture
|
||||||
|
|
||||||
### UI/UX constitution governs operator surfaces
|
### UI/UX constitution governs operator surfaces
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@ -1,50 +0,0 @@
|
|||||||
import json
|
|
||||||
import subprocess
|
|
||||||
import sys
|
|
||||||
import time
|
|
||||||
|
|
||||||
def send(proc, payload):
|
|
||||||
proc.stdin.write((json.dumps(payload) + "\n").encode("utf-8"))
|
|
||||||
proc.stdin.flush()
|
|
||||||
|
|
||||||
def read_line(proc, timeout=10.0):
|
|
||||||
start = time.time()
|
|
||||||
while time.time() - start < timeout:
|
|
||||||
line = proc.stdout.readline()
|
|
||||||
if line:
|
|
||||||
return line.decode("utf-8", errors="replace").strip()
|
|
||||||
time.sleep(0.05)
|
|
||||||
return ""
|
|
||||||
|
|
||||||
def main():
|
|
||||||
proc = subprocess.Popen(
|
|
||||||
["python3", "scripts/run-gitea-mcp.py"],
|
|
||||||
stdin=subprocess.PIPE,
|
|
||||||
stdout=subprocess.PIPE,
|
|
||||||
stderr=subprocess.PIPE,
|
|
||||||
)
|
|
||||||
try:
|
|
||||||
send(proc, {
|
|
||||||
"jsonrpc": "2.0",
|
|
||||||
"id": 1,
|
|
||||||
"method": "initialize",
|
|
||||||
"params": {
|
|
||||||
"protocolVersion": "2024-11-05",
|
|
||||||
"capabilities": {},
|
|
||||||
"clientInfo": {"name": "test", "version": "1.0.0"},
|
|
||||||
},
|
|
||||||
})
|
|
||||||
init_resp = read_line(proc)
|
|
||||||
send(proc, {
|
|
||||||
"jsonrpc": "2.0",
|
|
||||||
"id": 2,
|
|
||||||
"method": "tools/list",
|
|
||||||
"params": {}
|
|
||||||
})
|
|
||||||
tools_resp = read_line(proc)
|
|
||||||
print(tools_resp)
|
|
||||||
finally:
|
|
||||||
proc.terminate()
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
main()
|
|
||||||
@ -1,57 +0,0 @@
|
|||||||
# Specification Quality Checklist: Cross-Tenant Compare Preview and Promotion Preflight
|
|
||||||
|
|
||||||
**Purpose**: Validate full preparation-package completeness and implementation readiness before the feature moves into the implementation loop
|
|
||||||
**Created**: 2026-04-27
|
|
||||||
**Feature**: [spec.md](../spec.md)
|
|
||||||
|
|
||||||
## Content Quality
|
|
||||||
|
|
||||||
- [x] Business value and operator outcome stay explicit
|
|
||||||
- [x] The slice is tightly bounded to compare preview, promotion preflight, and portfolio launch continuity
|
|
||||||
- [x] Runtime-governance sections are present for an implementation-ready package
|
|
||||||
- [x] All mandatory sections are completed in `spec.md`, `plan.md`, and `tasks.md`
|
|
||||||
|
|
||||||
## Requirement Completeness
|
|
||||||
|
|
||||||
- [x] No `[NEEDS CLARIFICATION]` markers remain
|
|
||||||
- [x] Requirements are testable and unambiguous
|
|
||||||
- [x] Acceptance scenarios are defined for compare preview, read-only promotion preflight, and launch/return continuity
|
|
||||||
- [x] Edge cases are identified, including explicit rejection of same-tenant compare, cross-workspace attempts, lost entitlement, ambiguous identity, and stale target evidence
|
|
||||||
- [x] Scope is clearly bounded away from actual promotion execution, queues, persisted drafts, mapping automation, customer-facing compare, and multi-provider work
|
|
||||||
- [x] Dependencies, assumptions, risks, and follow-up candidates are identified
|
|
||||||
|
|
||||||
## Feature Readiness
|
|
||||||
|
|
||||||
- [x] The first slice is small enough for a bounded implementation loop
|
|
||||||
- [x] Concrete repo surfaces are named for compare reuse, portfolio launch, audit reuse, and likely new compare support files
|
|
||||||
- [x] Foundational work stays preparation-only and does not imply execution scope or new persistence
|
|
||||||
- [x] The tasks are ordered, testable, and grouped by user story
|
|
||||||
- [x] No unresolved product question blocks implementation once artifact analysis passes
|
|
||||||
|
|
||||||
## Governance Readiness
|
|
||||||
|
|
||||||
- [x] Workspace and tenant isolation rules are explicit, including `404` for non-members and out-of-scope tenants
|
|
||||||
- [x] The capability matrix is explicit: page access = `WORKSPACE_BASELINES_VIEW`, preview data = `TENANT_VIEW` on both tenants, preflight execution = `WORKSPACE_BASELINES_MANAGE`, and manage-denied members see a disabled preflight action with permission guidance
|
|
||||||
- [x] Promotion remains preflight-only, with no write execution, queue, or `OperationRun`
|
|
||||||
- [x] Audit remains bounded to promotion-preflight entry points with no new compare/promotion persistence truth
|
|
||||||
- [x] Livewire v4 and Filament v5 compliance, unchanged provider registration in `bootstrap/providers.php`, no new global-search resource, and no new asset strategy are explicit in the package
|
|
||||||
|
|
||||||
## Test Governance Review
|
|
||||||
|
|
||||||
- [x] Lane fit stays in focused `Unit` plus `Feature` validation only
|
|
||||||
- [x] Fixture and helper growth stays local to compare preview, preflight classification, and launch-context coverage
|
|
||||||
- [x] No browser, heavy-governance, or queue family is introduced implicitly
|
|
||||||
- [x] Minimal validation commands are explicit in the plan
|
|
||||||
- [x] The active feature PR close-out entry remains `Guardrail`
|
|
||||||
|
|
||||||
## Review Outcome
|
|
||||||
|
|
||||||
- [x] Review outcome class: `keep`
|
|
||||||
- [x] Workflow outcome: `keep`
|
|
||||||
- [x] Next command readiness: implementation prep is ready once artifact analysis is clear
|
|
||||||
|
|
||||||
## Notes
|
|
||||||
|
|
||||||
- This checklist validates the preparation package only: `spec.md`, `plan.md`, `tasks.md`, and this checklist artifact. It does not claim that runtime code or a promotion workflow already exists.
|
|
||||||
- The active slice stops before any target mutation, any queued execution, any persisted draft or compare snapshot, and any broader mapping automation.
|
|
||||||
- No new globally searchable resource is introduced, no new asset registration is expected, and deployment behavior remains unchanged unless a later implementation explicitly adds assets.
|
|
||||||
@ -1,210 +1,24 @@
|
|||||||
# Implementation Plan: Cross-Tenant Compare Preview and Promotion Preflight
|
# Implementation Plan: Cross-tenant Compare and Promotion
|
||||||
|
|
||||||
**Branch**: `043-cross-tenant-compare-and-promotion` | **Date**: 2026-04-27 | **Spec**: [spec.md](spec.md)
|
**Date**: 2026-01-07
|
||||||
**Input**: Feature specification from [spec.md](spec.md)
|
**Spec**: `specs/043-cross-tenant-compare-and-promotion/spec.md`
|
||||||
|
|
||||||
## Summary
|
## Summary
|
||||||
|
|
||||||
Refresh Spec 043 into a narrow, implementation-ready workflow that adds one canonical workspace-context compare page under `/admin`, one reusable compare preview builder, and one read-only promotion preflight action. The slice reuses existing baseline compare subject identity, portfolio-triage context continuity, capability resolvers, and workspace audit logging. It deliberately stops before actual promotion execution, queueing, or persisted promotion drafts.
|
Introduce read-only cross-tenant comparison views; optionally add promotion with strong safety gates.
|
||||||
|
|
||||||
Filament remains on Livewire v4, no panel-provider registration changes are required (`bootstrap/providers.php` remains the authoritative provider registration location), no globally searchable compare resource is added, and no new panel asset bundle is expected.
|
## Dependencies
|
||||||
|
|
||||||
## Technical Context
|
- Inventory core + UI (Specs 040–041)
|
||||||
|
- Strong authorization model for multi-tenant access
|
||||||
|
|
||||||
**Language/Version**: PHP 8.4, Laravel 12
|
## Deliverables
|
||||||
**Primary Dependencies**: Filament v5, Livewire v4, Pest v4, existing baseline compare services, portfolio-triage seams, audit services, and capability resolvers
|
|
||||||
**Storage**: PostgreSQL via existing inventory, policy-version, and audit tables; no new compare or promotion table
|
|
||||||
**Testing**: Pest v4 `Unit` and `Feature` coverage only
|
|
||||||
**Validation Lanes**: fast-feedback, confidence
|
|
||||||
**Target Platform**: Laravel monolith in `apps/platform`, admin panel only (`/admin`)
|
|
||||||
**Project Type**: Web application (Laravel monolith with Filament pages)
|
|
||||||
**Performance Goals**: compare preview and promotion preflight stay synchronous and derived from existing persisted truth; no background execution path in v1
|
|
||||||
**Constraints**: no target mutation, no `OperationRun`, no queue, no new persisted draft, no cross-workspace compare, no raw payload view by default
|
|
||||||
**Scale/Scope**: 2 tenant selectors, 1 canonical compare page, 1 preflight action, 1 launch/return continuity path, focused reuse of existing compare builders
|
|
||||||
|
|
||||||
## UI / Surface Guardrail Plan
|
- Tenant selection + comparison view
|
||||||
|
- Safe diff output and export
|
||||||
|
- (Optional) gated promotion workflow
|
||||||
|
|
||||||
- **Guardrail scope**: one new canonical compare page plus one launch action from existing tenant-registry/portfolio context
|
## Risks
|
||||||
- **Native vs custom classification summary**: native Filament page with shared compare/audit/navigation primitives
|
|
||||||
- **Shared-family relevance**: canonical admin pages, compare drill-down patterns, launch actions, audit-backed modal/action copy
|
|
||||||
- **State layers in scope**: page, query state
|
|
||||||
- **Audience modes in scope**: operator-MSP only
|
|
||||||
- **Decision/diagnostic/raw hierarchy plan**: decision-first compare summary, diagnostics second, raw evidence stays on existing tenant/baseline surfaces
|
|
||||||
- **Raw/support gating plan**: no new raw/support surface; keep payload proof behind existing pages
|
|
||||||
- **One-primary-action / duplicate-truth control**: the compare page keeps one dominant next action, `Generate promotion preflight`; drill-down and return actions stay secondary
|
|
||||||
- **Launch default**: the tenant-registry launch action prefills the launched tenant as `target tenant`; the operator chooses the source tenant explicitly
|
|
||||||
- **Handling modes by drift class or surface**: review-mandatory; any actual promotion execution or queue path is exception-required and out of scope
|
|
||||||
- **Repository-signal treatment**: review-mandatory
|
|
||||||
- **Special surface test profiles**: standard-native-filament
|
|
||||||
- **Required tests or manual smoke**: functional-core, state-contract
|
|
||||||
- **Exception path and spread control**: none
|
|
||||||
- **Active feature PR close-out entry**: Guardrail
|
|
||||||
|
|
||||||
## Shared Pattern & System Fit
|
- Data leakage across tenants
|
||||||
|
- Over-scoping promotion beyond safe MVP
|
||||||
- **Cross-cutting feature marker**: yes
|
|
||||||
- **Systems touched**:
|
|
||||||
- `App\Filament\Pages\BaselineCompareLanding`
|
|
||||||
- `App\Filament\Pages\BaselineCompareMatrix`
|
|
||||||
- `App\Filament\Resources\TenantResource`
|
|
||||||
- `App\Filament\Resources\TenantResource\Pages\ListTenants`
|
|
||||||
- `App\Services\Baselines\BaselineCompareService`
|
|
||||||
- `App\Support\Baselines\BaselineCompareMatrixBuilder`
|
|
||||||
- `App\Support\Baselines\Compare\CompareStrategyRegistry`
|
|
||||||
- `App\Services\PortfolioTriage\TenantTriageReviewService`
|
|
||||||
- `App\Services\Audit\WorkspaceAuditLogger`
|
|
||||||
- `App\Support\Audit\AuditActionId`
|
|
||||||
- `App\Support\Navigation\CanonicalNavigationContext`
|
|
||||||
- **Shared abstractions reused**: capability resolvers, baseline compare strategy selection, canonical navigation context, existing audit recorder/logger path, and tenant-registry return-state conventions
|
|
||||||
- **New abstraction introduced? why?**: one narrow compare preview builder and one narrow promotion preflight service, because no existing service accepts source+target tenant scope and computes promotion readiness without execution
|
|
||||||
- **Why the existing abstraction was sufficient or insufficient**: tenant-level baseline compare is sufficient for subject identity, evidence posture, and drill-down semantics, but insufficient for dual-tenant scope and promotion-readiness reasoning
|
|
||||||
- **Bounded deviation / spread control**: no local compare sidecars on tenant pages; future callers must route through the canonical compare page and its services
|
|
||||||
|
|
||||||
## OperationRun UX Impact
|
|
||||||
|
|
||||||
- **Touches OperationRun start/completion/link UX?**: no
|
|
||||||
- **Central contract reused**: `N/A`
|
|
||||||
- **Delegated UX behaviors**: `N/A`
|
|
||||||
- **Surface-owned behavior kept local**: compare preview and preflight remain synchronous and read-only
|
|
||||||
- **Queued DB-notification policy**: `N/A`
|
|
||||||
- **Terminal notification path**: `N/A`
|
|
||||||
- **Exception path**: none
|
|
||||||
|
|
||||||
## Provider Boundary & Portability Fit
|
|
||||||
|
|
||||||
- **Shared provider/platform boundary touched?**: yes
|
|
||||||
- **Provider-owned seams**: Microsoft-first inventory subject identity and policy-type mapping remain inside existing baseline compare strategy selection and inventory data
|
|
||||||
- **Platform-core seams**: source/target tenant scope, compare preview contract, promotion preflight contract, operator-facing readiness vocabulary
|
|
||||||
- **Neutral platform terms / contracts preserved**: `source tenant`, `target tenant`, `governed subject`, `compare preview`, `promotion preflight`, and `blocked reason`
|
|
||||||
- **Retained provider-specific semantics and why**: existing policy-type and inventory semantics remain Microsoft-first because this repo still has one real provider domain; the compare page should not invent fake provider-neutral mapping logic above that seam
|
|
||||||
- **Bounded extraction or follow-up path**: follow-up-spec only if later provider domains become current-release truth
|
|
||||||
|
|
||||||
## Constitution Check
|
|
||||||
|
|
||||||
*GATE: Must pass before implementation preparation continues.*
|
|
||||||
|
|
||||||
- Inventory-first: PASS. Compare preview and preflight derive from existing inventory and policy-version truth rather than a new compare snapshot.
|
|
||||||
- Read/write separation: PASS. This slice stays read-only; no write execution is introduced.
|
|
||||||
- Graph contract path: PASS. No new Graph endpoint or direct provider call is added.
|
|
||||||
- Deterministic capabilities: PASS. Reuse existing capability registries such as `Capabilities::TENANT_VIEW`, `Capabilities::WORKSPACE_BASELINES_VIEW`, `Capabilities::WORKSPACE_BASELINES_MANAGE`, and existing tenant sync/manage seams.
|
|
||||||
- Workspace and tenant isolation: PASS. The compare page must resolve workspace membership first and source/target entitlement second, with `404` for inaccessible tenants.
|
|
||||||
- RBAC-UX plane separation: PASS. This slice lives only in `/admin`; no `/system` or cross-plane route is introduced.
|
|
||||||
- Destructive action discipline: PASS by non-use. The slice contains no destructive action.
|
|
||||||
- Global search: PASS. No new Resource or Global Search result is introduced.
|
|
||||||
- OperationRun / Ops-UX: PASS by non-use. Actual promotion execution is deferred.
|
|
||||||
- Data minimization: PASS. The compare page summarizes derived readiness and blocks; raw payloads stay on existing tenant/baseline pages.
|
|
||||||
- Test governance: PASS. Proof stays in `Unit` plus `Feature`; no browser or heavy-governance expansion is planned.
|
|
||||||
- Proportionality / no premature abstraction: PASS. One preview builder and one preflight service are justified by the dual-tenant workflow; no new persistence or framework layer is added.
|
|
||||||
- Persisted truth: PASS. No new compare or promotion table.
|
|
||||||
- Behavioral state: PASS. Readiness and blocked reasons remain derived, not persisted.
|
|
||||||
- Shared pattern first / UI semantics / Filament-native UI: PASS. Existing compare, navigation, and audit paths are extended rather than replaced.
|
|
||||||
- Provider boundary: PASS. Microsoft-shaped subject matching stays in existing strategy seams; the page contract stays platform-neutral.
|
|
||||||
- Filament/Laravel panel safety: PASS. Filament v5 remains on Livewire v4, no provider registration change beyond `bootstrap/providers.php`, and no new assets are planned.
|
|
||||||
|
|
||||||
**Gate evaluation**: PASS.
|
|
||||||
|
|
||||||
## Test Governance Check
|
|
||||||
|
|
||||||
- **Test purpose / classification by changed surface**: `Feature` for the compare page, launch context, auth, and audit; `Unit` for compare preview matching and promotion-preflight classification
|
|
||||||
- **Affected validation lanes**: fast-feedback, confidence
|
|
||||||
- **Why this lane mix is the narrowest sufficient proof**: feature tests prove the Filament page and launch path while unit tests keep preview/preflight rules cheap and isolated. Browser or heavy-governance coverage is not required for the first read-only slice.
|
|
||||||
- **Narrowest proving command(s)**:
|
|
||||||
- `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Unit/Support/PortfolioCompare/CrossTenantComparePreviewBuilderTest.php`
|
|
||||||
- `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Unit/Support/PortfolioCompare/CrossTenantPromotionPreflightTest.php`
|
|
||||||
- `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/PortfolioCompare/CrossTenantComparePageTest.php`
|
|
||||||
- `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/PortfolioCompare/CrossTenantCompareAuthorizationTest.php`
|
|
||||||
- `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/PortfolioCompare/CrossTenantPromotionPreflightAuditTest.php`
|
|
||||||
- `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/PortfolioCompare/CrossTenantCompareLaunchContextTest.php`
|
|
||||||
- **Fixture / helper / factory / seed / context cost risks**: reuse existing inventory, baseline compare, tenant registry, and portfolio-triage fixtures; avoid browser setup, queue fixtures, or seeded promotion history
|
|
||||||
- **Expensive defaults or shared helper growth introduced?**: no
|
|
||||||
- **Heavy-family additions, promotions, or visibility changes**: none
|
|
||||||
- **Surface-class relief / special coverage rule**: standard-native-filament
|
|
||||||
- **Closing validation and reviewer handoff**: rerun the six focused commands above and confirm the slice remains read-only, deny-as-not-found-safe, and grounded on existing compare + portfolio seams
|
|
||||||
- **Budget / baseline / trend follow-up**: none expected
|
|
||||||
- **Review-stop questions**: lane fit, hidden fixture growth, accidental write execution, accidental queue/runtime scope
|
|
||||||
- **Escalation path**: `document-in-feature` for contained lane drift, `reject-or-split` for any attempt to add execution scope
|
|
||||||
- **Active feature PR close-out entry**: Guardrail
|
|
||||||
- **Why no dedicated follow-up spec is needed**: test upkeep remains feature-local; only actual promotion execution or multi-provider compare would warrant a separate follow-up spec
|
|
||||||
|
|
||||||
## Project Structure
|
|
||||||
|
|
||||||
### Documentation (this feature)
|
|
||||||
|
|
||||||
```text
|
|
||||||
specs/043-cross-tenant-compare-and-promotion/
|
|
||||||
├── checklists/
|
|
||||||
│ └── requirements.md
|
|
||||||
├── spec.md
|
|
||||||
├── plan.md
|
|
||||||
└── tasks.md
|
|
||||||
```
|
|
||||||
|
|
||||||
This refresh intentionally limits itself to the core preparation package plus `checklists/requirements.md`. No additional research/data-model/contracts artifact is required to make the narrowed slice implementation-ready.
|
|
||||||
|
|
||||||
### Source Code (repository root)
|
|
||||||
|
|
||||||
```text
|
|
||||||
apps/platform/
|
|
||||||
├── app/
|
|
||||||
│ ├── Filament/Pages/
|
|
||||||
│ │ ├── BaselineCompareLanding.php
|
|
||||||
│ │ ├── BaselineCompareMatrix.php
|
|
||||||
│ │ └── [new canonical compare page]
|
|
||||||
│ ├── Filament/Resources/TenantResource.php
|
|
||||||
│ ├── Filament/Resources/TenantResource/Pages/ListTenants.php
|
|
||||||
│ ├── Models/
|
|
||||||
│ │ ├── InventoryItem.php
|
|
||||||
│ │ └── PolicyVersion.php
|
|
||||||
│ ├── Services/Audit/
|
|
||||||
│ │ └── WorkspaceAuditLogger.php
|
|
||||||
│ ├── Services/Baselines/
|
|
||||||
│ │ └── BaselineCompareService.php
|
|
||||||
│ ├── Services/PortfolioTriage/
|
|
||||||
│ │ └── TenantTriageReviewService.php
|
|
||||||
│ ├── Support/Audit/AuditActionId.php
|
|
||||||
│ ├── Support/Baselines/
|
|
||||||
│ │ ├── BaselineCompareMatrixBuilder.php
|
|
||||||
│ │ └── Compare/CompareStrategyRegistry.php
|
|
||||||
│ └── Support/PortfolioCompare/ or Services/PortfolioCompare/
|
|
||||||
└── tests/
|
|
||||||
├── Feature/PortfolioCompare/
|
|
||||||
└── Unit/Support/PortfolioCompare/
|
|
||||||
```
|
|
||||||
|
|
||||||
**Structure Decision**: keep implementation inside `apps/platform`, reuse existing compare and portfolio seams, and introduce at most one small `PortfolioCompare` support/service namespace for the new dual-tenant preview/preflight logic.
|
|
||||||
|
|
||||||
## Complexity Tracking
|
|
||||||
|
|
||||||
| Violation | Why Needed | Simpler Alternative Rejected Because |
|
|
||||||
|-----------|------------|-------------------------------------|
|
|
||||||
| New compare preview builder | dual-tenant compare needs one place to translate existing inventory/baseline truth into a canonical preview contract | page-local mapping would duplicate compare logic and drift from existing baseline compare seams |
|
|
||||||
| New promotion preflight service | readiness reasoning must stay read-only and auditable before any execution path exists | bolting readiness rules into the page would make later reuse and testing brittle |
|
|
||||||
|
|
||||||
## Proportionality Review
|
|
||||||
|
|
||||||
- **Current operator problem**: portfolio operators still lack one bounded surface that answers whether a target tenant can follow a source tenant.
|
|
||||||
- **Existing structure is insufficient because**: existing baseline compare is tenant-vs-reference, not tenant-vs-tenant, and portfolio triage does not compute promotion readiness.
|
|
||||||
- **Narrowest correct implementation**: one canonical page plus one preview builder and one preflight service, no new table, no execution path.
|
|
||||||
- **Ownership cost created**: maintain a small preview/preflight contract and a focused test family.
|
|
||||||
- **Alternative intentionally rejected**: actual promotion execution, persisted promotion drafts, and local compare sidecars were rejected as premature.
|
|
||||||
- **Release truth**: current-release gap, not speculative platform work.
|
|
||||||
|
|
||||||
## Implementation Strategy
|
|
||||||
|
|
||||||
### Suggested MVP Scope
|
|
||||||
|
|
||||||
MVP = **US1 + US2 together**. A compare page without a promotion preflight leaves the core decision incomplete, and a preflight without a canonical compare page has no trustworthy operator context.
|
|
||||||
|
|
||||||
### Incremental Delivery
|
|
||||||
|
|
||||||
1. Reuse current compare, navigation, capability, and audit seams.
|
|
||||||
2. Deliver the canonical compare preview.
|
|
||||||
3. Add the read-only promotion preflight on top of the same page and services.
|
|
||||||
4. Add launch/return continuity from portfolio-triage and tenant-registry context.
|
|
||||||
5. Finish with narrow validation and formatting.
|
|
||||||
|
|
||||||
### Team Strategy
|
|
||||||
|
|
||||||
1. Settle the preview/preflight contracts first.
|
|
||||||
2. Parallelize unit tests for preview/preflight rules and feature tests for page/auth behavior.
|
|
||||||
3. Serialize merges around the canonical compare page and the shared `PortfolioCompare` service namespace so the page contract does not drift.
|
|
||||||
|
|||||||
@ -1,293 +1,59 @@
|
|||||||
# Feature Specification: Cross-Tenant Compare Preview and Promotion Preflight
|
# Feature Specification: Cross-tenant Compare and Promotion
|
||||||
|
|
||||||
**Feature Branch**: `043-cross-tenant-compare-and-promotion`
|
**Feature Branch**: `feat/043-cross-tenant-compare-and-promotion`
|
||||||
**Created**: 2026-01-07
|
**Created**: 2026-01-07
|
||||||
**Updated**: 2026-04-27
|
**Status**: Draft
|
||||||
**Status**: Ready for implementation
|
|
||||||
**Input**: Refresh existing Spec 043 against `docs/product/spec-candidates.md`, `docs/product/implementation-ledger.md`, and `docs/product/roadmap.md` so the feature becomes a narrow, implementation-ready slice instead of a broad future ambition.
|
|
||||||
|
|
||||||
## Spec Candidate Check *(mandatory - SPEC-GATE-001)*
|
## Purpose
|
||||||
|
|
||||||
- **Problem**: TenantPilot now has portfolio visibility, triage continuity, and strong tenant-level baseline compare surfaces, but operators still lack one canonical workspace-level path to compare a source tenant to a target tenant and prepare a safe promotion decision.
|
Enable safe cross-tenant comparison of inventory and, optionally, controlled promotion workflows.
|
||||||
- **Today's failure**: Operators can see that tenants differ, but they still reconstruct cross-tenant decisions manually across tenant registry, baseline compare, and tenant detail surfaces. Promotion remains a roadmap phrase, not a bounded product workflow.
|
|
||||||
- **User-visible improvement**: An authorized workspace operator can select a source and target tenant, review a structured compare preview of governed subjects, and generate a read-only promotion preflight that shows what is ready, blocked, or requires manual mapping before any write path exists.
|
|
||||||
- **Smallest enterprise-capable version**: One canonical `/admin` compare surface, one compare preview builder, one read-only promotion preflight action, deep links back to existing tenant and baseline compare surfaces, and bounded audit metadata for preflight entry points. No actual promotion execution ships in this slice.
|
|
||||||
- **Explicit non-goals**: No cutover, no write execution, no queue or `OperationRun`, no automatic target remapping of groups/tags/named locations, no cross-workspace compare, no customer-facing compare workspace, no provider marketplace, and no new persisted promotion draft entity.
|
|
||||||
- **Permanent complexity imported**: One canonical compare page, one narrow compare scope contract, one preview/preflight builder pair, one small audit metadata shape, and focused unit plus feature coverage.
|
|
||||||
- **Why now**: The implementation ledger explicitly identifies cross-tenant compare and promotion as one of the remaining real product gaps. It is the missing bridge between portfolio visibility and portfolio action.
|
|
||||||
- **Why not local**: A local compare action on one tenant page would duplicate entitlement, matching, audit, and promotion-readiness logic and would not create a reusable, canonical workspace workflow.
|
|
||||||
- **Approval class**: Workflow Compression
|
|
||||||
- **Red flags triggered**: New page + new compare/preflight service pair. Defense: the slice stays read-only, introduces no new table, reuses existing baseline compare and portfolio triage seams, and defers actual execution.
|
|
||||||
- **Score**: Nutzen: 2 | Dringlichkeit: 2 | Scope: 2 | Komplexitaet: 1 | Produktnaehe: 1 | Wiederverwendung: 2 | **Gesamt: 10/12**
|
|
||||||
- **Decision**: approve
|
|
||||||
|
|
||||||
## Spec Scope Fields *(mandatory)*
|
Comparison is read-only by default. Any write/promotion behavior must be explicitly gated, audited, and separately authorized.
|
||||||
|
|
||||||
- **Scope**: canonical-view
|
## User Scenarios & Testing
|
||||||
- **Primary Routes**:
|
|
||||||
- new canonical admin compare page under `/admin` for cross-tenant compare preview and promotion preflight
|
|
||||||
- existing `/admin/tenants` portfolio/registry surfaces as launch and return context
|
|
||||||
- existing tenant detail and baseline compare pages as secondary drill-down targets rather than duplicated local detail panes
|
|
||||||
- **Data Ownership**:
|
|
||||||
- compare preview and promotion preflight remain derived from existing tenant-owned inventory, policy-version, and baseline-compare truth
|
|
||||||
- no new compare snapshot, promotion draft, or mapping table is introduced in v1
|
|
||||||
- audit remains on the existing workspace audit log only
|
|
||||||
- **RBAC**:
|
|
||||||
- non-members or actors outside workspace scope receive `404`
|
|
||||||
- launch-action visibility requires established workspace context, `Capabilities::WORKSPACE_BASELINES_VIEW` on the workspace, and `Capabilities::TENANT_VIEW` on the launched tenant
|
|
||||||
- opening the compare page requires established workspace context and `Capabilities::WORKSPACE_BASELINES_VIEW` on the workspace
|
|
||||||
- loading preview data requires `Capabilities::TENANT_VIEW` on both source and target tenants
|
|
||||||
- executing promotion preflight requires the preview permissions plus `Capabilities::WORKSPACE_BASELINES_MANAGE` on the workspace
|
|
||||||
- for established members who can view compare but lack `Capabilities::WORKSPACE_BASELINES_MANAGE`, the preflight action remains visible but disabled with explicit permission help text; server-side attempts still return `403`
|
|
||||||
- the implementation must stay on existing capability registries instead of raw strings and must not introduce a new promotion capability family for this slice
|
|
||||||
|
|
||||||
For canonical-view specs, the spec MUST define:
|
### Scenario 1: Compare two tenants (read-only)
|
||||||
|
- Given the operator has access to Tenant A and Tenant B
|
||||||
|
- When they select two tenants and a set of policy types
|
||||||
|
- Then they can see differences in presence and key metadata
|
||||||
|
|
||||||
- **Default filter behavior when tenant-context is active**: if launched from the tenant registry or portfolio-triage context, prefill the launched tenant as the `target tenant`, leave the `source tenant` intentionally user-selected, and preserve a return context token.
|
### Scenario 2: Compare with a stable reference
|
||||||
- **Explicit entitlement checks preventing cross-tenant leakage**: the compare surface must validate workspace membership first, then validate both source and target tenant entitlement before any preview data loads. Any inaccessible tenant input is treated as not found.
|
- Given a reference selection scope
|
||||||
|
- When the operator runs comparison
|
||||||
|
- Then results are stable and reproducible for that scope
|
||||||
|
|
||||||
## Cross-Cutting / Shared Pattern Reuse *(mandatory when the feature touches notifications, status messaging, action links, header actions, dashboard signals/cards, navigation entry points, evidence/report viewers, or any other existing shared operator interaction family; otherwise write `N/A - no shared interaction family touched`)*
|
### Scenario 3: Promotion is explicitly gated (optional)
|
||||||
|
- Given promotion is enabled by policy
|
||||||
|
- When the operator initiates promotion
|
||||||
|
- Then the system requires explicit confirmation and records an audit event
|
||||||
|
|
||||||
- **Cross-cutting feature?**: yes
|
## Functional Requirements
|
||||||
- **Interaction class(es)**: navigation entry points, compare/drill-down actions, audit metadata, and canonical workspace-context pages
|
|
||||||
- **Systems touched**: `ListTenants`, portfolio-triage state, `CanonicalNavigationContext`, `BaselineCompareLanding`, `BaselineCompareMatrix`, `BaselineCompareService`, `CompareStrategyRegistry`, `WorkspaceAuditLogger`, and `AuditActionId`
|
|
||||||
- **Existing pattern(s) to extend**: canonical `/admin` workspace-context pages, baseline compare preview patterns, portfolio-triage return-state patterns, and existing workspace audit metadata patterns
|
|
||||||
- **Shared contract / presenter / builder / renderer to reuse**: `CanonicalNavigationContext`, `ActionSurfaceDeclaration`, `BaselineCompareService`, `BaselineCompareMatrixBuilder`, `CompareStrategyRegistry`, `TenantTriageReviewService`, and `WorkspaceAuditLogger`
|
|
||||||
- **Why the existing shared path is sufficient or insufficient**: existing tenant-level baseline compare surfaces already solve stable subject matching, result framing, and drill-down semantics, but they are insufficient for cross-tenant compare because they do not accept dual-tenant scope or produce a promotion-readiness preflight.
|
|
||||||
- **Allowed deviation and why**: none. The new surface should extend current compare and navigation patterns, not invent a parallel compare UX family.
|
|
||||||
- **Consistency impact**: source tenant, target tenant, compare preview, promotion preflight, blocked reason, and ready/manual mapping language must stay consistent across page copy, modal copy, audit prose, and deep links.
|
|
||||||
- **Review focus**: reviewers must block new local compare widgets or tenant-specific preflight sidecars that bypass the canonical compare page or its shared preview/preflight services.
|
|
||||||
|
|
||||||
## OperationRun UX Impact *(mandatory when the feature creates, queues, deduplicates, resumes, blocks, completes, or deep-links to an `OperationRun`; otherwise write `N/A - no OperationRun start or link semantics touched`)*
|
- FR1: Support selecting two tenants within authorized scope.
|
||||||
|
- FR2: Provide read-only diff views based on inventory metadata and stable identifiers.
|
||||||
|
- FR3: Provide exportable comparison results.
|
||||||
|
- FR4: If promotion is included:
|
||||||
|
- require explicit enablement
|
||||||
|
- require explicit confirmation per operation
|
||||||
|
- record audit logs
|
||||||
|
- support dry-run/preview
|
||||||
|
|
||||||
- **Touches OperationRun start/completion/link UX?**: no
|
## Non-Functional Requirements
|
||||||
- **Shared OperationRun UX contract/layer reused**: `N/A`
|
|
||||||
- **Delegated start/completion UX behaviors**: `N/A`
|
|
||||||
- **Local surface-owned behavior that remains**: compare preview and promotion preflight stay synchronous and read-only in v1
|
|
||||||
- **Queued DB-notification policy**: `N/A`
|
|
||||||
- **Terminal notification path**: `N/A`
|
|
||||||
- **Exception required?**: none
|
|
||||||
|
|
||||||
## Provider Boundary / Platform Core Check *(mandatory when the feature changes shared provider/platform seams, identity scope, governed-subject taxonomy, compare strategy selection, provider connection descriptors, or operator vocabulary that may leak provider-specific semantics into platform-core truth; otherwise write `N/A - no shared provider/platform boundary touched`)*
|
- NFR1: Enforce tenant isolation and least privilege across tenant selection and data access.
|
||||||
|
- NFR2: Comparison must not expose secrets or unsafe payload fields.
|
||||||
- **Shared provider/platform boundary touched?**: yes
|
|
||||||
- **Boundary classification**: mixed
|
|
||||||
- **Seams affected**: compare subject identity, compare strategy reuse, promotion preflight reason vocabulary, and operator-facing compare terminology
|
|
||||||
- **Neutral platform terms preserved or introduced**: `source tenant`, `target tenant`, `governed subject`, `compare preview`, `promotion preflight`, `mapping gap`, and `blocked reason`
|
|
||||||
- **Provider-specific semantics retained and why**: Microsoft-first policy-type and inventory semantics remain inside existing compare strategy and inventory seams because the repo currently has one real provider domain. They should not leak deeper into the page contract than necessary.
|
|
||||||
- **Why this does not deepen provider coupling accidentally**: the page and services stay anchored on existing compare registries and inventory identifiers instead of inventing Microsoft-specific page contracts or raw Graph payload handling.
|
|
||||||
- **Follow-up path**: future multi-provider compare remains a separate follow-up spec if it ever becomes current-release truth.
|
|
||||||
|
|
||||||
## UI / Surface Guardrail Impact *(mandatory when operator-facing surfaces are changed; otherwise write `N/A`)*
|
|
||||||
|
|
||||||
| Surface / Change | Operator-facing surface change? | Native vs Custom | Shared-Family Relevance | State Layers Touched | Exception Needed? | Low-Impact / `N/A` Note |
|
|
||||||
|---|---|---|---|---|---|---|
|
|
||||||
| Canonical cross-tenant compare page | yes | Native Filament page plus shared compare primitives | compare preview, navigation, audit-backed preflight action | page, query state, compare summary, modal/action state | no | Reuses baseline compare language and drill-down patterns instead of a custom standalone shell |
|
|
||||||
| Tenant registry / portfolio launch action | yes | Native Filament action | navigation entry point, contextual launch | table state, query/deep-link state | no | Extends existing portfolio-triage return-state handling |
|
|
||||||
| Actual promotion execution surface | no | N/A | none | none | no | `N/A - explicitly deferred` |
|
|
||||||
|
|
||||||
## Decision-First Surface Role *(mandatory when operator-facing surfaces are changed)*
|
|
||||||
|
|
||||||
| Surface | Decision Role | Human-in-the-loop Moment | Immediately Visible for First Decision | On-Demand Detail / Evidence | Why This Is Primary or Why Not | Workflow Alignment | Attention-load Reduction |
|
|
||||||
|---|---|---|---|---|---|---|---|
|
|
||||||
| Canonical cross-tenant compare page | Primary Decision Surface | Operator decides whether the target tenant is ready for promotion planning or still blocked by scope and mapping gaps | source/target summary, ready/blocked/manual counts, top blockers, and next action | tenant drill-down, baseline compare drill-down, subject-level diagnostics | Primary because it is the first canonical workspace place where cross-tenant action becomes decidable | Moves from portfolio triage into compare and preflight without manual reconstruction | Replaces cross-page mental diffing with one bounded decision surface |
|
|
||||||
| Tenant registry / portfolio launch action | Secondary Context | Operator chooses when to leave the tenant registry for compare | current tenant context and preserved return state | compare details live on the compare page | Secondary because it launches the decision surface rather than hosting it | Keeps portfolio review flow intact | Reduces repeated tenant re-selection and filter loss |
|
|
||||||
|
|
||||||
## Audience-Aware Disclosure *(mandatory when operator-facing surfaces are changed)*
|
|
||||||
|
|
||||||
| Surface | Audience Modes In Scope | Decision-First Default-Visible Content | Operator Diagnostics | Support / Raw Evidence | One Dominant Next Action | Hidden / Gated By Default | Duplicate-Truth Prevention |
|
|
||||||
|---|---|---|---|---|---|---|---|
|
|
||||||
| Canonical cross-tenant compare page | operator-MSP | source/target summary, compare counts, preflight readiness summary, top blocked reasons | subject-level mapping gaps and deep links to tenant-specific evidence | raw payloads remain on existing tenant/baseline pages, not this surface | `Generate promotion preflight` | raw JSON, provider IDs, and low-level evidence stay behind existing detail pages | compare page states the decision truth once; drill-down pages add proof rather than rephrasing the same blocker |
|
|
||||||
| Tenant registry / portfolio launch action | operator-MSP | current tenant context and compare launch intent | return-state token only | none | `Compare tenants` | any future write action remains absent | launch action does not duplicate compare summaries on the registry row |
|
|
||||||
|
|
||||||
## UI/UX Surface Classification *(mandatory when operator-facing surfaces are changed)*
|
|
||||||
|
|
||||||
| Surface | Action Surface Class | Surface Type | Likely Next Operator Action | Primary Inspect/Open Model | Row Click | Secondary Actions Placement | Destructive Actions Placement | Canonical Collection Route | Canonical Detail Route | Scope Signals | Canonical Noun | Critical Truth Visible by Default | Exception Type / Justification |
|
|
||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
|
|
||||||
| Canonical cross-tenant compare page | Utility / Workspace Decision | Draft apply analysis | Generate promotion preflight or open drill-down evidence | explicit selectors plus focused compare/preflight panels | forbidden | drill-down links and secondary navigation stay below the summary/preflight sections | none in v1 | new canonical `/admin` compare route | same page with shareable query state | workspace context plus source/target tenant chips | Cross-tenant compare | whether the target is ready, blocked, or needs manual mapping | none |
|
|
||||||
| Tenant registry / portfolio launch action | List / Table / Launch Context | Launch context support | Open compare with current tenant prefilled | explicit action from tenant list or triage context | preserved existing row behavior | compare entry is a safe secondary action | none | `/admin/tenants` | compare route | current workspace and tenant | Tenant registry | why the action launches compare, not promotion | existing tenant registry action hierarchy remains valid |
|
|
||||||
|
|
||||||
## Operator Surface Contract *(mandatory when operator-facing surfaces are changed)*
|
|
||||||
|
|
||||||
| Surface | Primary Persona | Decision / Operator Action Supported | Surface Type | Primary Operator Question | Default-visible Information | Diagnostics-only Information | Status Dimensions Used | Mutation Scope | Primary Actions | Dangerous Actions |
|
|
||||||
|---|---|---|---|---|---|---|---|---|---|---|
|
|
||||||
| Canonical cross-tenant compare page | Workspace operator / MSP operator | Decide whether a target tenant is ready for a later promotion workflow | Canonical decision page | Can this target tenant safely follow the selected source tenant for the chosen governed subjects? | source/target summary, compare counts, blocked reasons, ready/manual counts, and next action | subject-level mappings, stale evidence signals, and deep links to existing tenant compare/detail surfaces | compare state, readiness, mapping confidence, evidence freshness | TenantPilot only in v1 | Generate promotion preflight, open source tenant, open target tenant | none |
|
|
||||||
| Tenant registry / portfolio launch action | Workspace operator / MSP operator | Start compare from an existing portfolio review path | Registry action | Which tenant should I compare next without losing context? | current tenant identity and compare launch intent | preserved triage filters and return token | launch context only | none | Compare tenants | none |
|
|
||||||
|
|
||||||
## Proportionality Review *(mandatory when structural complexity is introduced)*
|
|
||||||
|
|
||||||
- **New source of truth?**: no
|
|
||||||
- **New persisted entity/table/artifact?**: no
|
|
||||||
- **New abstraction?**: yes - one narrow compare preview builder and one narrow promotion preflight service
|
|
||||||
- **New enum/state/reason family?**: no new persisted state family; readiness and blocked reasons remain derived from compare/preflight results
|
|
||||||
- **New cross-domain UI framework/taxonomy?**: no
|
|
||||||
- **Current operator problem**: operators can identify tenants that need attention but cannot reach a trustworthy cross-tenant decision without manual reconstruction.
|
|
||||||
- **Existing structure is insufficient because**: existing tenant-level baseline compare pages and portfolio triage state do not support dual-tenant scope or promotion-readiness reasoning.
|
|
||||||
- **Narrowest correct implementation**: derive compare preview and promotion preflight from existing inventory/baseline truth, keep the page canonical and read-only, and audit only the preflight entry points.
|
|
||||||
- **Ownership cost**: maintain one compare page, one preview builder, one preflight service, and a handful of focused tests.
|
|
||||||
- **Alternative intentionally rejected**: actual promotion execution and persisted draft plans were rejected because they would add write risk, queue semantics, and new truth before the compare/preflight workflow is proven.
|
|
||||||
- **Release truth**: current-release workflow gap, not future-release platform speculation
|
|
||||||
|
|
||||||
### Compatibility posture
|
|
||||||
|
|
||||||
This feature assumes a pre-production environment.
|
|
||||||
|
|
||||||
Backward compatibility, legacy aliases, migration shims, historical fixtures, and compatibility-specific tests are out of scope unless explicitly required by this spec.
|
|
||||||
|
|
||||||
Canonical replacement is preferred over preservation.
|
|
||||||
|
|
||||||
## Testing / Lane / Runtime Impact *(mandatory for runtime behavior changes)*
|
|
||||||
|
|
||||||
- **Test purpose / classification**: Unit, Feature
|
|
||||||
- **Validation lane(s)**: fast-feedback, confidence
|
|
||||||
- **Why this classification and these lanes are sufficient**: unit coverage proves preview matching and promotion-preflight classification without Filament overhead, while focused feature coverage proves page rendering, launch context, audit, and `404`/`403` semantics on the canonical compare surface.
|
|
||||||
- **New or expanded test families**: one focused `PortfolioCompare` feature family and one focused `Unit/Support/PortfolioCompare` family
|
|
||||||
- **Fixture / helper cost impact**: moderate; reuse existing tenant, workspace, inventory, baseline compare, and portfolio-triage fixtures instead of adding browser setup or queue scaffolding
|
|
||||||
- **Heavy-family visibility / justification**: none; do not widen this slice into browser or heavy-governance lanes by default
|
|
||||||
- **Special surface test profile**: standard-native-filament
|
|
||||||
- **Standard-native relief or required special coverage**: ordinary feature coverage is sufficient for the page and launch actions; a small unit test set must prove preflight classification and no-write semantics
|
|
||||||
- **Reviewer handoff**: reviewers must confirm that the slice stays read-only, reuses baseline compare and portfolio seams, preserves deny-as-not-found semantics for inaccessible tenants, and does not smuggle in actual promotion execution
|
|
||||||
- **Budget / baseline / trend impact**: low increase in unit + feature only
|
|
||||||
- **Escalation needed**: none
|
|
||||||
- **Active feature PR close-out entry**: Guardrail
|
|
||||||
- **Planned validation commands**:
|
|
||||||
- `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Unit/Support/PortfolioCompare/CrossTenantComparePreviewBuilderTest.php`
|
|
||||||
- `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Unit/Support/PortfolioCompare/CrossTenantPromotionPreflightTest.php`
|
|
||||||
- `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/PortfolioCompare/CrossTenantComparePageTest.php`
|
|
||||||
- `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/PortfolioCompare/CrossTenantCompareAuthorizationTest.php`
|
|
||||||
- `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/PortfolioCompare/CrossTenantPromotionPreflightAuditTest.php`
|
|
||||||
- `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/PortfolioCompare/CrossTenantCompareLaunchContextTest.php`
|
|
||||||
|
|
||||||
## Scope Boundaries
|
|
||||||
|
|
||||||
### In Scope
|
|
||||||
|
|
||||||
- one canonical workspace-context compare page for source/target tenant selection
|
|
||||||
- read-only compare preview using stable governed-subject identity and existing compare strategy patterns
|
|
||||||
- one read-only promotion preflight action that classifies ready, blocked, and manual-mapping subjects
|
|
||||||
- workspace audit metadata for preflight entry points
|
|
||||||
- launch and return continuity from portfolio-triage/tenant-registry context
|
|
||||||
- deep links to existing tenant and baseline compare detail pages instead of duplicated proof surfaces
|
|
||||||
|
|
||||||
### Non-Goals
|
|
||||||
|
|
||||||
- actual promotion execution or target mutation
|
|
||||||
- queueing, retries, or `OperationRun`
|
|
||||||
- persisted compare snapshots or promotion draft tables
|
|
||||||
- automatic mapping writers for groups, scope tags, filters, named locations, or app references
|
|
||||||
- customer-facing review or compare surfaces
|
|
||||||
- cross-workspace compare
|
|
||||||
- multi-provider compare frameworks
|
|
||||||
|
|
||||||
## Assumptions
|
|
||||||
|
|
||||||
- existing inventory and baseline compare seams already provide enough stable subject identity to drive a first compare preview
|
|
||||||
- current portfolio-triage return-state patterns are sufficient for launch and back-navigation continuity
|
|
||||||
- a read-only preflight is valuable before any write path exists and can be audited without introducing a second persistence truth
|
|
||||||
|
|
||||||
## Risks
|
|
||||||
|
|
||||||
- some compare subjects may still need provider-specific mapping logic before they can produce a trustworthy readiness result
|
|
||||||
- target inventory freshness or missing evidence may block preflight more often than expected and needs explicit reasoning on the page
|
|
||||||
- a later implementation could try to add actual promotion execution inside this slice; that must be rejected as scope growth
|
|
||||||
|
|
||||||
## Follow-up Candidates
|
|
||||||
|
|
||||||
- Cross-tenant promotion execution with preview -> confirmation -> queued run -> verify
|
|
||||||
- Managed mapping workflows for named locations, assignments, groups, and filters
|
|
||||||
- Cross-tenant decision inbox integration after compare/preflight exists
|
|
||||||
|
|
||||||
## User Scenarios & Testing *(mandatory)*
|
|
||||||
|
|
||||||
### User Story 1 - Compare two authorized tenants (Priority: P1)
|
|
||||||
|
|
||||||
As a workspace operator, I want to compare one source tenant to one target tenant from a canonical workspace surface so I can see where governed subjects match, differ, or are missing without reconstructing the answer manually.
|
|
||||||
|
|
||||||
**Why this priority**: This is the smallest valuable slice that turns portfolio visibility into a concrete operator decision surface.
|
|
||||||
|
|
||||||
**Independent Test**: Open the compare page with two authorized tenants, choose governed-subject filters, and verify that the compare preview shows reproducible ready/different/missing results and drill-down links.
|
|
||||||
|
|
||||||
**Acceptance Scenarios**:
|
|
||||||
|
|
||||||
1. **Given** an operator has access to both selected tenants, **When** they open the compare page and run the preview, **Then** they see a structured compare summary grouped by governed-subject state rather than a raw payload diff.
|
|
||||||
2. **Given** the same source and target selection, **When** the operator reloads or shares the preview URL, **Then** the compare state is reproducible for the same scoped selection.
|
|
||||||
3. **Given** the operator selects the same tenant as both source and target, **When** they try to run the preview, **Then** the page rejects the selection as invalid and does not produce compare or preflight output.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### User Story 2 - Generate a promotion preflight without writing (Priority: P1)
|
|
||||||
|
|
||||||
As a workspace operator, I want a read-only promotion preflight that tells me what is ready, blocked, or needs manual mapping before any cross-tenant write path exists.
|
|
||||||
|
|
||||||
**Why this priority**: Promotion language is not trustworthy until the product can explain why a target is or is not ready in a bounded, auditable way.
|
|
||||||
|
|
||||||
**Independent Test**: From an authorized compare preview, trigger the preflight action and verify that the page shows readiness counts, blocked reasons, and manual-mapping requirements without mutating source or target tenants.
|
|
||||||
|
|
||||||
**Acceptance Scenarios**:
|
|
||||||
|
|
||||||
1. **Given** a compare preview contains subjects with stable identity and usable target conditions, **When** the operator generates a promotion preflight, **Then** those subjects appear as ready with a clear explanation.
|
|
||||||
2. **Given** some subjects are missing identifiers, stale, or blocked by target conditions, **When** the operator generates the preflight, **Then** those subjects appear as blocked or manual-mapping-required with explicit reasons.
|
|
||||||
3. **Given** the operator generates a preflight, **When** the action completes, **Then** no target mutation, queued run, or provider write occurs.
|
|
||||||
4. **Given** the operator can view compare but lacks `WORKSPACE_BASELINES_MANAGE`, **When** they reach the compare page, **Then** the preflight action is visibly disabled with permission guidance and any forced request is rejected server-side.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### User Story 3 - Launch compare from portfolio context without losing return state (Priority: P2)
|
|
||||||
|
|
||||||
As a workspace operator, I want to enter compare from the tenant registry or portfolio-triage context and return without losing my working filters so compare becomes part of the portfolio workflow instead of a detached utility.
|
|
||||||
|
|
||||||
**Why this priority**: The workflow is much less useful if compare starts from scratch and breaks the operator's portfolio-review context.
|
|
||||||
|
|
||||||
**Independent Test**: Launch compare from the tenant registry with active triage filters, verify one tenant is prefilled, and verify the return path restores the prior registry state.
|
|
||||||
|
|
||||||
**Acceptance Scenarios**:
|
|
||||||
|
|
||||||
1. **Given** the tenant registry has active portfolio-triage filters, **When** the operator launches compare from a tenant row or contextual action, **Then** the compare page preserves a return token and prefills the launched tenant as the `target tenant`.
|
|
||||||
2. **Given** the operator returns from compare, **When** the registry reloads, **Then** the prior triage filters are restored.
|
|
||||||
|
|
||||||
### Edge Cases
|
|
||||||
|
|
||||||
- source and target tenant are the same tenant: reject the selection as invalid input and do not compute preview or preflight
|
|
||||||
- source and target tenants belong to different workspaces
|
|
||||||
- one selected tenant is no longer visible or never belonged to the actor's scope
|
|
||||||
- compare subjects have ambiguous identity or duplicate matches
|
|
||||||
- target evidence is stale or missing, making readiness impossible to prove
|
|
||||||
|
|
||||||
## Requirements *(mandatory)*
|
|
||||||
|
|
||||||
### Functional Requirements
|
|
||||||
|
|
||||||
- **FR1**: The feature MUST provide one canonical workspace-context compare surface for selecting source and target tenants.
|
|
||||||
- **FR2**: The feature MUST enforce workspace membership and source/target tenant entitlement before loading compare data; inaccessible tenants resolve as `404`.
|
|
||||||
- **FR3**: The compare preview MUST use stable governed-subject identity and existing inventory/baseline compare seams rather than raw JSON diffing.
|
|
||||||
- **FR4**: The compare preview MUST stay read-only and MUST deep-link to existing tenant or baseline detail surfaces for proof instead of duplicating raw diagnostics locally.
|
|
||||||
- **FR5**: The feature MUST provide a read-only promotion preflight action that classifies subjects as ready, blocked, or manual-mapping-required.
|
|
||||||
- **FR6**: The preflight MUST NOT execute a target write, queue a run, or persist a promotion draft artifact.
|
|
||||||
- **FR7**: The preflight MUST explain blocked and manual states with explicit operator-readable reasons.
|
|
||||||
- **FR8**: The feature MUST reuse existing capability registries with this exact split: page access = `WORKSPACE_BASELINES_VIEW`, preview data = `TENANT_VIEW` on both tenants, preflight execution = `WORKSPACE_BASELINES_MANAGE`.
|
|
||||||
- **FR9**: The feature MUST preserve launch and return continuity from the tenant registry / portfolio-triage path.
|
|
||||||
- **FR10**: The feature MUST record bounded workspace audit metadata for promotion-preflight entry points only.
|
|
||||||
- **FR11**: The compare page MUST reject same-tenant selection before preview or preflight runs.
|
|
||||||
|
|
||||||
### Non-Functional Requirements
|
|
||||||
|
|
||||||
- **NFR1**: The feature MUST preserve workspace and tenant isolation and MUST NOT leak source or target hints to unauthorized actors.
|
|
||||||
- **NFR2**: The compare page MUST remain operator-first, decision-first, and must not expose raw payloads by default.
|
|
||||||
- **NFR3**: The implementation MUST remain Filament-native on Livewire v4 and must not introduce a second compare shell or custom status framework.
|
|
||||||
- **NFR4**: The slice MUST not introduce new assets or new globally searchable resources.
|
|
||||||
|
|
||||||
## Success Criteria
|
## Success Criteria
|
||||||
|
|
||||||
- **SC1**: An authorized operator can produce a cross-tenant compare preview from one canonical page without switching across multiple tenant detail surfaces.
|
- SC1: Operators can identify which tenant differs for a given policy type in under 2 minutes.
|
||||||
- **SC2**: The same source, target, and filter selection produces reproducible compare output.
|
- SC2: Read-only comparisons are reproducible when run again with the same scope.
|
||||||
- **SC3**: A promotion preflight clearly separates ready, blocked, and manual subjects without performing any write.
|
|
||||||
- **SC4**: Unauthorized source/target combinations remain deny-as-not-found.
|
## Out of Scope
|
||||||
- **SC5**: View-only members can inspect compare results but cannot execute preflight, and the UI makes that boundary explicit.
|
|
||||||
|
- Bulk remediation without preview/confirmation.
|
||||||
|
|
||||||
## Related Specs
|
## Related Specs
|
||||||
|
|
||||||
- Program: `specs/039-inventory-program/spec.md`
|
- Program: `specs/039-inventory-program/spec.md`
|
||||||
- Core: `specs/040-inventory-core/spec.md`
|
- Core: `specs/040-inventory-core/spec.md`
|
||||||
- UI: `specs/041-inventory-ui/spec.md`
|
|
||||||
- Drift: `specs/044-drift-mvp/spec.md`
|
- Drift: `specs/044-drift-mvp/spec.md`
|
||||||
- Foundation follow-up context: `docs/product/spec-candidates.md` (`Cross-Tenant Compare and Promotion v1`)
|
|
||||||
|
|||||||
@ -1,190 +1,7 @@
|
|||||||
---
|
# Tasks: Cross-tenant Compare and Promotion
|
||||||
|
|
||||||
description: "Task list for Cross-Tenant Compare Preview and Promotion Preflight"
|
- [ ] T001 Define authorized tenant selection rules
|
||||||
|
- [ ] T002 Read-only compare UI and diff rules
|
||||||
---
|
- [ ] T003 Export capability for comparison results
|
||||||
|
- [ ] T004 If enabled: promotion workflow with preview + confirm + audit
|
||||||
# Tasks: Cross-Tenant Compare Preview and Promotion Preflight
|
- [ ] T005 Tests: tenant isolation, authorization, reproducibility
|
||||||
|
|
||||||
**Input**: Design documents from `/Users/ahmeddarrazi/Documents/projects/wt-plattform/specs/043-cross-tenant-compare-and-promotion/`
|
|
||||||
**Prerequisites**: `/Users/ahmeddarrazi/Documents/projects/wt-plattform/specs/043-cross-tenant-compare-and-promotion/plan.md` (required), `/Users/ahmeddarrazi/Documents/projects/wt-plattform/specs/043-cross-tenant-compare-and-promotion/spec.md` (required)
|
|
||||||
|
|
||||||
**Tests**: REQUIRED (Pest) for runtime behavior changes. Keep proof in narrow `Unit` plus `Feature` lanes only; do not add browser or heavy-governance coverage by default for this first read-only slice.
|
|
||||||
**Operations**: No new `OperationRun`, queue, retry, monitoring page, or execution ledger is introduced. Promotion remains preflight-only.
|
|
||||||
**RBAC**: Existing workspace and tenant membership semantics remain authoritative. Non-members or actors lacking source or target tenant entitlement receive `404`; members who reach the canonical compare surface but lack the required capability receive `403`. Page access uses `Capabilities::WORKSPACE_BASELINES_VIEW`, preview data uses `Capabilities::TENANT_VIEW` on both tenants, and preflight execution adds `Capabilities::WORKSPACE_BASELINES_MANAGE`.
|
|
||||||
**Provider Boundary**: The page contract stays platform-neutral (`source tenant`, `target tenant`, `governed subject`, `promotion preflight`) while reusing Microsoft-first inventory and baseline compare seams under the hood.
|
|
||||||
**Organization**: Tasks are grouped by user story so compare preview, promotion preflight, and portfolio launch continuity remain independently testable once the shared contracts exist.
|
|
||||||
|
|
||||||
## Test Governance Checklist
|
|
||||||
|
|
||||||
- [ ] Lane assignment stays `Unit` plus `Feature` and remains the narrowest sufficient proof for the changed behavior.
|
|
||||||
- [ ] New or changed tests stay in `apps/platform/tests/Unit/Support/PortfolioCompare/` and `apps/platform/tests/Feature/PortfolioCompare/` only.
|
|
||||||
- [ ] Shared helpers, fixtures, and context defaults stay cheap by default; do not add browser setup, queue scaffolding, or seeded promotion history.
|
|
||||||
- [ ] Planned validation commands cover compare preview, promotion preflight, launch continuity, audit, and authorization without widening scope.
|
|
||||||
- [ ] The declared surface test profile remains `standard-native-filament` because the slice adds one canonical page and one launch action on existing surfaces.
|
|
||||||
- [ ] Any deferred execution, mapping automation, or multi-provider follow-up resolves as `document-in-feature` or `follow-up-spec`, not hidden scope growth.
|
|
||||||
|
|
||||||
## Phase 1: Setup (Shared Context)
|
|
||||||
|
|
||||||
**Purpose**: Confirm the narrowed slice, the reusable compare seams, and the reviewer stop conditions before implementation begins.
|
|
||||||
|
|
||||||
- [ ] T001 Review the narrowed compare-preview and promotion-preflight slice in `/Users/ahmeddarrazi/Documents/projects/wt-plattform/specs/043-cross-tenant-compare-and-promotion/spec.md` and `/Users/ahmeddarrazi/Documents/projects/wt-plattform/specs/043-cross-tenant-compare-and-promotion/plan.md` together with the current candidate and ledger references.
|
|
||||||
- [ ] T002 [P] Confirm the compare and subject-identity seams that this slice must reuse in `apps/platform/app/Filament/Pages/BaselineCompareLanding.php`, `apps/platform/app/Filament/Pages/BaselineCompareMatrix.php`, `apps/platform/app/Services/Baselines/BaselineCompareService.php`, `apps/platform/app/Support/Baselines/BaselineCompareMatrixBuilder.php`, `apps/platform/app/Support/Baselines/Compare/CompareStrategyRegistry.php`, `apps/platform/app/Models/InventoryItem.php`, and `apps/platform/app/Models/PolicyVersion.php`.
|
|
||||||
- [ ] T003 [P] Confirm the portfolio launch, authorization, and audit seams that this slice must reuse in `apps/platform/app/Filament/Resources/TenantResource.php`, `apps/platform/app/Filament/Resources/TenantResource/Pages/ListTenants.php`, `apps/platform/app/Services/PortfolioTriage/TenantTriageReviewService.php`, `apps/platform/app/Services/Audit/WorkspaceAuditLogger.php`, `apps/platform/app/Support/Audit/AuditActionId.php`, `apps/platform/app/Services/Auth/CapabilityResolver.php`, and `apps/platform/app/Services/Auth/WorkspaceCapabilityResolver.php`.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Phase 2: Foundational (Blocking Prerequisites)
|
|
||||||
|
|
||||||
**Purpose**: Add the shared compare scope and promotion-preflight primitives that every user story depends on.
|
|
||||||
|
|
||||||
**Critical**: No user-story work should begin until this phase is complete.
|
|
||||||
|
|
||||||
- [ ] T004 [P] Define the minimal compare-page input/output shape inside `apps/platform/app/Support/PortfolioCompare/` or `apps/platform/app/Services/PortfolioCompare/` for source tenant, target tenant, governed-subject filters, and preflight output without adding a wider DTO or resolver framework.
|
|
||||||
- [ ] T005 [P] Implement source-plus-target entitlement checks inside the canonical compare page and shared preview/preflight services using the existing capability resolvers so workspace membership, source entitlement, target entitlement, and capability denial all follow existing `404`/`403` semantics.
|
|
||||||
- [ ] T006 Implement the compare preview builder so it reuses stable governed-subject identity from existing inventory and baseline compare seams and produces a canonical preview summary without storing new compare truth.
|
|
||||||
- [ ] T007 Implement the promotion-preflight service so it classifies governed subjects as ready, blocked, or manual-mapping-required and explicitly performs no target mutation, queue dispatch, or `OperationRun` creation.
|
|
||||||
- [ ] T008 [P] Add bounded preflight audit action IDs and metadata shaping in `apps/platform/app/Support/Audit/AuditActionId.php` and `apps/platform/app/Services/Audit/WorkspaceAuditLogger.php` for promotion-preflight entry points only.
|
|
||||||
|
|
||||||
**Checkpoint**: Shared compare scope, entitlement resolution, preview building, preflight classification, and audit metadata exist; user stories can proceed independently.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Phase 3: User Story 1 - Compare Two Authorized Tenants (Priority: P1) MVP
|
|
||||||
|
|
||||||
**Goal**: Give an authorized workspace operator one canonical compare page that shows a reproducible source-vs-target preview without cross-page reconstruction.
|
|
||||||
|
|
||||||
**Independent Test**: Open the compare page with two authorized tenants, apply governed-subject filters, and verify that the preview shows match/difference/missing states plus drill-down links.
|
|
||||||
|
|
||||||
### Tests for User Story 1
|
|
||||||
|
|
||||||
- [ ] T009 [P] [US1] Add feature coverage for rendering the canonical compare page, selecting source and target tenants, rejecting same-tenant selection, and showing one default-visible compare summary with no duplicate decision truth or raw/support evidence in `apps/platform/tests/Feature/PortfolioCompare/CrossTenantComparePageTest.php`.
|
|
||||||
- [ ] T010 [P] [US1] Add feature coverage for `404` vs `403` semantics across source/target entitlement, workspace `WORKSPACE_BASELINES_VIEW`, tenant `TENANT_VIEW`, visible-disabled preflight UX for members lacking `WORKSPACE_BASELINES_MANAGE`, and server-side denial of forced preflight requests in `apps/platform/tests/Feature/PortfolioCompare/CrossTenantCompareAuthorizationTest.php`.
|
|
||||||
- [ ] T011 [P] [US1] Add unit coverage for compare preview subject matching and reproducible summary output in `apps/platform/tests/Unit/Support/PortfolioCompare/CrossTenantComparePreviewBuilderTest.php`.
|
|
||||||
|
|
||||||
### Implementation for User Story 1
|
|
||||||
|
|
||||||
- [ ] T012 [US1] Add the canonical compare page under `apps/platform/app/Filament/Pages/` with source/target selectors, governed-subject filters, shareable query state, and compare preview summary built from the shared preview builder.
|
|
||||||
- [ ] T013 [US1] Reuse existing baseline compare and inventory seams so the compare page deep-links to tenant-level proof surfaces instead of duplicating raw diagnostics.
|
|
||||||
- [ ] T014 [US1] Keep page copy, chips, and summary wording aligned to `source tenant`, `target tenant`, `governed subject`, and `compare preview` rather than Microsoft-first or execution-first vocabulary.
|
|
||||||
|
|
||||||
**Checkpoint**: User Story 1 is independently functional when the canonical page produces a reproducible compare preview for two authorized tenants.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Phase 4: User Story 2 - Generate a Read-Only Promotion Preflight (Priority: P1)
|
|
||||||
|
|
||||||
**Goal**: Let the operator ask whether the chosen target is ready for a later promotion workflow without performing any write.
|
|
||||||
|
|
||||||
**Independent Test**: From an authorized compare preview, trigger the preflight action and verify that the page shows ready, blocked, and manual-mapping-required groups without mutating target data.
|
|
||||||
|
|
||||||
### Tests for User Story 2
|
|
||||||
|
|
||||||
- [ ] T015 [P] [US2] Add unit coverage for preflight classification across ready, blocked, manual-mapping, stale-evidence, and missing-identifier cases in `apps/platform/tests/Unit/Support/PortfolioCompare/CrossTenantPromotionPreflightTest.php`.
|
|
||||||
- [ ] T016 [P] [US2] Add feature coverage for the compare page's `Generate promotion preflight` action, visible-disabled manage-denial UX, one dominant next action, visible readiness summary, and no default-visible raw/support evidence in `apps/platform/tests/Feature/PortfolioCompare/CrossTenantComparePageTest.php`.
|
|
||||||
- [ ] T017 [P] [US2] Add feature coverage for preflight audit metadata and explicit no-write semantics in `apps/platform/tests/Feature/PortfolioCompare/CrossTenantPromotionPreflightAuditTest.php`.
|
|
||||||
|
|
||||||
### Implementation for User Story 2
|
|
||||||
|
|
||||||
- [ ] T018 [US2] Add the read-only `Generate promotion preflight` action to the canonical compare page, keeping it distinct from any future execution action and free of queue/runtime side effects.
|
|
||||||
- [ ] T019 [US2] Render a promotion-preflight summary that groups governed subjects into ready, blocked, and manual-mapping-required buckets with explicit operator-readable reasons.
|
|
||||||
- [ ] T020 [US2] Route preflight entry-point audit through the existing workspace audit pipeline with source tenant, target tenant, subject counts, and blocked-reason metadata only.
|
|
||||||
|
|
||||||
**Checkpoint**: User Story 2 is independently functional when the operator can generate an audited, read-only readiness decision from the compare page.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Phase 5: User Story 3 - Launch Compare from Portfolio Context Without Losing State (Priority: P2)
|
|
||||||
|
|
||||||
**Goal**: Make compare part of the portfolio workflow by preserving the launch tenant and return state from the tenant registry / portfolio-triage path.
|
|
||||||
|
|
||||||
**Independent Test**: Launch compare from the tenant registry with active triage filters, verify the launched tenant is prefilled, and verify the return path restores the prior registry state.
|
|
||||||
|
|
||||||
### Tests for User Story 3
|
|
||||||
|
|
||||||
- [ ] T021 [P] [US3] Add feature coverage for compare launch and return continuity from the tenant registry / portfolio-triage path in `apps/platform/tests/Feature/PortfolioCompare/CrossTenantCompareLaunchContextTest.php`.
|
|
||||||
- [ ] T022 [P] [US3] Extend authorization coverage so launch actions only appear or resolve when the current actor is entitled to the launched tenant and the compare surface in `apps/platform/tests/Feature/PortfolioCompare/CrossTenantCompareAuthorizationTest.php`.
|
|
||||||
|
|
||||||
### Implementation for User Story 3
|
|
||||||
|
|
||||||
- [ ] T023 [US3] Add a bounded launch action from `apps/platform/app/Filament/Resources/TenantResource.php` or `apps/platform/app/Filament/Resources/TenantResource/Pages/ListTenants.php` that opens the canonical compare page with the current tenant prefilled as the `target tenant`.
|
|
||||||
- [ ] T024 [US3] Preserve and restore portfolio-triage return state using the existing navigation-context pattern rather than a page-local custom token format.
|
|
||||||
|
|
||||||
**Checkpoint**: User Story 3 is independently functional when compare can be launched from portfolio context and the operator can return without losing triage filters.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Phase 6: Polish & Cross-Cutting Concerns
|
|
||||||
|
|
||||||
**Purpose**: Finish narrow validation and reviewer close-out without widening scope.
|
|
||||||
|
|
||||||
- [ ] T025 [P] Run the focused unit validation commands for `apps/platform/tests/Unit/Support/PortfolioCompare/CrossTenantComparePreviewBuilderTest.php` and `apps/platform/tests/Unit/Support/PortfolioCompare/CrossTenantPromotionPreflightTest.php`.
|
|
||||||
- [ ] T026 [P] Run the focused feature validation commands for `apps/platform/tests/Feature/PortfolioCompare/CrossTenantComparePageTest.php`, `apps/platform/tests/Feature/PortfolioCompare/CrossTenantCompareAuthorizationTest.php`, `apps/platform/tests/Feature/PortfolioCompare/CrossTenantPromotionPreflightAuditTest.php`, and `apps/platform/tests/Feature/PortfolioCompare/CrossTenantCompareLaunchContextTest.php`.
|
|
||||||
- [ ] T027 Run dirty-only formatting for touched platform files with `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail bin pint --dirty --format agent`.
|
|
||||||
- [ ] T028 [P] Add or update the checklist/reviewer guard confirming that this slice introduces no new asset registration and no globally searchable resource.
|
|
||||||
- [ ] T029 Record TEST-GOV-001 close-out and any `document-in-feature` or `follow-up-spec` deferrals for actual execution, mapping automation, or multi-provider compare in the active feature PR or implementation notes.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Dependencies & Execution Order
|
|
||||||
|
|
||||||
### Phase Dependencies
|
|
||||||
|
|
||||||
- **Phase 1 (Setup)**: no dependencies; start immediately.
|
|
||||||
- **Phase 2 (Foundational)**: depends on Phase 1 and blocks all user stories.
|
|
||||||
- **Phase 3 (US1)**: depends on Phase 2 and establishes the canonical compare truth.
|
|
||||||
- **Phase 4 (US2)**: depends on Phase 2 and should ship with US1 because compare without readiness reasoning leaves promotion language vague.
|
|
||||||
- **Phase 5 (US3)**: depends on Phase 2 and is safest after US1 because the canonical compare page must exist before launch continuity can target it.
|
|
||||||
- **Phase 6 (Polish)**: depends on all desired user stories being complete.
|
|
||||||
|
|
||||||
### User Story Dependencies
|
|
||||||
|
|
||||||
- **US1 (P1)**: independently testable after Phase 2 and forms the MVP decision surface.
|
|
||||||
- **US2 (P1)**: independently testable after Phase 2 and should ship with US1 for a complete P1 slice.
|
|
||||||
- **US3 (P2)**: independently testable after Phase 2 and improves portfolio workflow continuity once the canonical page exists.
|
|
||||||
|
|
||||||
### Within Each User Story
|
|
||||||
|
|
||||||
- Write the listed Pest coverage first and make it fail for the intended behavior gap.
|
|
||||||
- Settle the shared preview/preflight service contract before adding or widening page wiring.
|
|
||||||
- Re-run the narrowest affected validation command after each story checkpoint before moving to the next story.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Parallel Execution Examples
|
|
||||||
|
|
||||||
### User Story 1
|
|
||||||
|
|
||||||
- T009, T010, and T011 can run in parallel before runtime edits begin.
|
|
||||||
- After the preview contract settles, T012 and T013 can proceed in parallel because page wiring and compare-service reuse touch different seams; T014 should follow both.
|
|
||||||
|
|
||||||
### User Story 2
|
|
||||||
|
|
||||||
- T015, T016, and T017 can run in parallel because they cover separate unit, page, and audit concerns.
|
|
||||||
- After T018 settles the action shape, T019 and T020 can proceed in parallel because UI rendering and audit metadata touch different seams.
|
|
||||||
|
|
||||||
### User Story 3
|
|
||||||
|
|
||||||
- T021 and T022 can run in parallel before implementation starts.
|
|
||||||
- T023 should land before T024 so return-state handling can target the final launch route.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Implementation Strategy
|
|
||||||
|
|
||||||
### Suggested MVP Scope
|
|
||||||
|
|
||||||
- MVP = **US1 + US2 together**. The feature is only product-complete when the operator can compare two tenants and immediately ask whether that comparison is promotion-ready.
|
|
||||||
|
|
||||||
### Incremental Delivery
|
|
||||||
|
|
||||||
1. Complete Phase 1 and Phase 2.
|
|
||||||
2. Deliver US1 and US2 together.
|
|
||||||
3. Add US3 launch and return continuity.
|
|
||||||
4. Finish with narrow validation and formatting in Phase 6.
|
|
||||||
|
|
||||||
### Team Strategy
|
|
||||||
|
|
||||||
1. Finish the preview/preflight contracts together before splitting page work.
|
|
||||||
2. Parallelize unit and feature test authoring inside each story first.
|
|
||||||
3. Serialize merges around the canonical compare page and shared `PortfolioCompare` service namespace so the workflow language stays coherent.
|
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue
Block a user