Compare commits
6 Commits
246-suppor
...
dev
| Author | SHA1 | Date | |
|---|---|---|---|
| 7ee4909212 | |||
| 72bfb37ba7 | |||
| aacd82849a | |||
| ff3392892b | |||
| e222845a36 | |||
| 6e3736a53f |
8
.github/agents/copilot-instructions.md
vendored
8
.github/agents/copilot-instructions.md
vendored
@ -260,6 +260,10 @@ ## 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)
|
||||||
|
|
||||||
@ -294,9 +298,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
|
||||||
|
|||||||
@ -0,0 +1,24 @@
|
|||||||
|
<?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;
|
||||||
|
}
|
||||||
|
}
|
||||||
494
apps/platform/app/Filament/Pages/Governance/GovernanceInbox.php
Normal file
494
apps/platform/app/Filament/Pages/Governance/GovernanceInbox.php
Normal file
@ -0,0 +1,494 @@
|
|||||||
|
<?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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -6,6 +6,7 @@
|
|||||||
|
|
||||||
use App\Filament\Resources\OperationRunResource;
|
use App\Filament\Resources\OperationRunResource;
|
||||||
use App\Models\OperationRun;
|
use App\Models\OperationRun;
|
||||||
|
use App\Models\SupportRequest;
|
||||||
use App\Models\Tenant;
|
use App\Models\Tenant;
|
||||||
use App\Models\User;
|
use App\Models\User;
|
||||||
use App\Services\Audit\WorkspaceAuditLogger;
|
use App\Services\Audit\WorkspaceAuditLogger;
|
||||||
@ -30,6 +31,7 @@
|
|||||||
use App\Support\RestoreSafety\RestoreSafetyCopy;
|
use App\Support\RestoreSafety\RestoreSafetyCopy;
|
||||||
use App\Support\Rbac\UiEnforcement;
|
use App\Support\Rbac\UiEnforcement;
|
||||||
use App\Support\SupportDiagnostics\SupportDiagnosticBundleBuilder;
|
use App\Support\SupportDiagnostics\SupportDiagnosticBundleBuilder;
|
||||||
|
use App\Support\SupportRequests\SupportRequestSubmissionService;
|
||||||
use App\Support\Tenants\ReferencedTenantLifecyclePresentation;
|
use App\Support\Tenants\ReferencedTenantLifecyclePresentation;
|
||||||
use App\Support\Tenants\TenantInteractionLane;
|
use App\Support\Tenants\TenantInteractionLane;
|
||||||
use App\Support\Tenants\TenantOperabilityQuestion;
|
use App\Support\Tenants\TenantOperabilityQuestion;
|
||||||
@ -40,6 +42,10 @@
|
|||||||
use App\Support\Ui\OperatorExplanation\OperatorExplanationPattern;
|
use App\Support\Ui\OperatorExplanation\OperatorExplanationPattern;
|
||||||
use Filament\Actions\Action;
|
use Filament\Actions\Action;
|
||||||
use Filament\Actions\ActionGroup;
|
use Filament\Actions\ActionGroup;
|
||||||
|
use Filament\Forms\Components\Placeholder;
|
||||||
|
use Filament\Forms\Components\Select;
|
||||||
|
use Filament\Forms\Components\TextInput;
|
||||||
|
use Filament\Forms\Components\Textarea;
|
||||||
use Filament\Notifications\Notification;
|
use Filament\Notifications\Notification;
|
||||||
use Filament\Pages\Page;
|
use Filament\Pages\Page;
|
||||||
use Filament\Schemas\Components\EmbeddedSchema;
|
use Filament\Schemas\Components\EmbeddedSchema;
|
||||||
@ -141,10 +147,6 @@ protected function getHeaderActions(): array
|
|||||||
? OperationRunLinks::tenantlessView($this->run, $navigationContext)
|
? OperationRunLinks::tenantlessView($this->run, $navigationContext)
|
||||||
: OperationRunLinks::index());
|
: OperationRunLinks::index());
|
||||||
|
|
||||||
if (isset($this->run)) {
|
|
||||||
$actions[] = $this->openSupportDiagnosticsAction();
|
|
||||||
}
|
|
||||||
|
|
||||||
if (! isset($this->run)) {
|
if (! isset($this->run)) {
|
||||||
return $actions;
|
return $actions;
|
||||||
}
|
}
|
||||||
@ -167,6 +169,14 @@ protected function getHeaderActions(): array
|
|||||||
->color('gray');
|
->color('gray');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
$actions[] = ActionGroup::make([
|
||||||
|
$this->openSupportDiagnosticsAction(),
|
||||||
|
$this->requestSupportAction(),
|
||||||
|
])
|
||||||
|
->label('More')
|
||||||
|
->icon('heroicon-o-ellipsis-horizontal')
|
||||||
|
->color('gray');
|
||||||
|
|
||||||
$actions[] = $this->resumeCaptureAction();
|
$actions[] = $this->resumeCaptureAction();
|
||||||
|
|
||||||
return $actions;
|
return $actions;
|
||||||
@ -228,8 +238,6 @@ private function openSupportDiagnosticsAction(): Action
|
|||||||
$action = Action::make('openSupportDiagnostics')
|
$action = Action::make('openSupportDiagnostics')
|
||||||
->label('Open support diagnostics')
|
->label('Open support diagnostics')
|
||||||
->icon('heroicon-o-lifebuoy')
|
->icon('heroicon-o-lifebuoy')
|
||||||
->iconButton()
|
|
||||||
->tooltip('Open support diagnostics')
|
|
||||||
->color('gray')
|
->color('gray')
|
||||||
->record($this->run)
|
->record($this->run)
|
||||||
->modal()
|
->modal()
|
||||||
@ -251,39 +259,85 @@ private function openSupportDiagnosticsAction(): Action
|
|||||||
->apply();
|
->apply();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function authorizeOperationRunSupportRequest(): void
|
||||||
|
{
|
||||||
|
$this->resolveRunTenantForCapability(Capabilities::SUPPORT_REQUESTS_CREATE);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function requestSupportAction(): Action
|
||||||
|
{
|
||||||
|
$action = Action::make('requestSupport')
|
||||||
|
->label('Request support')
|
||||||
|
->icon('heroicon-o-paper-airplane')
|
||||||
|
->record($this->run)
|
||||||
|
->slideOver()
|
||||||
|
->stickyModalHeader()
|
||||||
|
->modalHeading('Request support')
|
||||||
|
->modalDescription('Share a concise summary and TenantAtlas will attach redacted context from the current run.')
|
||||||
|
->modalSubmitActionLabel('Submit support request')
|
||||||
|
->form([
|
||||||
|
Placeholder::make('primary_context')
|
||||||
|
->label('Primary context')
|
||||||
|
->content(fn (): string => OperationRunLinks::identifier($this->run))
|
||||||
|
->columnSpanFull(),
|
||||||
|
Placeholder::make('included_context')
|
||||||
|
->label('Included context')
|
||||||
|
->content(fn (): string => $this->operationSupportRequestAttachmentSummary())
|
||||||
|
->columnSpanFull(),
|
||||||
|
Select::make('severity')
|
||||||
|
->label('Severity')
|
||||||
|
->options(SupportRequest::severityOptions())
|
||||||
|
->default(SupportRequest::SEVERITY_NORMAL)
|
||||||
|
->required()
|
||||||
|
->native(false),
|
||||||
|
TextInput::make('summary')
|
||||||
|
->label('Summary')
|
||||||
|
->required()
|
||||||
|
->columnSpanFull(),
|
||||||
|
Textarea::make('reproduction_notes')
|
||||||
|
->label('Reproduction notes')
|
||||||
|
->rows(4)
|
||||||
|
->columnSpanFull(),
|
||||||
|
TextInput::make('contact_name')
|
||||||
|
->label('Contact name')
|
||||||
|
->default(fn (): ?string => $this->resolveViewerActor()->name),
|
||||||
|
TextInput::make('contact_email')
|
||||||
|
->label('Contact email')
|
||||||
|
->email()
|
||||||
|
->default(fn (): ?string => $this->resolveViewerActor()->email),
|
||||||
|
])
|
||||||
|
->action(function (array $data): void {
|
||||||
|
$actor = $this->resolveViewerActor();
|
||||||
|
|
||||||
|
$supportRequest = app(SupportRequestSubmissionService::class)->submitForOperationRun($this->run, $actor, $data);
|
||||||
|
|
||||||
|
Notification::make()
|
||||||
|
->title('Support request submitted')
|
||||||
|
->body('Reference '.$supportRequest->internal_reference)
|
||||||
|
->success()
|
||||||
|
->send();
|
||||||
|
});
|
||||||
|
|
||||||
|
return UiEnforcement::forAction($action)
|
||||||
|
->requireCapability(Capabilities::SUPPORT_REQUESTS_CREATE)
|
||||||
|
->apply();
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @return array<string, mixed>
|
* @return array<string, mixed>
|
||||||
*/
|
*/
|
||||||
public function operationRunSupportDiagnosticBundle(): array
|
public function operationRunSupportDiagnosticBundle(): array
|
||||||
{
|
{
|
||||||
$user = auth()->user();
|
$user = $this->resolveViewerActor();
|
||||||
$tenant = $this->supportDiagnosticsTenant();
|
$tenant = $this->resolveRunTenantForCapability(Capabilities::SUPPORT_DIAGNOSTICS_VIEW);
|
||||||
|
|
||||||
if (! $user instanceof User || ! $tenant instanceof Tenant) {
|
|
||||||
abort(404);
|
|
||||||
}
|
|
||||||
|
|
||||||
$resolver = app(CapabilityResolver::class);
|
|
||||||
|
|
||||||
if (! $resolver->isMember($user, $tenant)) {
|
|
||||||
abort(404);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (! $resolver->can($user, $tenant, Capabilities::SUPPORT_DIAGNOSTICS_VIEW)) {
|
|
||||||
abort(403);
|
|
||||||
}
|
|
||||||
|
|
||||||
return app(SupportDiagnosticBundleBuilder::class)->forOperationRun($this->run, $user);
|
return app(SupportDiagnosticBundleBuilder::class)->forOperationRun($this->run, $user);
|
||||||
}
|
}
|
||||||
|
|
||||||
private function auditOperationSupportDiagnosticsOpen(): void
|
private function auditOperationSupportDiagnosticsOpen(): void
|
||||||
{
|
{
|
||||||
$user = auth()->user();
|
$user = $this->resolveViewerActor();
|
||||||
$tenant = $this->supportDiagnosticsTenant();
|
$tenant = $this->resolveRunTenantForCapability(Capabilities::SUPPORT_DIAGNOSTICS_VIEW);
|
||||||
|
|
||||||
if (! $user instanceof User || ! $tenant instanceof Tenant) {
|
|
||||||
abort(404);
|
|
||||||
}
|
|
||||||
|
|
||||||
$this->recordSupportDiagnosticsOpened(
|
$this->recordSupportDiagnosticsOpened(
|
||||||
tenant: $tenant,
|
tenant: $tenant,
|
||||||
@ -307,6 +361,59 @@ private function supportDiagnosticsTenant(): ?Tenant
|
|||||||
return $this->run->loadMissing('tenant')->tenant;
|
return $this->run->loadMissing('tenant')->tenant;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private function resolveViewerActor(): User
|
||||||
|
{
|
||||||
|
$user = auth()->user();
|
||||||
|
|
||||||
|
if (! $user instanceof User) {
|
||||||
|
abort(403);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $user;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function resolveRunTenantForCapability(string $capability): Tenant
|
||||||
|
{
|
||||||
|
$tenant = $this->supportDiagnosticsTenant();
|
||||||
|
$user = $this->resolveViewerActor();
|
||||||
|
|
||||||
|
if (! $tenant instanceof Tenant) {
|
||||||
|
abort(404);
|
||||||
|
}
|
||||||
|
|
||||||
|
$resolver = app(CapabilityResolver::class);
|
||||||
|
|
||||||
|
if (! $resolver->isMember($user, $tenant)) {
|
||||||
|
abort(404);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (! $resolver->can($user, $tenant, $capability)) {
|
||||||
|
abort(403);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $tenant;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function operationSupportRequestAttachmentSummary(): string
|
||||||
|
{
|
||||||
|
$tenant = $this->supportDiagnosticsTenant();
|
||||||
|
$user = auth()->user();
|
||||||
|
|
||||||
|
if (! $tenant instanceof Tenant || ! $user instanceof User) {
|
||||||
|
return 'Only canonical redacted run context will be attached.';
|
||||||
|
}
|
||||||
|
|
||||||
|
$resolver = app(CapabilityResolver::class);
|
||||||
|
|
||||||
|
if (! $resolver->isMember($user, $tenant)) {
|
||||||
|
return 'Only canonical redacted run context will be attached.';
|
||||||
|
}
|
||||||
|
|
||||||
|
return $resolver->can($user, $tenant, Capabilities::SUPPORT_DIAGNOSTICS_VIEW)
|
||||||
|
? 'A redacted diagnostic snapshot and the canonical run context will be attached.'
|
||||||
|
: 'Only the canonical redacted run context will be attached because you cannot view support diagnostics.';
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @param array<string, mixed> $bundle
|
* @param array<string, mixed> $bundle
|
||||||
*/
|
*/
|
||||||
|
|||||||
@ -0,0 +1,497 @@
|
|||||||
|
<?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,6 +9,7 @@
|
|||||||
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;
|
||||||
@ -176,6 +177,24 @@ 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,7 +7,11 @@
|
|||||||
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;
|
||||||
@ -20,7 +24,9 @@
|
|||||||
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;
|
||||||
@ -51,6 +57,7 @@ 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'],
|
||||||
@ -58,10 +65,23 @@ 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).
|
||||||
*
|
*
|
||||||
@ -111,6 +131,14 @@ 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}.
|
||||||
*
|
*
|
||||||
@ -180,6 +208,71 @@ 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([
|
||||||
@ -455,6 +548,56 @@ 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);
|
||||||
@ -490,6 +633,7 @@ 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();
|
||||||
}
|
}
|
||||||
@ -563,15 +707,25 @@ 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->hasWorkspaceOverride($field))
|
->disabled(fn (): bool => ! $this->currentUserCanManage() || ! $this->canResetField($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->hasWorkspaceOverride($field)) {
|
if (! $this->canResetField($field)) {
|
||||||
|
if ($this->isEntitlementOverrideValueField($field)) {
|
||||||
|
return 'No workspace override to reset.';
|
||||||
|
}
|
||||||
|
|
||||||
return 'No workspace override to reset.';
|
return 'No workspace override to reset.';
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -579,6 +733,200 @@ 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;
|
||||||
@ -721,6 +1069,27 @@ 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];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -11,6 +11,7 @@
|
|||||||
use App\Filament\Widgets\Dashboard\RecentDriftFindings;
|
use App\Filament\Widgets\Dashboard\RecentDriftFindings;
|
||||||
use App\Filament\Widgets\Dashboard\RecentOperations;
|
use App\Filament\Widgets\Dashboard\RecentOperations;
|
||||||
use App\Filament\Widgets\Dashboard\RecoveryReadiness;
|
use App\Filament\Widgets\Dashboard\RecoveryReadiness;
|
||||||
|
use App\Models\SupportRequest;
|
||||||
use App\Models\Tenant;
|
use App\Models\Tenant;
|
||||||
use App\Models\User;
|
use App\Models\User;
|
||||||
use App\Services\Audit\WorkspaceAuditLogger;
|
use App\Services\Audit\WorkspaceAuditLogger;
|
||||||
@ -20,8 +21,14 @@
|
|||||||
use App\Support\ProductTelemetry\ProductUsageEventCatalog;
|
use App\Support\ProductTelemetry\ProductUsageEventCatalog;
|
||||||
use App\Support\Rbac\UiEnforcement;
|
use App\Support\Rbac\UiEnforcement;
|
||||||
use App\Support\SupportDiagnostics\SupportDiagnosticBundleBuilder;
|
use App\Support\SupportDiagnostics\SupportDiagnosticBundleBuilder;
|
||||||
|
use App\Support\SupportRequests\SupportRequestSubmissionService;
|
||||||
use Filament\Actions\Action;
|
use Filament\Actions\Action;
|
||||||
use Filament\Facades\Filament;
|
use Filament\Facades\Filament;
|
||||||
|
use Filament\Forms\Components\Placeholder;
|
||||||
|
use Filament\Forms\Components\Select;
|
||||||
|
use Filament\Forms\Components\TextInput;
|
||||||
|
use Filament\Forms\Components\Textarea;
|
||||||
|
use Filament\Notifications\Notification;
|
||||||
use Filament\Pages\Dashboard;
|
use Filament\Pages\Dashboard;
|
||||||
use Filament\Widgets\Widget;
|
use Filament\Widgets\Widget;
|
||||||
use Filament\Widgets\WidgetConfiguration;
|
use Filament\Widgets\WidgetConfiguration;
|
||||||
@ -70,10 +77,72 @@ public function getColumns(): int|array
|
|||||||
protected function getHeaderActions(): array
|
protected function getHeaderActions(): array
|
||||||
{
|
{
|
||||||
return [
|
return [
|
||||||
|
$this->requestSupportAction(),
|
||||||
$this->openSupportDiagnosticsAction(),
|
$this->openSupportDiagnosticsAction(),
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function authorizeTenantSupportRequest(): void
|
||||||
|
{
|
||||||
|
$this->resolveCurrentTenantForCapability(Capabilities::SUPPORT_REQUESTS_CREATE);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function requestSupportAction(): Action
|
||||||
|
{
|
||||||
|
$action = Action::make('requestSupport')
|
||||||
|
->label('Request support')
|
||||||
|
->icon('heroicon-o-paper-airplane')
|
||||||
|
->color('gray')
|
||||||
|
->slideOver()
|
||||||
|
->stickyModalHeader()
|
||||||
|
->modalHeading('Request support')
|
||||||
|
->modalDescription('Share a concise summary and TenantAtlas will attach redacted context from existing records.')
|
||||||
|
->modalSubmitActionLabel('Submit request')
|
||||||
|
->form([
|
||||||
|
Placeholder::make('included_context')
|
||||||
|
->label('Included context')
|
||||||
|
->content(fn (): string => $this->tenantSupportRequestAttachmentSummary())
|
||||||
|
->columnSpanFull(),
|
||||||
|
Select::make('severity')
|
||||||
|
->label('Severity')
|
||||||
|
->options(SupportRequest::severityOptions())
|
||||||
|
->default(SupportRequest::SEVERITY_NORMAL)
|
||||||
|
->required()
|
||||||
|
->native(false),
|
||||||
|
TextInput::make('summary')
|
||||||
|
->label('Summary')
|
||||||
|
->required()
|
||||||
|
->columnSpanFull(),
|
||||||
|
Textarea::make('reproduction_notes')
|
||||||
|
->label('Reproduction notes')
|
||||||
|
->rows(4)
|
||||||
|
->columnSpanFull(),
|
||||||
|
TextInput::make('contact_name')
|
||||||
|
->label('Contact name')
|
||||||
|
->default(fn (): ?string => $this->resolveDashboardActor()->name),
|
||||||
|
TextInput::make('contact_email')
|
||||||
|
->label('Contact email')
|
||||||
|
->email()
|
||||||
|
->default(fn (): ?string => $this->resolveDashboardActor()->email),
|
||||||
|
])
|
||||||
|
->action(function (array $data): void {
|
||||||
|
$actor = $this->resolveDashboardActor();
|
||||||
|
$tenant = $this->resolveCurrentTenantForCapability(Capabilities::SUPPORT_REQUESTS_CREATE);
|
||||||
|
|
||||||
|
$supportRequest = app(SupportRequestSubmissionService::class)->submitForTenant($tenant, $actor, $data);
|
||||||
|
|
||||||
|
Notification::make()
|
||||||
|
->title('Support request submitted')
|
||||||
|
->body('Reference '.$supportRequest->internal_reference)
|
||||||
|
->success()
|
||||||
|
->send();
|
||||||
|
});
|
||||||
|
|
||||||
|
return UiEnforcement::forAction($action)
|
||||||
|
->requireCapability(Capabilities::SUPPORT_REQUESTS_CREATE)
|
||||||
|
->apply();
|
||||||
|
}
|
||||||
|
|
||||||
private function openSupportDiagnosticsAction(): Action
|
private function openSupportDiagnosticsAction(): Action
|
||||||
{
|
{
|
||||||
$action = Action::make('openSupportDiagnostics')
|
$action = Action::make('openSupportDiagnostics')
|
||||||
@ -104,34 +173,16 @@ private function openSupportDiagnosticsAction(): Action
|
|||||||
*/
|
*/
|
||||||
public function tenantSupportDiagnosticBundle(): array
|
public function tenantSupportDiagnosticBundle(): array
|
||||||
{
|
{
|
||||||
$user = auth()->user();
|
$user = $this->resolveDashboardActor();
|
||||||
$tenant = Filament::getTenant();
|
$tenant = $this->resolveCurrentTenantForCapability(Capabilities::SUPPORT_DIAGNOSTICS_VIEW);
|
||||||
|
|
||||||
if (! $user instanceof User || ! $tenant instanceof Tenant) {
|
|
||||||
abort(404);
|
|
||||||
}
|
|
||||||
|
|
||||||
$resolver = app(CapabilityResolver::class);
|
|
||||||
|
|
||||||
if (! $resolver->isMember($user, $tenant)) {
|
|
||||||
abort(404);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (! $resolver->can($user, $tenant, Capabilities::SUPPORT_DIAGNOSTICS_VIEW)) {
|
|
||||||
abort(403);
|
|
||||||
}
|
|
||||||
|
|
||||||
return app(SupportDiagnosticBundleBuilder::class)->forTenant($tenant, $user);
|
return app(SupportDiagnosticBundleBuilder::class)->forTenant($tenant, $user);
|
||||||
}
|
}
|
||||||
|
|
||||||
private function auditTenantSupportDiagnosticsOpen(): void
|
private function auditTenantSupportDiagnosticsOpen(): void
|
||||||
{
|
{
|
||||||
$user = auth()->user();
|
$user = $this->resolveDashboardActor();
|
||||||
$tenant = Filament::getTenant();
|
$tenant = $this->resolveCurrentTenantForCapability(Capabilities::SUPPORT_DIAGNOSTICS_VIEW);
|
||||||
|
|
||||||
if (! $user instanceof User || ! $tenant instanceof Tenant) {
|
|
||||||
abort(404);
|
|
||||||
}
|
|
||||||
|
|
||||||
$this->recordSupportDiagnosticsOpened(
|
$this->recordSupportDiagnosticsOpened(
|
||||||
tenant: $tenant,
|
tenant: $tenant,
|
||||||
@ -172,4 +223,57 @@ private function recordSupportDiagnosticsOpened(Tenant $tenant, array $bundle, U
|
|||||||
|
|
||||||
$this->supportDiagnosticsAuditKeys[] = $auditKey;
|
$this->supportDiagnosticsAuditKeys[] = $auditKey;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private function resolveDashboardActor(): User
|
||||||
|
{
|
||||||
|
$user = auth()->user();
|
||||||
|
|
||||||
|
if (! $user instanceof User) {
|
||||||
|
abort(404);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $user;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function resolveCurrentTenantForCapability(string $capability): Tenant
|
||||||
|
{
|
||||||
|
$user = $this->resolveDashboardActor();
|
||||||
|
$tenant = Filament::getTenant();
|
||||||
|
|
||||||
|
if (! $tenant instanceof Tenant) {
|
||||||
|
abort(404);
|
||||||
|
}
|
||||||
|
|
||||||
|
$resolver = app(CapabilityResolver::class);
|
||||||
|
|
||||||
|
if (! $resolver->isMember($user, $tenant)) {
|
||||||
|
abort(404);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (! $resolver->can($user, $tenant, $capability)) {
|
||||||
|
abort(403);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $tenant;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function tenantSupportRequestAttachmentSummary(): string
|
||||||
|
{
|
||||||
|
$tenant = Filament::getTenant();
|
||||||
|
$user = auth()->user();
|
||||||
|
|
||||||
|
if (! $tenant instanceof Tenant || ! $user instanceof User) {
|
||||||
|
return 'Only canonical redacted tenant context will be attached.';
|
||||||
|
}
|
||||||
|
|
||||||
|
$resolver = app(CapabilityResolver::class);
|
||||||
|
|
||||||
|
if (! $resolver->isMember($user, $tenant)) {
|
||||||
|
return 'Only canonical redacted tenant context will be attached.';
|
||||||
|
}
|
||||||
|
|
||||||
|
return $resolver->can($user, $tenant, Capabilities::SUPPORT_DIAGNOSTICS_VIEW)
|
||||||
|
? 'A redacted diagnostic snapshot and the canonical tenant context will be attached.'
|
||||||
|
: 'Only the canonical redacted tenant context will be attached because you cannot view support diagnostics.';
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -30,6 +30,8 @@
|
|||||||
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;
|
||||||
@ -662,7 +664,16 @@ 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()
|
||||||
@ -700,9 +711,7 @@ 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->currentUserCan(Capabilities::WORKSPACE_MANAGED_TENANT_ONBOARD_ACTIVATE)
|
->tooltip(fn (): ?string => $this->completionActionTooltip())
|
||||||
? null
|
|
||||||
: 'Owner required to complete onboarding.')
|
|
||||||
->action(fn () => $this->completeOnboarding()),
|
->action(fn () => $this->completeOnboarding()),
|
||||||
]),
|
]),
|
||||||
]),
|
]),
|
||||||
@ -4498,6 +4507,10 @@ 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(
|
||||||
@ -4530,6 +4543,116 @@ 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();
|
||||||
@ -4863,6 +4986,16 @@ 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,6 +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\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;
|
||||||
@ -267,6 +268,20 @@ 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,7 +2,10 @@
|
|||||||
|
|
||||||
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;
|
||||||
@ -10,6 +13,7 @@
|
|||||||
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;
|
||||||
@ -45,6 +49,8 @@
|
|||||||
|
|
||||||
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';
|
||||||
@ -102,9 +108,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 available in list header.')
|
->satisfy(ActionSurfaceSlot::ListHeader, 'Generate Pack action appears in the list header once review packs exist.')
|
||||||
->satisfy(ActionSurfaceSlot::InspectAffordance, ActionSurfaceInspectAffordance::ClickableRow->value)
|
->satisfy(ActionSurfaceSlot::InspectAffordance, ActionSurfaceInspectAffordance::ClickableRow->value)
|
||||||
->satisfy(ActionSurfaceSlot::ListEmptyState, 'Empty state includes Generate CTA.')
|
->satisfy(ActionSurfaceSlot::ListEmptyState, 'Empty state carries the single Generate CTA while the registry is empty.')
|
||||||
->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.');
|
||||||
@ -190,6 +196,13 @@ 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()
|
||||||
@ -350,41 +363,62 @@ 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([
|
||||||
UiEnforcement::forAction(
|
static::generatePackAction(name: 'generate_first', label: 'Generate first pack'),
|
||||||
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 = Filament::getTenant();
|
$tenant = Tenant::current() ?? static::resolveTenantContextForCurrentPanel() ?? 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()->where('tenant_id', (int) $tenant->getKey());
|
return parent::getEloquentQuery()
|
||||||
|
->with(['tenant', 'operationRun', 'evidenceSnapshot', 'tenantReview'])
|
||||||
|
->where('tenant_id', (int) $tenant->getKey());
|
||||||
}
|
}
|
||||||
|
|
||||||
public static function getPages(): array
|
public static function getPages(): array
|
||||||
@ -458,6 +492,14 @@ 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;
|
||||||
|
|
||||||
@ -493,4 +535,69 @@ 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,12 +3,7 @@
|
|||||||
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
|
||||||
{
|
{
|
||||||
@ -17,29 +12,13 @@ class ListReviewPacks extends ListRecords
|
|||||||
protected function getHeaderActions(): array
|
protected function getHeaderActions(): array
|
||||||
{
|
{
|
||||||
return [
|
return [
|
||||||
UiEnforcement::forAction(
|
ReviewPackResource::generatePackAction()
|
||||||
Actions\Action::make('generate_pack')
|
->visible(fn (): bool => $this->tableHasRecords()),
|
||||||
->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,6 +19,51 @@ 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')
|
||||||
@ -28,46 +73,7 @@ protected function getHeaderActions(): array
|
|||||||
->url(fn (): string => app(ReviewPackService::class)->generateDownloadUrl($this->record))
|
->url(fn (): string => app(ReviewPackService::class)->generateDownloadUrl($this->record))
|
||||||
->openUrlInNewTab(),
|
->openUrlInNewTab(),
|
||||||
|
|
||||||
UiEnforcement::forAction(
|
$regenerateAction,
|
||||||
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,7 +6,9 @@
|
|||||||
|
|
||||||
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;
|
||||||
@ -15,6 +17,7 @@
|
|||||||
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;
|
||||||
@ -241,6 +244,25 @@ 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()
|
||||||
@ -287,20 +309,7 @@ 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([
|
||||||
UiEnforcement::forTableAction(
|
$exportExecutivePackAction,
|
||||||
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')
|
||||||
@ -423,6 +432,64 @@ 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']);
|
||||||
@ -457,6 +524,10 @@ 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();
|
||||||
|
|
||||||
@ -593,6 +664,15 @@ 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,12 +4,15 @@
|
|||||||
|
|
||||||
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;
|
||||||
@ -24,6 +27,13 @@ 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);
|
||||||
@ -69,7 +79,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->record->statusEnum()->isTerminal()),
|
->visible(fn (): bool => ! $this->isCustomerWorkspaceView() && ! $this->record->statusEnum()->isTerminal()),
|
||||||
]));
|
]));
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -85,6 +95,10 @@ 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';
|
||||||
}
|
}
|
||||||
@ -122,6 +136,10 @@ 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()) {
|
||||||
@ -178,7 +196,6 @@ private function refreshReviewAction(): Actions\Action
|
|||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
->requireCapability(Capabilities::TENANT_REVIEW_MANAGE)
|
->requireCapability(Capabilities::TENANT_REVIEW_MANAGE)
|
||||||
->preserveVisibility()
|
|
||||||
->apply();
|
->apply();
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -232,7 +249,7 @@ private function publishReviewAction(): Actions\Action
|
|||||||
|
|
||||||
private function exportExecutivePackAction(): Actions\Action
|
private function exportExecutivePackAction(): Actions\Action
|
||||||
{
|
{
|
||||||
return UiEnforcement::forAction(
|
$action = 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')
|
||||||
@ -241,11 +258,17 @@ 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
|
||||||
@ -319,4 +342,39 @@ 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,12 +9,19 @@
|
|||||||
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;
|
||||||
|
|
||||||
@ -85,6 +92,85 @@ 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,6 +80,9 @@ protected function getHeaderActions(): array
|
|||||||
$this->pauseRestoreExecuteAction(),
|
$this->pauseRestoreExecuteAction(),
|
||||||
$this->resumeRestoreExecuteAction(),
|
$this->resumeRestoreExecuteAction(),
|
||||||
$this->viewHistoryRestoreExecuteAction(),
|
$this->viewHistoryRestoreExecuteAction(),
|
||||||
|
$this->pauseAiExecutionAction(),
|
||||||
|
$this->resumeAiExecutionAction(),
|
||||||
|
$this->viewHistoryAiExecutionAction(),
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -199,6 +202,21 @@ 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);
|
||||||
@ -213,7 +231,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($data);
|
[$scopeType, $workspace, $reasonText, $expiresAt] = $this->normalizePauseInput($controlKey, $data);
|
||||||
|
|
||||||
$scopeQuery = $this->activationScopeQuery($controlKey, $scopeType, $workspace);
|
$scopeQuery = $this->activationScopeQuery($controlKey, $scopeType, $workspace);
|
||||||
|
|
||||||
@ -273,7 +291,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($data);
|
[$scopeType, $workspace] = $this->normalizeResumeInput($controlKey, $data);
|
||||||
|
|
||||||
$activation = $this->activationScopeQuery($controlKey, $scopeType, $workspace)
|
$activation = $this->activationScopeQuery($controlKey, $scopeType, $workspace)
|
||||||
->notExpired()
|
->notExpired()
|
||||||
@ -331,11 +349,8 @@ private function pauseFormSchema(string $controlKey): array
|
|||||||
return [
|
return [
|
||||||
Radio::make('scope_type')
|
Radio::make('scope_type')
|
||||||
->label('Scope')
|
->label('Scope')
|
||||||
->options([
|
->options($this->scopeOptions($controlKey))
|
||||||
'global' => 'Global',
|
->default($this->defaultScopeFor($controlKey))
|
||||||
'workspace' => 'One workspace',
|
|
||||||
])
|
|
||||||
->default('global')
|
|
||||||
->live()
|
->live()
|
||||||
->required(),
|
->required(),
|
||||||
|
|
||||||
@ -395,11 +410,8 @@ private function resumeFormSchema(string $controlKey): array
|
|||||||
return [
|
return [
|
||||||
Radio::make('scope_type')
|
Radio::make('scope_type')
|
||||||
->label('Scope')
|
->label('Scope')
|
||||||
->options([
|
->options($this->scopeOptions($controlKey))
|
||||||
'global' => 'Global',
|
->default($this->defaultScopeFor($controlKey))
|
||||||
'workspace' => 'One workspace',
|
|
||||||
])
|
|
||||||
->default('global')
|
|
||||||
->live()
|
->live()
|
||||||
->required(),
|
->required(),
|
||||||
|
|
||||||
@ -456,9 +468,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(array $data): array
|
private function normalizePauseInput(string $controlKey, array $data): array
|
||||||
{
|
{
|
||||||
[$scopeType, $workspace] = $this->resolveScopeInput($data);
|
[$scopeType, $workspace] = $this->resolveScopeInput($controlKey, $data);
|
||||||
$reasonText = trim((string) ($data['reason_text'] ?? ''));
|
$reasonText = trim((string) ($data['reason_text'] ?? ''));
|
||||||
|
|
||||||
if ($reasonText === '') {
|
if ($reasonText === '') {
|
||||||
@ -485,19 +497,20 @@ private function normalizePauseInput(array $data): array
|
|||||||
/**
|
/**
|
||||||
* @return array{0: string, 1: ?Workspace}
|
* @return array{0: string, 1: ?Workspace}
|
||||||
*/
|
*/
|
||||||
private function normalizeResumeInput(array $data): array
|
private function normalizeResumeInput(string $controlKey, array $data): array
|
||||||
{
|
{
|
||||||
return $this->resolveScopeInput($data);
|
return $this->resolveScopeInput($controlKey, $data);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @return array{0: string, 1: ?Workspace}
|
* @return array{0: string, 1: ?Workspace}
|
||||||
*/
|
*/
|
||||||
private function resolveScopeInput(array $data): array
|
private function resolveScopeInput(string $controlKey, 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, ['global', 'workspace'], true)) {
|
if (! in_array($scopeType, $supportedScopes, true)) {
|
||||||
throw ValidationException::withMessages([
|
throw ValidationException::withMessages([
|
||||||
'scope_type' => 'Invalid scope selected.',
|
'scope_type' => 'Invalid scope selected.',
|
||||||
]);
|
]);
|
||||||
@ -526,6 +539,26 @@ private function resolveScopeInput(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,6 +4,8 @@
|
|||||||
|
|
||||||
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;
|
||||||
@ -18,6 +20,7 @@
|
|||||||
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
|
||||||
@ -66,6 +69,26 @@ 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())
|
||||||
@ -90,10 +113,20 @@ public function generatePack(bool $includePii = true, bool $includeOperations =
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
$reviewPack = $service->generate($tenant, $user, [
|
try {
|
||||||
'include_pii' => $includePii,
|
$reviewPack = $service->generate($tenant, $user, [
|
||||||
'include_operations' => $includeOperations,
|
'include_pii' => $includePii,
|
||||||
]);
|
'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)
|
||||||
@ -130,6 +163,17 @@ 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'])
|
||||||
@ -146,6 +190,10 @@ 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,
|
||||||
@ -194,6 +242,10 @@ 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,
|
||||||
@ -224,6 +276,10 @@ 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,7 +4,12 @@
|
|||||||
|
|
||||||
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;
|
||||||
@ -15,6 +20,21 @@ 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;
|
||||||
}
|
}
|
||||||
@ -29,7 +49,26 @@ public function __invoke(Request $request, ReviewPack $reviewPack): StreamedResp
|
|||||||
throw new NotFoundHttpException;
|
throw new NotFoundHttpException;
|
||||||
}
|
}
|
||||||
|
|
||||||
$tenant = $reviewPack->tenant;
|
app(WorkspaceAuditLogger::class)->log(
|
||||||
|
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',
|
||||||
|
|||||||
121
apps/platform/app/Models/SupportRequest.php
Normal file
121
apps/platform/app/Models/SupportRequest.php
Normal file
@ -0,0 +1,121 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Models;
|
||||||
|
|
||||||
|
use App\Support\Concerns\DerivesWorkspaceIdFromTenant;
|
||||||
|
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||||
|
use Illuminate\Database\Eloquent\Model;
|
||||||
|
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||||
|
|
||||||
|
class SupportRequest extends Model
|
||||||
|
{
|
||||||
|
use DerivesWorkspaceIdFromTenant;
|
||||||
|
|
||||||
|
/** @use HasFactory<\Database\Factories\SupportRequestFactory> */
|
||||||
|
use HasFactory;
|
||||||
|
|
||||||
|
public const string PRIMARY_CONTEXT_TENANT = 'tenant';
|
||||||
|
|
||||||
|
public const string PRIMARY_CONTEXT_OPERATION_RUN = 'operation_run';
|
||||||
|
|
||||||
|
public const string ATTACHMENT_MODE_DIAGNOSTIC_SNAPSHOT_ATTACHED = 'diagnostic_snapshot_attached';
|
||||||
|
|
||||||
|
public const string ATTACHMENT_MODE_CANONICAL_CONTEXT_ONLY = 'canonical_context_only';
|
||||||
|
|
||||||
|
public const string SEVERITY_LOW = 'low';
|
||||||
|
|
||||||
|
public const string SEVERITY_NORMAL = 'normal';
|
||||||
|
|
||||||
|
public const string SEVERITY_HIGH = 'high';
|
||||||
|
|
||||||
|
public const string SEVERITY_BLOCKING = 'blocking';
|
||||||
|
|
||||||
|
protected $guarded = [];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<string, string>
|
||||||
|
*/
|
||||||
|
protected function casts(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'context_envelope' => 'array',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<string, string>
|
||||||
|
*/
|
||||||
|
public static function severityOptions(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
self::SEVERITY_LOW => 'Low',
|
||||||
|
self::SEVERITY_NORMAL => 'Normal',
|
||||||
|
self::SEVERITY_HIGH => 'High',
|
||||||
|
self::SEVERITY_BLOCKING => 'Blocking',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return list<string>
|
||||||
|
*/
|
||||||
|
public static function severityValues(): array
|
||||||
|
{
|
||||||
|
return array_keys(self::severityOptions());
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return list<string>
|
||||||
|
*/
|
||||||
|
public static function primaryContextTypes(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
self::PRIMARY_CONTEXT_TENANT,
|
||||||
|
self::PRIMARY_CONTEXT_OPERATION_RUN,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return list<string>
|
||||||
|
*/
|
||||||
|
public static function attachmentModes(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
self::ATTACHMENT_MODE_DIAGNOSTIC_SNAPSHOT_ATTACHED,
|
||||||
|
self::ATTACHMENT_MODE_CANONICAL_CONTEXT_ONLY,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return BelongsTo<Workspace, $this>
|
||||||
|
*/
|
||||||
|
public function workspace(): BelongsTo
|
||||||
|
{
|
||||||
|
return $this->belongsTo(Workspace::class);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return BelongsTo<Tenant, $this>
|
||||||
|
*/
|
||||||
|
public function tenant(): BelongsTo
|
||||||
|
{
|
||||||
|
return $this->belongsTo(Tenant::class);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return BelongsTo<OperationRun, $this>
|
||||||
|
*/
|
||||||
|
public function operationRun(): BelongsTo
|
||||||
|
{
|
||||||
|
return $this->belongsTo(OperationRun::class);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return BelongsTo<User, $this>
|
||||||
|
*/
|
||||||
|
public function initiator(): BelongsTo
|
||||||
|
{
|
||||||
|
return $this->belongsTo(User::class, 'initiated_by_user_id');
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -7,11 +7,13 @@
|
|||||||
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;
|
||||||
@ -179,10 +181,12 @@ 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([
|
||||||
|
|||||||
@ -4,9 +4,10 @@
|
|||||||
|
|
||||||
namespace App\Services\Audit;
|
namespace App\Services\Audit;
|
||||||
|
|
||||||
use App\Models\Tenant;
|
|
||||||
use App\Models\OperationRun;
|
use App\Models\OperationRun;
|
||||||
use App\Models\PlatformUser;
|
use App\Models\PlatformUser;
|
||||||
|
use App\Models\SupportRequest;
|
||||||
|
use App\Models\Tenant;
|
||||||
use App\Models\User;
|
use App\Models\User;
|
||||||
use App\Models\Workspace;
|
use App\Models\Workspace;
|
||||||
use App\Support\Audit\AuditActionId;
|
use App\Support\Audit\AuditActionId;
|
||||||
@ -14,6 +15,7 @@
|
|||||||
use App\Support\Audit\AuditActorType;
|
use App\Support\Audit\AuditActorType;
|
||||||
use App\Support\Audit\AuditTargetSnapshot;
|
use App\Support\Audit\AuditTargetSnapshot;
|
||||||
use Carbon\CarbonImmutable;
|
use Carbon\CarbonImmutable;
|
||||||
|
use InvalidArgumentException;
|
||||||
|
|
||||||
class WorkspaceAuditLogger
|
class WorkspaceAuditLogger
|
||||||
{
|
{
|
||||||
@ -136,4 +138,39 @@ public function logSupportDiagnosticsOpened(
|
|||||||
tenant: $tenant,
|
tenant: $tenant,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function logSupportRequestCreated(
|
||||||
|
SupportRequest $supportRequest,
|
||||||
|
User|PlatformUser|null $actor = null,
|
||||||
|
): \App\Models\AuditLog {
|
||||||
|
$supportRequest->loadMissing(['tenant.workspace']);
|
||||||
|
|
||||||
|
$tenant = $supportRequest->tenant;
|
||||||
|
|
||||||
|
if (! $tenant instanceof Tenant) {
|
||||||
|
throw new InvalidArgumentException('Support requests must belong to a tenant.');
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this->log(
|
||||||
|
workspace: $tenant->workspace,
|
||||||
|
action: AuditActionId::SupportRequestCreated,
|
||||||
|
context: [
|
||||||
|
'internal_reference' => $supportRequest->internal_reference,
|
||||||
|
'primary_context_type' => $supportRequest->primary_context_type,
|
||||||
|
'primary_context_id' => $supportRequest->primary_context_type === SupportRequest::PRIMARY_CONTEXT_OPERATION_RUN
|
||||||
|
? (string) $supportRequest->operation_run_id
|
||||||
|
: (string) $tenant->getKey(),
|
||||||
|
'attachment_mode' => $supportRequest->attachment_mode,
|
||||||
|
'redaction_mode' => (string) data_get($supportRequest->context_envelope, 'redaction_mode', 'default_redacted'),
|
||||||
|
],
|
||||||
|
actor: $actor,
|
||||||
|
status: 'success',
|
||||||
|
resourceType: 'support_request',
|
||||||
|
resourceId: (string) $supportRequest->getKey(),
|
||||||
|
targetLabel: $supportRequest->internal_reference,
|
||||||
|
summary: 'Support request created for '.$supportRequest->internal_reference,
|
||||||
|
operationRunId: $supportRequest->operation_run_id !== null ? (int) $supportRequest->operation_run_id : null,
|
||||||
|
tenant: $tenant,
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -20,6 +20,7 @@ class RoleCapabilityMap
|
|||||||
Capabilities::TENANT_DELETE,
|
Capabilities::TENANT_DELETE,
|
||||||
Capabilities::TENANT_SYNC,
|
Capabilities::TENANT_SYNC,
|
||||||
Capabilities::SUPPORT_DIAGNOSTICS_VIEW,
|
Capabilities::SUPPORT_DIAGNOSTICS_VIEW,
|
||||||
|
Capabilities::SUPPORT_REQUESTS_CREATE,
|
||||||
Capabilities::TENANT_INVENTORY_SYNC_RUN,
|
Capabilities::TENANT_INVENTORY_SYNC_RUN,
|
||||||
Capabilities::TENANT_FINDINGS_VIEW,
|
Capabilities::TENANT_FINDINGS_VIEW,
|
||||||
Capabilities::TENANT_FINDINGS_TRIAGE,
|
Capabilities::TENANT_FINDINGS_TRIAGE,
|
||||||
@ -65,6 +66,7 @@ class RoleCapabilityMap
|
|||||||
Capabilities::TENANT_MANAGE,
|
Capabilities::TENANT_MANAGE,
|
||||||
Capabilities::TENANT_SYNC,
|
Capabilities::TENANT_SYNC,
|
||||||
Capabilities::SUPPORT_DIAGNOSTICS_VIEW,
|
Capabilities::SUPPORT_DIAGNOSTICS_VIEW,
|
||||||
|
Capabilities::SUPPORT_REQUESTS_CREATE,
|
||||||
Capabilities::TENANT_INVENTORY_SYNC_RUN,
|
Capabilities::TENANT_INVENTORY_SYNC_RUN,
|
||||||
Capabilities::TENANT_FINDINGS_VIEW,
|
Capabilities::TENANT_FINDINGS_VIEW,
|
||||||
Capabilities::TENANT_FINDINGS_TRIAGE,
|
Capabilities::TENANT_FINDINGS_TRIAGE,
|
||||||
@ -106,6 +108,7 @@ class RoleCapabilityMap
|
|||||||
Capabilities::TENANT_VIEW,
|
Capabilities::TENANT_VIEW,
|
||||||
Capabilities::TENANT_SYNC,
|
Capabilities::TENANT_SYNC,
|
||||||
Capabilities::SUPPORT_DIAGNOSTICS_VIEW,
|
Capabilities::SUPPORT_DIAGNOSTICS_VIEW,
|
||||||
|
Capabilities::SUPPORT_REQUESTS_CREATE,
|
||||||
Capabilities::TENANT_INVENTORY_SYNC_RUN,
|
Capabilities::TENANT_INVENTORY_SYNC_RUN,
|
||||||
Capabilities::TENANT_FINDINGS_VIEW,
|
Capabilities::TENANT_FINDINGS_VIEW,
|
||||||
Capabilities::TENANT_FINDINGS_TRIAGE,
|
Capabilities::TENANT_FINDINGS_TRIAGE,
|
||||||
|
|||||||
@ -0,0 +1,410 @@
|
|||||||
|
<?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,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,327 @@
|
|||||||
|
<?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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,104 @@
|
|||||||
|
<?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,6 +4,7 @@
|
|||||||
|
|
||||||
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;
|
||||||
@ -13,6 +14,7 @@
|
|||||||
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;
|
||||||
@ -28,6 +30,7 @@ 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,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
@ -49,6 +52,8 @@ 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);
|
||||||
@ -138,6 +143,8 @@ 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);
|
||||||
@ -227,18 +234,43 @@ 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): string
|
public function generateDownloadUrl(ReviewPack $pack, array $parameters = []): 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',
|
||||||
['reviewPack' => $pack->getKey()],
|
array_merge(['reviewPack' => $pack->getKey()], $parameters),
|
||||||
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(
|
||||||
@ -314,6 +346,17 @@ 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,6 +4,7 @@
|
|||||||
|
|
||||||
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;
|
||||||
@ -11,11 +12,14 @@
|
|||||||
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;
|
||||||
@ -33,27 +37,7 @@ public function updateWorkspaceSetting(User $actor, Workspace $workspace, string
|
|||||||
{
|
{
|
||||||
$this->authorizeManage($actor, $workspace);
|
$this->authorizeManage($actor, $workspace);
|
||||||
|
|
||||||
$definition = $this->requireDefinition($domain, $key);
|
$result = $this->persistWorkspaceSetting($workspace, $domain, $key, $value, (int) $actor->getKey());
|
||||||
$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();
|
||||||
|
|
||||||
@ -67,7 +51,7 @@ public function updateWorkspaceSetting(User $actor, Workspace $workspace, string
|
|||||||
'scope' => 'workspace',
|
'scope' => 'workspace',
|
||||||
'domain' => $domain,
|
'domain' => $domain,
|
||||||
'key' => $key,
|
'key' => $key,
|
||||||
'before_value' => $beforeValue,
|
'before_value' => $result['before_value'],
|
||||||
'after_value' => $afterValue,
|
'after_value' => $afterValue,
|
||||||
],
|
],
|
||||||
],
|
],
|
||||||
@ -76,7 +60,79 @@ public function updateWorkspaceSetting(User $actor, Workspace $workspace, string
|
|||||||
resourceId: $domain.'.'.$key,
|
resourceId: $domain.'.'.$key,
|
||||||
);
|
);
|
||||||
|
|
||||||
return $setting;
|
return $result['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
|
||||||
@ -174,6 +230,39 @@ 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,6 +12,7 @@
|
|||||||
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
|
||||||
{
|
{
|
||||||
@ -43,6 +44,55 @@ 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()
|
||||||
|
|||||||
27
apps/platform/app/Support/Ai/AiDataClassification.php
Normal file
27
apps/platform/app/Support/Ai/AiDataClassification.php
Normal file
@ -0,0 +1,27 @@
|
|||||||
|
<?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',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,39 @@
|
|||||||
|
<?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;
|
||||||
|
}
|
||||||
|
}
|
||||||
18
apps/platform/app/Support/Ai/AiDecisionReasonCode.php
Normal file
18
apps/platform/app/Support/Ai/AiDecisionReasonCode.php
Normal file
@ -0,0 +1,18 @@
|
|||||||
|
<?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';
|
||||||
|
}
|
||||||
37
apps/platform/app/Support/Ai/AiExecutionDecision.php
Normal file
37
apps/platform/app/Support/Ai/AiExecutionDecision.php
Normal file
@ -0,0 +1,37 @@
|
|||||||
|
<?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';
|
||||||
|
}
|
||||||
|
}
|
||||||
28
apps/platform/app/Support/Ai/AiExecutionRequest.php
Normal file
28
apps/platform/app/Support/Ai/AiExecutionRequest.php
Normal file
@ -0,0 +1,28 @@
|
|||||||
|
<?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,
|
||||||
|
) {}
|
||||||
|
}
|
||||||
43
apps/platform/app/Support/Ai/AiPolicyMode.php
Normal file
43
apps/platform/app/Support/Ai/AiPolicyMode.php
Normal file
@ -0,0 +1,43 @@
|
|||||||
|
<?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;
|
||||||
|
},
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
19
apps/platform/app/Support/Ai/AiProviderClass.php
Normal file
19
apps/platform/app/Support/Ai/AiProviderClass.php
Normal file
@ -0,0 +1,19 @@
|
|||||||
|
<?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',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
126
apps/platform/app/Support/Ai/AiUseCaseCatalog.php
Normal file
126
apps/platform/app/Support/Ai/AiUseCaseCatalog.php
Normal file
@ -0,0 +1,126 @@
|
|||||||
|
<?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,
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
181
apps/platform/app/Support/Ai/GovernedAiExecutionBoundary.php
Normal file
181
apps/platform/app/Support/Ai/GovernedAiExecutionBoundary.php
Normal file
@ -0,0 +1,181 @@
|
|||||||
|
<?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,12 +94,16 @@ 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 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';
|
||||||
@ -236,11 +240,15 @@ 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::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',
|
||||||
@ -324,9 +332,13 @@ 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::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',
|
||||||
|
|||||||
@ -72,6 +72,9 @@ class Capabilities
|
|||||||
// Support diagnostics
|
// Support diagnostics
|
||||||
public const SUPPORT_DIAGNOSTICS_VIEW = 'support_diagnostics.view';
|
public const SUPPORT_DIAGNOSTICS_VIEW = 'support_diagnostics.view';
|
||||||
|
|
||||||
|
// Support requests
|
||||||
|
public const SUPPORT_REQUESTS_CREATE = 'support_requests.create';
|
||||||
|
|
||||||
// Inventory
|
// Inventory
|
||||||
public const TENANT_INVENTORY_SYNC_RUN = 'tenant_inventory_sync.run';
|
public const TENANT_INVENTORY_SYNC_RUN = 'tenant_inventory_sync.run';
|
||||||
|
|
||||||
|
|||||||
@ -18,6 +18,8 @@ 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,6 +57,7 @@ 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,6 +48,7 @@ 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';
|
||||||
|
|||||||
@ -0,0 +1,26 @@
|
|||||||
|
<?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(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,888 @@
|
|||||||
|
<?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,6 +17,13 @@ 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,6 +6,7 @@
|
|||||||
|
|
||||||
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;
|
||||||
@ -147,6 +148,43 @@ 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,7 +4,10 @@
|
|||||||
|
|
||||||
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
|
||||||
{
|
{
|
||||||
@ -17,6 +20,15 @@ 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',
|
||||||
@ -218,6 +230,129 @@ 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,6 +19,7 @@
|
|||||||
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;
|
||||||
@ -133,6 +134,39 @@ 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>
|
||||||
|
|||||||
@ -0,0 +1,129 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Support\SupportRequests;
|
||||||
|
|
||||||
|
use App\Models\OperationRun;
|
||||||
|
use App\Models\Tenant;
|
||||||
|
use App\Models\User;
|
||||||
|
use App\Support\SupportDiagnostics\SupportDiagnosticBundleBuilder;
|
||||||
|
|
||||||
|
final class SupportRequestContextBuilder
|
||||||
|
{
|
||||||
|
public const ATTACHMENT_MODE_DIAGNOSTIC_SNAPSHOT_ATTACHED = 'diagnostic_snapshot_attached';
|
||||||
|
|
||||||
|
public const ATTACHMENT_MODE_CANONICAL_CONTEXT_ONLY = 'canonical_context_only';
|
||||||
|
|
||||||
|
public function __construct(
|
||||||
|
private readonly SupportDiagnosticBundleBuilder $supportDiagnosticBundleBuilder,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<string, mixed>
|
||||||
|
*/
|
||||||
|
public function forTenant(Tenant $tenant, User $actor, bool $attachDiagnosticSnapshot): array
|
||||||
|
{
|
||||||
|
return $this->buildEnvelope(
|
||||||
|
bundle: $this->supportDiagnosticBundleBuilder->forTenant($tenant, $actor),
|
||||||
|
attachDiagnosticSnapshot: $attachDiagnosticSnapshot,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<string, mixed>
|
||||||
|
*/
|
||||||
|
public function forOperationRun(OperationRun $run, User $actor, bool $attachDiagnosticSnapshot): array
|
||||||
|
{
|
||||||
|
return $this->buildEnvelope(
|
||||||
|
bundle: $this->supportDiagnosticBundleBuilder->forOperationRun($run, $actor),
|
||||||
|
attachDiagnosticSnapshot: $attachDiagnosticSnapshot,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string, mixed> $bundle
|
||||||
|
* @return array<string, mixed>
|
||||||
|
*/
|
||||||
|
private function buildEnvelope(array $bundle, bool $attachDiagnosticSnapshot): array
|
||||||
|
{
|
||||||
|
$attachmentMode = $attachDiagnosticSnapshot
|
||||||
|
? self::ATTACHMENT_MODE_DIAGNOSTIC_SNAPSHOT_ATTACHED
|
||||||
|
: self::ATTACHMENT_MODE_CANONICAL_CONTEXT_ONLY;
|
||||||
|
|
||||||
|
return [
|
||||||
|
'schema_version' => 1,
|
||||||
|
'generated_from' => 'support_diagnostics_bundle',
|
||||||
|
'attachment_mode' => $attachmentMode,
|
||||||
|
'redaction_mode' => (string) data_get($bundle, 'redaction.mode', 'default_redacted'),
|
||||||
|
'primary_context' => [
|
||||||
|
'type' => (string) data_get($bundle, 'context.type'),
|
||||||
|
'workspace_id' => data_get($bundle, 'context.workspace_id'),
|
||||||
|
'tenant_id' => data_get($bundle, 'context.tenant_id'),
|
||||||
|
'operation_run_id' => data_get($bundle, 'context.operation_run_id'),
|
||||||
|
'workspace_label' => data_get($bundle, 'context.workspace_label'),
|
||||||
|
'tenant_label' => data_get($bundle, 'context.tenant_label'),
|
||||||
|
],
|
||||||
|
'canonical_context' => [
|
||||||
|
'headline' => (string) data_get($bundle, 'summary.headline', data_get($bundle, 'headline')),
|
||||||
|
'dominant_issue' => (string) data_get($bundle, 'summary.dominant_issue', data_get($bundle, 'dominant_issue')),
|
||||||
|
'freshness_state' => (string) data_get($bundle, 'freshness_state'),
|
||||||
|
'completeness_note' => data_get($bundle, 'summary.completeness_note'),
|
||||||
|
'redaction_note' => data_get($bundle, 'summary.redaction_note'),
|
||||||
|
'context' => data_get($bundle, 'context', []),
|
||||||
|
'tenant' => data_get($bundle, 'tenant'),
|
||||||
|
'operation_run' => data_get($bundle, 'operation_run'),
|
||||||
|
'sections' => $this->canonicalSections($bundle),
|
||||||
|
'notes' => is_array($bundle['notes'] ?? null)
|
||||||
|
? array_values($bundle['notes'])
|
||||||
|
: [],
|
||||||
|
],
|
||||||
|
'diagnostic_snapshot' => $attachDiagnosticSnapshot
|
||||||
|
? [
|
||||||
|
'contextual_help' => data_get($bundle, 'contextual_help'),
|
||||||
|
'sections' => is_array($bundle['sections'] ?? null)
|
||||||
|
? array_values($bundle['sections'])
|
||||||
|
: [],
|
||||||
|
'redaction' => is_array($bundle['redaction'] ?? null)
|
||||||
|
? $bundle['redaction']
|
||||||
|
: [],
|
||||||
|
'notes' => is_array($bundle['notes'] ?? null)
|
||||||
|
? array_values($bundle['notes'])
|
||||||
|
: [],
|
||||||
|
]
|
||||||
|
: null,
|
||||||
|
'omissions' => $attachDiagnosticSnapshot
|
||||||
|
? []
|
||||||
|
: [[
|
||||||
|
'type' => 'diagnostic_snapshot',
|
||||||
|
'reason' => 'omitted_without_support_diagnostics_view',
|
||||||
|
'message' => 'Redacted diagnostic evidence was omitted because the creator could not view support diagnostics.',
|
||||||
|
]],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string, mixed> $bundle
|
||||||
|
* @return list<array<string, mixed>>
|
||||||
|
*/
|
||||||
|
private function canonicalSections(array $bundle): array
|
||||||
|
{
|
||||||
|
if (! is_array($bundle['sections'] ?? null)) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
return array_values(array_map(
|
||||||
|
static fn (array $section): array => [
|
||||||
|
'key' => (string) ($section['key'] ?? ''),
|
||||||
|
'label' => (string) ($section['label'] ?? ''),
|
||||||
|
'availability' => (string) ($section['availability'] ?? 'missing'),
|
||||||
|
'summary' => (string) ($section['summary'] ?? ''),
|
||||||
|
'freshness_note' => $section['freshness_note'] ?? null,
|
||||||
|
'references' => is_array($section['references'] ?? null)
|
||||||
|
? array_values($section['references'])
|
||||||
|
: [],
|
||||||
|
],
|
||||||
|
$bundle['sections'],
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,15 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Support\SupportRequests;
|
||||||
|
|
||||||
|
use Illuminate\Support\Str;
|
||||||
|
|
||||||
|
final class SupportRequestReferenceGenerator
|
||||||
|
{
|
||||||
|
public function generate(): string
|
||||||
|
{
|
||||||
|
return 'SR-'.strtoupper((string) Str::ulid());
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,186 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Support\SupportRequests;
|
||||||
|
|
||||||
|
use App\Models\OperationRun;
|
||||||
|
use App\Models\SupportRequest;
|
||||||
|
use App\Models\Tenant;
|
||||||
|
use App\Models\User;
|
||||||
|
use App\Services\Audit\WorkspaceAuditLogger;
|
||||||
|
use App\Services\Auth\CapabilityResolver;
|
||||||
|
use App\Support\Auth\Capabilities;
|
||||||
|
use Illuminate\Validation\Rule;
|
||||||
|
use Illuminate\Validation\ValidationException;
|
||||||
|
|
||||||
|
final class SupportRequestSubmissionService
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
private readonly CapabilityResolver $capabilityResolver,
|
||||||
|
private readonly SupportRequestContextBuilder $supportRequestContextBuilder,
|
||||||
|
private readonly SupportRequestReferenceGenerator $supportRequestReferenceGenerator,
|
||||||
|
private readonly WorkspaceAuditLogger $workspaceAuditLogger,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string, mixed> $data
|
||||||
|
*/
|
||||||
|
public function submitForTenant(Tenant $tenant, User $actor, array $data): SupportRequest
|
||||||
|
{
|
||||||
|
$this->authorizeCreation($tenant, $actor);
|
||||||
|
|
||||||
|
return $this->submit(
|
||||||
|
tenant: $tenant,
|
||||||
|
actor: $actor,
|
||||||
|
data: $data,
|
||||||
|
primaryContextType: SupportRequest::PRIMARY_CONTEXT_TENANT,
|
||||||
|
operationRun: null,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string, mixed> $data
|
||||||
|
*/
|
||||||
|
public function submitForOperationRun(OperationRun $run, User $actor, array $data): SupportRequest
|
||||||
|
{
|
||||||
|
$run->loadMissing('tenant.workspace');
|
||||||
|
|
||||||
|
$tenant = $run->tenant;
|
||||||
|
|
||||||
|
if (! $tenant instanceof Tenant) {
|
||||||
|
abort(404);
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->authorizeCreation($tenant, $actor);
|
||||||
|
|
||||||
|
return $this->submit(
|
||||||
|
tenant: $tenant,
|
||||||
|
actor: $actor,
|
||||||
|
data: $data,
|
||||||
|
primaryContextType: SupportRequest::PRIMARY_CONTEXT_OPERATION_RUN,
|
||||||
|
operationRun: $run,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function authorizeCreation(Tenant $tenant, User $actor): void
|
||||||
|
{
|
||||||
|
if (! $this->capabilityResolver->isMember($actor, $tenant)) {
|
||||||
|
abort(404);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (! $this->capabilityResolver->can($actor, $tenant, Capabilities::SUPPORT_REQUESTS_CREATE)) {
|
||||||
|
abort(403);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string, mixed> $data
|
||||||
|
*/
|
||||||
|
private function submit(
|
||||||
|
Tenant $tenant,
|
||||||
|
User $actor,
|
||||||
|
array $data,
|
||||||
|
string $primaryContextType,
|
||||||
|
?OperationRun $operationRun,
|
||||||
|
): SupportRequest {
|
||||||
|
$validated = $this->validate($data);
|
||||||
|
$attachDiagnosticSnapshot = $this->capabilityResolver->can($actor, $tenant, Capabilities::SUPPORT_DIAGNOSTICS_VIEW);
|
||||||
|
|
||||||
|
$contextEnvelope = $operationRun instanceof OperationRun
|
||||||
|
? $this->supportRequestContextBuilder->forOperationRun($operationRun, $actor, $attachDiagnosticSnapshot)
|
||||||
|
: $this->supportRequestContextBuilder->forTenant($tenant, $actor, $attachDiagnosticSnapshot);
|
||||||
|
|
||||||
|
$contactName = $validated['contact_name'] ?? $this->normalizeNullableString($actor->name) ?? $this->normalizeNullableString($actor->email);
|
||||||
|
$contactEmail = $validated['contact_email'] ?? $this->normalizeNullableString($actor->email);
|
||||||
|
$connection = SupportRequest::query()->getModel()->getConnection();
|
||||||
|
|
||||||
|
return $connection->transaction(function () use (
|
||||||
|
$actor,
|
||||||
|
$contactEmail,
|
||||||
|
$contactName,
|
||||||
|
$contextEnvelope,
|
||||||
|
$operationRun,
|
||||||
|
$primaryContextType,
|
||||||
|
$tenant,
|
||||||
|
$validated,
|
||||||
|
): SupportRequest {
|
||||||
|
$supportRequest = SupportRequest::query()->create([
|
||||||
|
'workspace_id' => (int) $tenant->workspace_id,
|
||||||
|
'tenant_id' => (int) $tenant->getKey(),
|
||||||
|
'operation_run_id' => $operationRun instanceof OperationRun ? (int) $operationRun->getKey() : null,
|
||||||
|
'initiated_by_user_id' => (int) $actor->getKey(),
|
||||||
|
'internal_reference' => $this->supportRequestReferenceGenerator->generate(),
|
||||||
|
'primary_context_type' => $primaryContextType,
|
||||||
|
'attachment_mode' => (string) data_get($contextEnvelope, 'attachment_mode', SupportRequest::ATTACHMENT_MODE_CANONICAL_CONTEXT_ONLY),
|
||||||
|
'severity' => $validated['severity'],
|
||||||
|
'summary' => $validated['summary'],
|
||||||
|
'reproduction_notes' => $validated['reproduction_notes'],
|
||||||
|
'contact_name' => $contactName,
|
||||||
|
'contact_email' => $contactEmail,
|
||||||
|
'context_envelope' => $contextEnvelope,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$supportRequest->loadMissing(['tenant.workspace']);
|
||||||
|
|
||||||
|
$this->workspaceAuditLogger->logSupportRequestCreated($supportRequest, $actor);
|
||||||
|
|
||||||
|
return $supportRequest;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string, mixed> $data
|
||||||
|
* @return array{
|
||||||
|
* severity: string,
|
||||||
|
* summary: string,
|
||||||
|
* reproduction_notes: ?string,
|
||||||
|
* contact_name: ?string,
|
||||||
|
* contact_email: ?string,
|
||||||
|
* }
|
||||||
|
*/
|
||||||
|
private function validate(array $data): array
|
||||||
|
{
|
||||||
|
$validated = validator(
|
||||||
|
[
|
||||||
|
'severity' => $data['severity'] ?? SupportRequest::SEVERITY_NORMAL,
|
||||||
|
'summary' => $data['summary'] ?? null,
|
||||||
|
'reproduction_notes' => $data['reproduction_notes'] ?? null,
|
||||||
|
'contact_name' => $data['contact_name'] ?? null,
|
||||||
|
'contact_email' => $data['contact_email'] ?? null,
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'severity' => ['required', 'string', Rule::in(SupportRequest::severityValues())],
|
||||||
|
'summary' => ['required', 'string'],
|
||||||
|
'reproduction_notes' => ['nullable', 'string'],
|
||||||
|
'contact_name' => ['nullable', 'string'],
|
||||||
|
'contact_email' => ['nullable', 'email'],
|
||||||
|
],
|
||||||
|
)->validate();
|
||||||
|
|
||||||
|
$validated['summary'] = trim((string) $validated['summary']);
|
||||||
|
|
||||||
|
if ($validated['summary'] === '') {
|
||||||
|
throw ValidationException::withMessages([
|
||||||
|
'summary' => 'The summary field is required.',
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
$validated['reproduction_notes'] = $this->normalizeNullableString($validated['reproduction_notes'] ?? null);
|
||||||
|
$validated['contact_name'] = $this->normalizeNullableString($validated['contact_name'] ?? null);
|
||||||
|
$validated['contact_email'] = $this->normalizeNullableString($validated['contact_email'] ?? null);
|
||||||
|
|
||||||
|
return $validated;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function normalizeNullableString(mixed $value): ?string
|
||||||
|
{
|
||||||
|
if (! is_string($value)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$normalized = trim($value);
|
||||||
|
|
||||||
|
return $normalized === '' ? null : $normalized;
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -39,6 +39,7 @@
|
|||||||
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;
|
||||||
@ -661,6 +662,32 @@ 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',
|
||||||
@ -722,12 +749,17 @@ 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 that exposes context and links, not a declaration-backed mutable system workbench.',
|
'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.',
|
||||||
'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 without mutating actions.',
|
'proves' => 'The workspace detail page stays capability-gated and renders contextual tenant and run links while remaining outside the primary declaration-backed table contract.',
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'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,6 +8,8 @@ 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();
|
||||||
@ -22,4 +24,37 @@ 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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
98
apps/platform/database/factories/SupportRequestFactory.php
Normal file
98
apps/platform/database/factories/SupportRequestFactory.php
Normal file
@ -0,0 +1,98 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace Database\Factories;
|
||||||
|
|
||||||
|
use App\Models\OperationRun;
|
||||||
|
use App\Models\SupportRequest;
|
||||||
|
use App\Models\Tenant;
|
||||||
|
use App\Models\User;
|
||||||
|
use Illuminate\Database\Eloquent\Factories\Factory;
|
||||||
|
use Illuminate\Support\Str;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @extends \Illuminate\Database\Eloquent\Factories\Factory<\App\Models\SupportRequest>
|
||||||
|
*/
|
||||||
|
class SupportRequestFactory extends Factory
|
||||||
|
{
|
||||||
|
protected $model = SupportRequest::class;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Define the model's default state.
|
||||||
|
*
|
||||||
|
* @return array<string, mixed>
|
||||||
|
*/
|
||||||
|
public function definition(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'tenant_id' => Tenant::factory(),
|
||||||
|
'initiated_by_user_id' => User::factory(),
|
||||||
|
'internal_reference' => 'SR-'.strtoupper((string) Str::ulid()),
|
||||||
|
'primary_context_type' => SupportRequest::PRIMARY_CONTEXT_TENANT,
|
||||||
|
'attachment_mode' => SupportRequest::ATTACHMENT_MODE_DIAGNOSTIC_SNAPSHOT_ATTACHED,
|
||||||
|
'severity' => SupportRequest::SEVERITY_NORMAL,
|
||||||
|
'summary' => fake()->sentence(),
|
||||||
|
'reproduction_notes' => fake()->optional()->paragraph(),
|
||||||
|
'contact_name' => fake()->name(),
|
||||||
|
'contact_email' => fake()->safeEmail(),
|
||||||
|
'context_envelope' => [
|
||||||
|
'schema_version' => 1,
|
||||||
|
'generated_from' => 'factory',
|
||||||
|
'attachment_mode' => SupportRequest::ATTACHMENT_MODE_DIAGNOSTIC_SNAPSHOT_ATTACHED,
|
||||||
|
'primary_context' => [
|
||||||
|
'type' => SupportRequest::PRIMARY_CONTEXT_TENANT,
|
||||||
|
],
|
||||||
|
'canonical_context' => [
|
||||||
|
'sections' => [],
|
||||||
|
],
|
||||||
|
'diagnostic_snapshot' => [
|
||||||
|
'sections' => [],
|
||||||
|
],
|
||||||
|
'omissions' => [],
|
||||||
|
],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
public function canonicalContextOnly(): static
|
||||||
|
{
|
||||||
|
return $this->state(fn (array $attributes): array => [
|
||||||
|
'attachment_mode' => SupportRequest::ATTACHMENT_MODE_CANONICAL_CONTEXT_ONLY,
|
||||||
|
'context_envelope' => array_replace_recursive($attributes['context_envelope'] ?? [], [
|
||||||
|
'attachment_mode' => SupportRequest::ATTACHMENT_MODE_CANONICAL_CONTEXT_ONLY,
|
||||||
|
'diagnostic_snapshot' => null,
|
||||||
|
'omissions' => [[
|
||||||
|
'type' => 'diagnostic_snapshot',
|
||||||
|
'reason' => 'omitted_without_support_diagnostics_view',
|
||||||
|
]],
|
||||||
|
]),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function forOperationRun(OperationRun $operationRun): static
|
||||||
|
{
|
||||||
|
return $this->state(fn (): array => [
|
||||||
|
'tenant_id' => (int) $operationRun->tenant_id,
|
||||||
|
'workspace_id' => (int) $operationRun->workspace_id,
|
||||||
|
'operation_run_id' => (int) $operationRun->getKey(),
|
||||||
|
'primary_context_type' => SupportRequest::PRIMARY_CONTEXT_OPERATION_RUN,
|
||||||
|
'context_envelope' => [
|
||||||
|
'schema_version' => 1,
|
||||||
|
'generated_from' => 'factory',
|
||||||
|
'attachment_mode' => SupportRequest::ATTACHMENT_MODE_DIAGNOSTIC_SNAPSHOT_ATTACHED,
|
||||||
|
'primary_context' => [
|
||||||
|
'type' => SupportRequest::PRIMARY_CONTEXT_OPERATION_RUN,
|
||||||
|
'tenant_id' => (int) $operationRun->tenant_id,
|
||||||
|
'operation_run_id' => (int) $operationRun->getKey(),
|
||||||
|
],
|
||||||
|
'canonical_context' => [
|
||||||
|
'sections' => [],
|
||||||
|
],
|
||||||
|
'diagnostic_snapshot' => [
|
||||||
|
'sections' => [],
|
||||||
|
],
|
||||||
|
'omissions' => [],
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,46 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
use Illuminate\Database\Migrations\Migration;
|
||||||
|
use Illuminate\Database\Schema\Blueprint;
|
||||||
|
use Illuminate\Support\Facades\Schema;
|
||||||
|
|
||||||
|
return new class extends Migration
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Run the migrations.
|
||||||
|
*/
|
||||||
|
public function up(): void
|
||||||
|
{
|
||||||
|
Schema::create('support_requests', function (Blueprint $table): void {
|
||||||
|
$table->id();
|
||||||
|
$table->foreignId('workspace_id')->constrained('workspaces')->cascadeOnDelete();
|
||||||
|
$table->foreignId('tenant_id')->constrained('tenants')->cascadeOnDelete();
|
||||||
|
$table->foreignId('operation_run_id')->nullable()->constrained('operation_runs')->nullOnDelete();
|
||||||
|
$table->foreignId('initiated_by_user_id')->nullable()->constrained('users')->nullOnDelete();
|
||||||
|
$table->string('internal_reference')->unique();
|
||||||
|
$table->string('primary_context_type');
|
||||||
|
$table->string('attachment_mode');
|
||||||
|
$table->string('severity');
|
||||||
|
$table->text('summary');
|
||||||
|
$table->text('reproduction_notes')->nullable();
|
||||||
|
$table->string('contact_name')->nullable();
|
||||||
|
$table->string('contact_email')->nullable();
|
||||||
|
$table->jsonb('context_envelope')->default('{}');
|
||||||
|
$table->timestamps();
|
||||||
|
|
||||||
|
$table->index(['workspace_id', 'tenant_id']);
|
||||||
|
$table->index(['tenant_id', 'created_at']);
|
||||||
|
$table->index(['operation_run_id', 'created_at']);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reverse the migrations.
|
||||||
|
*/
|
||||||
|
public function down(): void
|
||||||
|
{
|
||||||
|
Schema::dropIfExists('support_requests');
|
||||||
|
}
|
||||||
|
};
|
||||||
@ -0,0 +1,164 @@
|
|||||||
|
<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>
|
||||||
@ -0,0 +1,19 @@
|
|||||||
|
<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,9 +1,23 @@
|
|||||||
@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>
|
||||||
@ -35,6 +49,115 @@
|
|||||||
@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,6 +9,10 @@
|
|||||||
/** @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 */
|
||||||
@ -24,6 +28,18 @@
|
|||||||
@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">
|
||||||
@ -37,12 +53,15 @@
|
|||||||
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>
|
||||||
@elseif ($statusEnum === ReviewPackStatus::Queued || $statusEnum === ReviewPackStatus::Generating)
|
@endif
|
||||||
|
|
||||||
|
@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">
|
||||||
@ -63,7 +82,9 @@
|
|||||||
Started {{ $pack->created_at?->diffForHumans() ?? '—' }}
|
Started {{ $pack->created_at?->diffForHumans() ?? '—' }}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@elseif ($statusEnum === ReviewPackStatus::Ready)
|
@endif
|
||||||
|
|
||||||
|
@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">
|
||||||
@ -116,13 +137,16 @@
|
|||||||
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>
|
||||||
@elseif ($statusEnum === ReviewPackStatus::Failed)
|
@endif
|
||||||
|
|
||||||
|
@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">
|
||||||
@ -163,12 +187,15 @@
|
|||||||
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>
|
||||||
@elseif ($statusEnum === ReviewPackStatus::Expired)
|
@endif
|
||||||
|
|
||||||
|
@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">
|
||||||
@ -189,11 +216,25 @@
|
|||||||
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>
|
||||||
|
|||||||
@ -0,0 +1,100 @@
|
|||||||
|
<?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,10 +10,14 @@
|
|||||||
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;
|
||||||
@ -68,6 +72,23 @@ 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');
|
||||||
@ -207,6 +228,36 @@ function evidenceSnapshotHeaderActions(Testable $component): array
|
|||||||
->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,12 +5,14 @@
|
|||||||
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;
|
||||||
@ -240,6 +242,47 @@ 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(),
|
||||||
|
|||||||
@ -0,0 +1,111 @@
|
|||||||
|
<?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']);
|
||||||
|
});
|
||||||
@ -0,0 +1,99 @@
|
|||||||
|
<?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();
|
||||||
|
});
|
||||||
@ -0,0 +1,64 @@
|
|||||||
|
<?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);
|
||||||
|
});
|
||||||
@ -0,0 +1,143 @@
|
|||||||
|
<?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,6 +950,7 @@ 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,
|
||||||
|
|||||||
@ -0,0 +1,49 @@
|
|||||||
|
<?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,6 +35,7 @@ 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,
|
||||||
@ -67,6 +68,7 @@ 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')
|
||||||
@ -76,6 +78,7 @@ 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,
|
||||||
|
|||||||
@ -0,0 +1,276 @@
|
|||||||
|
<?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,14 +2,17 @@
|
|||||||
|
|
||||||
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;
|
||||||
@ -131,3 +134,49 @@ 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,3 +27,10 @@
|
|||||||
->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,7 +3,13 @@
|
|||||||
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;
|
||||||
@ -36,12 +42,56 @@ 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);
|
||||||
|
|
||||||
|
|||||||
@ -0,0 +1,294 @@
|
|||||||
|
<?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,18 +3,23 @@
|
|||||||
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;
|
||||||
@ -157,6 +162,23 @@ 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 {
|
||||||
@ -210,6 +232,22 @@ function createEvidenceSnapshotForReviewPack(Tenant $tenant): EvidenceSnapshot
|
|||||||
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,11 +9,13 @@
|
|||||||
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);
|
||||||
|
|
||||||
@ -21,6 +23,17 @@
|
|||||||
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 {
|
||||||
@ -64,11 +77,9 @@
|
|||||||
'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)->assertOk();
|
$this->actingAs($user)->get($signedUrl)->assertNotFound();
|
||||||
});
|
});
|
||||||
|
|
||||||
// ─── REVIEW_PACK_VIEW Member ────────────────────────────────
|
// ─── REVIEW_PACK_VIEW Member ────────────────────────────────
|
||||||
@ -124,11 +135,15 @@
|
|||||||
$tenant->makeCurrent();
|
$tenant->makeCurrent();
|
||||||
Filament::setTenant($tenant, true);
|
Filament::setTenant($tenant, true);
|
||||||
|
|
||||||
Livewire::actingAs($user)
|
$component = Livewire::actingAs($user)
|
||||||
->test(ListReviewPacks::class)
|
->test(ListReviewPacks::class)
|
||||||
->assertActionVisible('generate_pack')
|
->assertTableEmptyStateActionsExistInOrder(['generate_first']);
|
||||||
->assertActionDisabled('generate_pack')
|
|
||||||
->assertActionExists('generate_pack', fn ($action): bool => $action->getTooltip() === UiTooltips::insufficientPermission());
|
$emptyStateAction = getReviewPackRbacEmptyStateAction($component, 'generate_first');
|
||||||
|
|
||||||
|
expect($emptyStateAction)->not->toBeNull()
|
||||||
|
->and($emptyStateAction?->isDisabled())->toBeTrue()
|
||||||
|
->and($emptyStateAction?->getTooltip())->toBe(UiTooltips::insufficientPermission());
|
||||||
});
|
});
|
||||||
|
|
||||||
// ─── REVIEW_PACK_MANAGE Member ──────────────────────────────
|
// ─── REVIEW_PACK_MANAGE Member ──────────────────────────────
|
||||||
@ -137,6 +152,12 @@
|
|||||||
$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,16 +13,19 @@
|
|||||||
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);
|
||||||
@ -31,6 +34,31 @@
|
|||||||
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([
|
||||||
@ -130,8 +158,7 @@ function seedReviewPackEvidence(Tenant $tenant): EvidenceSnapshot
|
|||||||
'tenant_id' => (int) $otherTenant->getKey(),
|
'tenant_id' => (int) $otherTenant->getKey(),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
$tenant->makeCurrent();
|
setTenantPanelContext($tenant);
|
||||||
Filament::setTenant($tenant, true);
|
|
||||||
|
|
||||||
Livewire::actingAs($user)
|
Livewire::actingAs($user)
|
||||||
->test(ListReviewPacks::class)
|
->test(ListReviewPacks::class)
|
||||||
@ -150,32 +177,112 @@ function seedReviewPackEvidence(Tenant $tenant): EvidenceSnapshot
|
|||||||
->assertSee('No review packs yet');
|
->assertSee('No review packs yet');
|
||||||
});
|
});
|
||||||
|
|
||||||
// ─── List Page Header Action ─────────────────────────────────
|
// ─── List Page Start CTA Placement ───────────────────────────
|
||||||
|
|
||||||
it('shows the generate_pack header action for a MANAGE user', function (): void {
|
it('shows generate only in the empty state when no review packs exist', 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_pack action for a readonly user', function (): void {
|
it('disables the generate_first action for a readonly user in the empty state', 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);
|
||||||
|
|
||||||
Livewire::actingAs($user)
|
$component = Livewire::actingAs($user)
|
||||||
->test(ListReviewPacks::class)
|
->test(ListReviewPacks::class)
|
||||||
->assertActionVisible('generate_pack')
|
->assertTableEmptyStateActionsExistInOrder(['generate_first']);
|
||||||
->assertActionDisabled('generate_pack')
|
|
||||||
->assertActionExists('generate_pack', fn ($action): bool => $action->getTooltip() === UiTooltips::insufficientPermission());
|
$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)
|
||||||
|
->test(ViewReviewPack::class, ['record' => $pack->getKey()])
|
||||||
|
->assertActionVisible('regenerate')
|
||||||
|
->assertActionDisabled('regenerate');
|
||||||
});
|
});
|
||||||
|
|
||||||
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 {
|
||||||
@ -225,6 +332,12 @@ 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);
|
||||||
|
|
||||||
@ -236,7 +349,7 @@ function seedReviewPackEvidence(Tenant $tenant): EvidenceSnapshot
|
|||||||
])
|
])
|
||||||
->assertNotified();
|
->assertNotified();
|
||||||
|
|
||||||
expect(ReviewPack::query()->count())->toBe(0);
|
expect(ReviewPack::query()->count())->toBe(1);
|
||||||
Queue::assertNothingPushed();
|
Queue::assertNothingPushed();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@ -11,6 +11,9 @@
|
|||||||
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;
|
||||||
@ -48,7 +51,13 @@ 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);
|
||||||
|
|||||||
@ -0,0 +1,66 @@
|
|||||||
|
<?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();
|
||||||
|
});
|
||||||
@ -0,0 +1,160 @@
|
|||||||
|
<?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');
|
||||||
|
});
|
||||||
@ -0,0 +1,156 @@
|
|||||||
|
<?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');
|
||||||
|
});
|
||||||
@ -0,0 +1,222 @@
|
|||||||
|
<?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()]);
|
||||||
|
});
|
||||||
@ -0,0 +1,66 @@
|
|||||||
|
<?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,3 +79,76 @@
|
|||||||
->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,6 +44,7 @@ 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', [])
|
||||||
@ -58,6 +59,7 @@ 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'])
|
||||||
@ -74,6 +76,7 @@ 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')
|
||||||
@ -97,6 +100,9 @@ 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);
|
||||||
|
|
||||||
@ -142,6 +148,18 @@ 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,6 +5,7 @@
|
|||||||
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;
|
||||||
|
|
||||||
@ -12,6 +13,14 @@
|
|||||||
$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,6 +30,14 @@
|
|||||||
'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)
|
||||||
@ -38,6 +46,7 @@
|
|||||||
|
|
||||||
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', [])
|
||||||
@ -56,6 +65,8 @@
|
|||||||
->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')
|
||||||
@ -75,6 +86,11 @@
|
|||||||
->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')
|
||||||
@ -88,5 +104,12 @@
|
|||||||
->where('key', 'retention_keep_last_default')
|
->where('key', 'retention_keep_last_default')
|
||||||
->first();
|
->first();
|
||||||
|
|
||||||
expect($setting)->not->toBeNull();
|
$aiSetting = WorkspaceSetting::query()
|
||||||
|
->where('workspace_id', (int) $workspace->getKey())
|
||||||
|
->where('domain', 'ai')
|
||||||
|
->where('key', 'policy_mode')
|
||||||
|
->first();
|
||||||
|
|
||||||
|
expect($setting)->not->toBeNull()
|
||||||
|
->and($aiSetting)->not->toBeNull();
|
||||||
});
|
});
|
||||||
|
|||||||
@ -0,0 +1,166 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
use App\Filament\Pages\Operations\TenantlessOperationRunViewer;
|
||||||
|
use App\Models\OperationRun;
|
||||||
|
use App\Models\SupportRequest;
|
||||||
|
use App\Models\Tenant;
|
||||||
|
use App\Models\User;
|
||||||
|
use App\Models\Workspace;
|
||||||
|
use App\Models\WorkspaceMembership;
|
||||||
|
use App\Support\OperationRunLinks;
|
||||||
|
use App\Support\OperationRunOutcome;
|
||||||
|
use App\Support\OperationRunStatus;
|
||||||
|
use App\Support\OperationRunType;
|
||||||
|
use App\Support\Workspaces\WorkspaceContext;
|
||||||
|
use Filament\Actions\Action;
|
||||||
|
use Filament\Actions\ActionGroup;
|
||||||
|
use Filament\Facades\Filament;
|
||||||
|
use Livewire\Livewire;
|
||||||
|
|
||||||
|
uses(\Illuminate\Foundation\Testing\RefreshDatabase::class);
|
||||||
|
|
||||||
|
function operationSupportRequestComponent(User $user, OperationRun $run): \Livewire\Features\SupportTesting\Testable
|
||||||
|
{
|
||||||
|
test()->actingAs($user);
|
||||||
|
Filament::setTenant(null, true);
|
||||||
|
session()->put(WorkspaceContext::SESSION_KEY, (int) $run->workspace_id);
|
||||||
|
|
||||||
|
return Livewire::actingAs($user)->test(TenantlessOperationRunViewer::class, ['run' => $run]);
|
||||||
|
}
|
||||||
|
|
||||||
|
function operationSupportRequestHeaderActions(\Livewire\Features\SupportTesting\Testable $component): array
|
||||||
|
{
|
||||||
|
$instance = $component->instance();
|
||||||
|
|
||||||
|
if ($instance->getCachedHeaderActions() === []) {
|
||||||
|
$instance->cacheInteractsWithHeaderActions();
|
||||||
|
}
|
||||||
|
|
||||||
|
return $instance->getCachedHeaderActions();
|
||||||
|
}
|
||||||
|
|
||||||
|
function operationSupportRequestHeaderPrimaryNames(\Livewire\Features\SupportTesting\Testable $component): array
|
||||||
|
{
|
||||||
|
return collect(operationSupportRequestHeaderActions($component))
|
||||||
|
->reject(static fn ($action): bool => $action instanceof ActionGroup)
|
||||||
|
->map(static fn ($action): ?string => $action instanceof Action ? $action->getName() : null)
|
||||||
|
->filter()
|
||||||
|
->values()
|
||||||
|
->all();
|
||||||
|
}
|
||||||
|
|
||||||
|
function operationSupportRequestHeaderMoreActionNames(\Livewire\Features\SupportTesting\Testable $component): array
|
||||||
|
{
|
||||||
|
$moreGroup = collect(operationSupportRequestHeaderActions($component))
|
||||||
|
->first(static fn ($action): bool => $action instanceof ActionGroup && $action->getLabel() === 'More');
|
||||||
|
|
||||||
|
return collect($moreGroup?->getActions() ?? [])
|
||||||
|
->map(static fn ($action): ?string => $action instanceof Action ? $action->getName() : null)
|
||||||
|
->filter()
|
||||||
|
->values()
|
||||||
|
->all();
|
||||||
|
}
|
||||||
|
|
||||||
|
function operationSupportRequestHeaderMoreAction(\Livewire\Features\SupportTesting\Testable $component, string $name): ?Action
|
||||||
|
{
|
||||||
|
$moreGroup = collect(operationSupportRequestHeaderActions($component))
|
||||||
|
->first(static fn ($action): bool => $action instanceof ActionGroup && $action->getLabel() === 'More');
|
||||||
|
|
||||||
|
$action = collect($moreGroup?->getActions() ?? [])
|
||||||
|
->first(static fn ($action): bool => $action instanceof Action && $action->getName() === $name);
|
||||||
|
|
||||||
|
return $action instanceof Action ? $action : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
it('creates a run-scoped support request from the tenantless operation viewer', function (): void {
|
||||||
|
$tenant = Tenant::factory()->create(['name' => 'Contoso Support Tenant']);
|
||||||
|
[$user, $tenant] = createUserWithTenant(tenant: $tenant, role: 'operator');
|
||||||
|
|
||||||
|
$run = OperationRun::factory()
|
||||||
|
->forTenant($tenant)
|
||||||
|
->create([
|
||||||
|
'type' => OperationRunType::BaselineCompare->value,
|
||||||
|
'status' => OperationRunStatus::Completed->value,
|
||||||
|
'outcome' => OperationRunOutcome::Failed->value,
|
||||||
|
'summary_counts' => [
|
||||||
|
'total' => 0,
|
||||||
|
'processed' => 0,
|
||||||
|
],
|
||||||
|
'failure_summary' => [[
|
||||||
|
'message' => 'Run failed after provider validation.',
|
||||||
|
]],
|
||||||
|
'completed_at' => now()->subMinutes(10),
|
||||||
|
]);
|
||||||
|
|
||||||
|
$component = operationSupportRequestComponent($user, $run);
|
||||||
|
|
||||||
|
expect(operationSupportRequestHeaderPrimaryNames($component))
|
||||||
|
->not->toContain('openSupportDiagnostics')
|
||||||
|
->not->toContain('requestSupport')
|
||||||
|
->and(operationSupportRequestHeaderMoreActionNames($component))
|
||||||
|
->toEqualCanonicalizing(['openSupportDiagnostics', 'requestSupport'])
|
||||||
|
->and(operationSupportRequestHeaderMoreAction($component, 'openSupportDiagnostics')?->isIconButton())
|
||||||
|
->toBeFalse();
|
||||||
|
|
||||||
|
$component
|
||||||
|
->assertActionVisible('openSupportDiagnostics')
|
||||||
|
->assertActionEnabled('openSupportDiagnostics')
|
||||||
|
->assertActionVisible('requestSupport')
|
||||||
|
->assertActionEnabled('requestSupport')
|
||||||
|
->assertActionExists('requestSupport', fn (Action $action): bool => $action->getLabel() === 'Request support')
|
||||||
|
->mountAction('requestSupport')
|
||||||
|
->setActionData([
|
||||||
|
'severity' => SupportRequest::SEVERITY_BLOCKING,
|
||||||
|
'summary' => 'This failed operation needs support escalation.',
|
||||||
|
'reproduction_notes' => 'Open the canonical run detail and submit the request from the grouped secondary action.',
|
||||||
|
])
|
||||||
|
->callMountedAction()
|
||||||
|
->assertHasNoActionErrors()
|
||||||
|
->assertNotified('Support request submitted');
|
||||||
|
|
||||||
|
$supportRequest = SupportRequest::query()->sole();
|
||||||
|
|
||||||
|
expect($supportRequest->internal_reference)->toMatch('/^SR-[0-9A-HJKMNP-TV-Z]{26}$/')
|
||||||
|
->and($supportRequest->workspace_id)->toBe((int) $tenant->workspace_id)
|
||||||
|
->and($supportRequest->tenant_id)->toBe((int) $tenant->getKey())
|
||||||
|
->and($supportRequest->initiated_by_user_id)->toBe((int) $user->getKey())
|
||||||
|
->and($supportRequest->primary_context_type)->toBe(SupportRequest::PRIMARY_CONTEXT_OPERATION_RUN)
|
||||||
|
->and($supportRequest->operation_run_id)->toBe((int) $run->getKey())
|
||||||
|
->and($supportRequest->attachment_mode)->toBe(SupportRequest::ATTACHMENT_MODE_DIAGNOSTIC_SNAPSHOT_ATTACHED)
|
||||||
|
->and($supportRequest->severity)->toBe(SupportRequest::SEVERITY_BLOCKING)
|
||||||
|
->and($supportRequest->summary)->toBe('This failed operation needs support escalation.')
|
||||||
|
->and(data_get($supportRequest->context_envelope, 'primary_context.type'))->toBe('operation_run')
|
||||||
|
->and(data_get($supportRequest->context_envelope, 'primary_context.operation_run_id'))->toBe((int) $run->getKey())
|
||||||
|
->and(data_get($supportRequest->context_envelope, 'diagnostic_snapshot'))->toBeArray();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps tenantless operation detail deny-as-not-found for workspace members without tenant entitlement', function (): void {
|
||||||
|
$workspace = Workspace::factory()->create();
|
||||||
|
$tenant = Tenant::factory()->create([
|
||||||
|
'workspace_id' => (int) $workspace->getKey(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
$user = User::factory()->create();
|
||||||
|
|
||||||
|
WorkspaceMembership::factory()->create([
|
||||||
|
'workspace_id' => (int) $workspace->getKey(),
|
||||||
|
'user_id' => (int) $user->getKey(),
|
||||||
|
'role' => 'owner',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$run = OperationRun::factory()->create([
|
||||||
|
'tenant_id' => (int) $tenant->getKey(),
|
||||||
|
'workspace_id' => (int) $workspace->getKey(),
|
||||||
|
'type' => OperationRunType::BaselineCompare->value,
|
||||||
|
'status' => OperationRunStatus::Completed->value,
|
||||||
|
'outcome' => OperationRunOutcome::Succeeded->value,
|
||||||
|
'completed_at' => now(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->actingAs($user)
|
||||||
|
->withSession([WorkspaceContext::SESSION_KEY => (int) $workspace->getKey()])
|
||||||
|
->get(route('admin.operations.view', ['run' => (int) $run->getKey()]))
|
||||||
|
->assertNotFound();
|
||||||
|
});
|
||||||
@ -0,0 +1,204 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
use App\Filament\Pages\Operations\TenantlessOperationRunViewer;
|
||||||
|
use App\Filament\Pages\TenantDashboard;
|
||||||
|
use App\Models\AuditLog;
|
||||||
|
use App\Models\OperationRun;
|
||||||
|
use App\Models\ProviderConnection;
|
||||||
|
use App\Models\SupportRequest;
|
||||||
|
use App\Models\Tenant;
|
||||||
|
use App\Models\User;
|
||||||
|
use App\Support\Audit\AuditActionId;
|
||||||
|
use App\Support\OperationRunOutcome;
|
||||||
|
use App\Support\OperationRunStatus;
|
||||||
|
use App\Support\OperationRunType;
|
||||||
|
use App\Support\Providers\ProviderReasonCodes;
|
||||||
|
use App\Support\Providers\ProviderVerificationStatus;
|
||||||
|
use App\Support\Workspaces\WorkspaceContext;
|
||||||
|
use Filament\Facades\Filament;
|
||||||
|
use Livewire\Livewire;
|
||||||
|
|
||||||
|
uses(\Illuminate\Foundation\Testing\RefreshDatabase::class);
|
||||||
|
|
||||||
|
function supportRequestAuditTenantComponent(User $user, Tenant $tenant): \Livewire\Features\SupportTesting\Testable
|
||||||
|
{
|
||||||
|
test()->actingAs($user);
|
||||||
|
session()->put(WorkspaceContext::SESSION_KEY, (int) $tenant->workspace_id);
|
||||||
|
setTenantPanelContext($tenant);
|
||||||
|
|
||||||
|
return Livewire::actingAs($user)->test(TenantDashboard::class);
|
||||||
|
}
|
||||||
|
|
||||||
|
function supportRequestAuditOperationComponent(User $user, OperationRun $run): \Livewire\Features\SupportTesting\Testable
|
||||||
|
{
|
||||||
|
test()->actingAs($user);
|
||||||
|
Filament::setTenant(null, true);
|
||||||
|
session()->put(WorkspaceContext::SESSION_KEY, (int) $run->workspace_id);
|
||||||
|
|
||||||
|
return Livewire::actingAs($user)->test(TenantlessOperationRunViewer::class, ['run' => $run]);
|
||||||
|
}
|
||||||
|
|
||||||
|
it('records a redacted audit entry for tenant-scoped support requests', function (): void {
|
||||||
|
$tenant = Tenant::factory()->create(['name' => 'Audit Support Tenant']);
|
||||||
|
[$user, $tenant] = createUserWithTenant(tenant: $tenant, role: 'owner');
|
||||||
|
|
||||||
|
ProviderConnection::factory()
|
||||||
|
->withCredential()
|
||||||
|
->create([
|
||||||
|
'tenant_id' => (int) $tenant->getKey(),
|
||||||
|
'workspace_id' => (int) $tenant->workspace_id,
|
||||||
|
'display_name' => 'Audit Microsoft connection',
|
||||||
|
'verification_status' => ProviderVerificationStatus::Blocked->value,
|
||||||
|
'last_error_reason_code' => ProviderReasonCodes::ProviderPermissionMissing,
|
||||||
|
'last_error_message' => 'tenant-provider-secret',
|
||||||
|
]);
|
||||||
|
|
||||||
|
supportRequestAuditTenantComponent($user, $tenant)
|
||||||
|
->mountAction('requestSupport')
|
||||||
|
->setActionData([
|
||||||
|
'severity' => SupportRequest::SEVERITY_HIGH,
|
||||||
|
'summary' => 'Need tenant support audit proof.',
|
||||||
|
])
|
||||||
|
->callMountedAction()
|
||||||
|
->assertHasNoActionErrors();
|
||||||
|
|
||||||
|
$supportRequest = SupportRequest::query()->sole();
|
||||||
|
|
||||||
|
$audit = AuditLog::query()
|
||||||
|
->where('action', AuditActionId::SupportRequestCreated->value)
|
||||||
|
->latest('id')
|
||||||
|
->first();
|
||||||
|
|
||||||
|
expect($audit)->not->toBeNull()
|
||||||
|
->and($audit?->workspace_id)->toBe((int) $tenant->workspace_id)
|
||||||
|
->and($audit?->tenant_id)->toBe((int) $tenant->getKey())
|
||||||
|
->and($audit?->resource_type)->toBe('support_request')
|
||||||
|
->and($audit?->resource_id)->toBe((string) $supportRequest->getKey())
|
||||||
|
->and($audit?->target_label)->toBe($supportRequest->internal_reference)
|
||||||
|
->and($audit?->operation_run_id)->toBeNull()
|
||||||
|
->and(data_get($audit?->metadata, 'internal_reference'))->toBe($supportRequest->internal_reference)
|
||||||
|
->and(data_get($audit?->metadata, 'primary_context_type'))->toBe(SupportRequest::PRIMARY_CONTEXT_TENANT)
|
||||||
|
->and(data_get($audit?->metadata, 'primary_context_id'))->toBe((string) $tenant->getKey())
|
||||||
|
->and(data_get($audit?->metadata, 'attachment_mode'))->toBe(SupportRequest::ATTACHMENT_MODE_DIAGNOSTIC_SNAPSHOT_ATTACHED)
|
||||||
|
->and(data_get($audit?->metadata, 'redaction_mode'))->toBe('default_redacted')
|
||||||
|
->and((string) json_encode($audit?->metadata))->not->toContain('tenant-provider-secret');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('records a redacted audit entry for run-scoped support requests', function (): void {
|
||||||
|
$tenant = Tenant::factory()->create();
|
||||||
|
[$user, $tenant] = createUserWithTenant(tenant: $tenant, role: 'operator');
|
||||||
|
|
||||||
|
$run = OperationRun::factory()->create([
|
||||||
|
'tenant_id' => (int) $tenant->getKey(),
|
||||||
|
'workspace_id' => (int) $tenant->workspace_id,
|
||||||
|
'type' => OperationRunType::BaselineCompare->value,
|
||||||
|
'status' => OperationRunStatus::Completed->value,
|
||||||
|
'outcome' => OperationRunOutcome::Failed->value,
|
||||||
|
'summary_counts' => [
|
||||||
|
'total' => 0,
|
||||||
|
'processed' => 0,
|
||||||
|
],
|
||||||
|
'context' => [
|
||||||
|
'raw_response_body' => 'run-provider-secret',
|
||||||
|
],
|
||||||
|
'failure_summary' => [[
|
||||||
|
'message' => 'Run failed after provider validation.',
|
||||||
|
]],
|
||||||
|
'completed_at' => now(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
supportRequestAuditOperationComponent($user, $run)
|
||||||
|
->mountAction('requestSupport')
|
||||||
|
->setActionData([
|
||||||
|
'severity' => SupportRequest::SEVERITY_BLOCKING,
|
||||||
|
'summary' => 'Need run support audit proof.',
|
||||||
|
])
|
||||||
|
->callMountedAction()
|
||||||
|
->assertHasNoActionErrors();
|
||||||
|
|
||||||
|
$supportRequest = SupportRequest::query()->sole();
|
||||||
|
|
||||||
|
$audit = AuditLog::query()
|
||||||
|
->where('action', AuditActionId::SupportRequestCreated->value)
|
||||||
|
->latest('id')
|
||||||
|
->first();
|
||||||
|
|
||||||
|
expect($audit)->not->toBeNull()
|
||||||
|
->and($audit?->workspace_id)->toBe((int) $tenant->workspace_id)
|
||||||
|
->and($audit?->tenant_id)->toBe((int) $tenant->getKey())
|
||||||
|
->and($audit?->resource_type)->toBe('support_request')
|
||||||
|
->and($audit?->resource_id)->toBe((string) $supportRequest->getKey())
|
||||||
|
->and($audit?->target_label)->toBe($supportRequest->internal_reference)
|
||||||
|
->and($audit?->operation_run_id)->toBe((int) $run->getKey())
|
||||||
|
->and(data_get($audit?->metadata, 'internal_reference'))->toBe($supportRequest->internal_reference)
|
||||||
|
->and(data_get($audit?->metadata, 'primary_context_type'))->toBe(SupportRequest::PRIMARY_CONTEXT_OPERATION_RUN)
|
||||||
|
->and(data_get($audit?->metadata, 'primary_context_id'))->toBe((string) $run->getKey())
|
||||||
|
->and(data_get($audit?->metadata, 'attachment_mode'))->toBe(SupportRequest::ATTACHMENT_MODE_DIAGNOSTIC_SNAPSHOT_ATTACHED)
|
||||||
|
->and(data_get($audit?->metadata, 'redaction_mode'))->toBe('default_redacted')
|
||||||
|
->and((string) json_encode($audit?->metadata))->not->toContain('run-provider-secret');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('creates distinct support references for duplicate submissions without outbound http or operation-run side effects', function (): void {
|
||||||
|
$tenant = Tenant::factory()->create();
|
||||||
|
[$user, $tenant] = createUserWithTenant(tenant: $tenant, role: 'operator');
|
||||||
|
|
||||||
|
$run = OperationRun::factory()->create([
|
||||||
|
'tenant_id' => (int) $tenant->getKey(),
|
||||||
|
'workspace_id' => (int) $tenant->workspace_id,
|
||||||
|
'type' => OperationRunType::BaselineCompare->value,
|
||||||
|
'status' => OperationRunStatus::Completed->value,
|
||||||
|
'outcome' => OperationRunOutcome::Failed->value,
|
||||||
|
'summary_counts' => [
|
||||||
|
'total' => 0,
|
||||||
|
'processed' => 0,
|
||||||
|
],
|
||||||
|
'failure_summary' => [[
|
||||||
|
'message' => 'Run failed after provider validation.',
|
||||||
|
]],
|
||||||
|
'completed_at' => now(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
$component = supportRequestAuditOperationComponent($user, $run);
|
||||||
|
$existingRunCount = OperationRun::query()->count();
|
||||||
|
|
||||||
|
assertNoOutboundHttp(function () use ($component): void {
|
||||||
|
$component
|
||||||
|
->mountAction('requestSupport')
|
||||||
|
->setActionData([
|
||||||
|
'severity' => SupportRequest::SEVERITY_HIGH,
|
||||||
|
'summary' => 'Duplicate run support request.',
|
||||||
|
])
|
||||||
|
->callMountedAction()
|
||||||
|
->assertHasNoActionErrors()
|
||||||
|
->mountAction('requestSupport')
|
||||||
|
->setActionData([
|
||||||
|
'severity' => SupportRequest::SEVERITY_HIGH,
|
||||||
|
'summary' => 'Duplicate run support request.',
|
||||||
|
])
|
||||||
|
->callMountedAction()
|
||||||
|
->assertHasNoActionErrors();
|
||||||
|
});
|
||||||
|
|
||||||
|
$supportRequests = SupportRequest::query()
|
||||||
|
->orderBy('id')
|
||||||
|
->get();
|
||||||
|
|
||||||
|
$auditReferences = AuditLog::query()
|
||||||
|
->where('action', AuditActionId::SupportRequestCreated->value)
|
||||||
|
->orderBy('id')
|
||||||
|
->pluck('target_label');
|
||||||
|
|
||||||
|
expect($supportRequests)->toHaveCount(2)
|
||||||
|
->and($supportRequests->pluck('summary')->all())->toBe([
|
||||||
|
'Duplicate run support request.',
|
||||||
|
'Duplicate run support request.',
|
||||||
|
])
|
||||||
|
->and($supportRequests->pluck('internal_reference')->unique())->toHaveCount(2)
|
||||||
|
->and($supportRequests->pluck('operation_run_id')->unique()->all())->toBe([(int) $run->getKey()])
|
||||||
|
->and($auditReferences->all())->toBe($supportRequests->pluck('internal_reference')->all())
|
||||||
|
->and(OperationRun::query()->count())->toBe($existingRunCount)
|
||||||
|
->and($run->fresh()?->status)->toBe(OperationRunStatus::Completed->value)
|
||||||
|
->and($run->fresh()?->outcome)->toBe(OperationRunOutcome::Failed->value);
|
||||||
|
});
|
||||||
@ -0,0 +1,75 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
use App\Filament\Pages\Operations\TenantlessOperationRunViewer;
|
||||||
|
use App\Filament\Pages\TenantDashboard;
|
||||||
|
use App\Models\OperationRun;
|
||||||
|
use App\Models\SupportRequest;
|
||||||
|
use App\Models\Tenant;
|
||||||
|
use App\Models\User;
|
||||||
|
use App\Support\OperationRunOutcome;
|
||||||
|
use App\Support\OperationRunStatus;
|
||||||
|
use App\Support\OperationRunType;
|
||||||
|
use App\Support\Workspaces\WorkspaceContext;
|
||||||
|
use Filament\Facades\Filament;
|
||||||
|
use Livewire\Livewire;
|
||||||
|
|
||||||
|
uses(\Illuminate\Foundation\Testing\RefreshDatabase::class);
|
||||||
|
|
||||||
|
function supportRequestAuthorizationTenantComponent(User $user, Tenant $tenant): \Livewire\Features\SupportTesting\Testable
|
||||||
|
{
|
||||||
|
test()->actingAs($user);
|
||||||
|
session()->put(WorkspaceContext::SESSION_KEY, (int) $tenant->workspace_id);
|
||||||
|
setTenantPanelContext($tenant);
|
||||||
|
|
||||||
|
return Livewire::actingAs($user)->test(TenantDashboard::class);
|
||||||
|
}
|
||||||
|
|
||||||
|
function supportRequestAuthorizationOperationComponent(User $user, OperationRun $run): \Livewire\Features\SupportTesting\Testable
|
||||||
|
{
|
||||||
|
test()->actingAs($user);
|
||||||
|
Filament::setTenant(null, true);
|
||||||
|
session()->put(WorkspaceContext::SESSION_KEY, (int) $run->workspace_id);
|
||||||
|
|
||||||
|
return Livewire::actingAs($user)->test(TenantlessOperationRunViewer::class, ['run' => $run]);
|
||||||
|
}
|
||||||
|
|
||||||
|
it('returns forbidden for entitled tenant members without support request capability', function (): void {
|
||||||
|
$tenant = Tenant::factory()->create();
|
||||||
|
[$user, $tenant] = createUserWithTenant(tenant: $tenant, role: 'readonly');
|
||||||
|
|
||||||
|
supportRequestAuthorizationTenantComponent($user, $tenant)
|
||||||
|
->assertActionVisible('requestSupport')
|
||||||
|
->assertActionDisabled('requestSupport')
|
||||||
|
->call('authorizeTenantSupportRequest')
|
||||||
|
->assertForbidden();
|
||||||
|
|
||||||
|
expect(SupportRequest::query()->count())->toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns forbidden for entitled run viewers without support request capability', function (): void {
|
||||||
|
$tenant = Tenant::factory()->create();
|
||||||
|
[$user, $tenant] = createUserWithTenant(tenant: $tenant, role: 'readonly');
|
||||||
|
|
||||||
|
$run = OperationRun::factory()->create([
|
||||||
|
'tenant_id' => (int) $tenant->getKey(),
|
||||||
|
'workspace_id' => (int) $tenant->workspace_id,
|
||||||
|
'type' => OperationRunType::BaselineCompare->value,
|
||||||
|
'status' => OperationRunStatus::Completed->value,
|
||||||
|
'outcome' => OperationRunOutcome::Succeeded->value,
|
||||||
|
'summary_counts' => [
|
||||||
|
'total' => 0,
|
||||||
|
'processed' => 0,
|
||||||
|
],
|
||||||
|
'completed_at' => now(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
supportRequestAuthorizationOperationComponent($user, $run)
|
||||||
|
->assertActionVisible('requestSupport')
|
||||||
|
->assertActionDisabled('requestSupport')
|
||||||
|
->call('authorizeOperationRunSupportRequest')
|
||||||
|
->assertForbidden();
|
||||||
|
|
||||||
|
expect(SupportRequest::query()->count())->toBe(0);
|
||||||
|
});
|
||||||
@ -0,0 +1,137 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
use App\Filament\Pages\TenantDashboard;
|
||||||
|
use App\Models\SupportRequest;
|
||||||
|
use App\Models\Tenant;
|
||||||
|
use App\Models\User;
|
||||||
|
use App\Models\WorkspaceMembership;
|
||||||
|
use App\Services\Auth\CapabilityResolver;
|
||||||
|
use App\Support\Auth\Capabilities;
|
||||||
|
use App\Support\Workspaces\WorkspaceContext;
|
||||||
|
use Filament\Actions\Action;
|
||||||
|
use Livewire\Livewire;
|
||||||
|
|
||||||
|
use function Pest\Laravel\mock;
|
||||||
|
|
||||||
|
uses(\Illuminate\Foundation\Testing\RefreshDatabase::class);
|
||||||
|
|
||||||
|
function tenantSupportRequestComponent(User $user, Tenant $tenant): \Livewire\Features\SupportTesting\Testable
|
||||||
|
{
|
||||||
|
test()->actingAs($user);
|
||||||
|
session()->put(WorkspaceContext::SESSION_KEY, (int) $tenant->workspace_id);
|
||||||
|
setTenantPanelContext($tenant);
|
||||||
|
|
||||||
|
return Livewire::actingAs($user)->test(TenantDashboard::class);
|
||||||
|
}
|
||||||
|
|
||||||
|
it('creates a tenant support request from the dashboard', function (): void {
|
||||||
|
$tenant = Tenant::factory()->create(['name' => 'Contoso Support Tenant']);
|
||||||
|
[$user, $tenant] = createUserWithTenant(tenant: $tenant, role: 'owner');
|
||||||
|
|
||||||
|
tenantSupportRequestComponent($user, $tenant)
|
||||||
|
->assertActionVisible('requestSupport')
|
||||||
|
->assertActionEnabled('requestSupport')
|
||||||
|
->assertActionExists('requestSupport', fn (Action $action): bool => $action->getLabel() === 'Request support')
|
||||||
|
->mountAction('requestSupport')
|
||||||
|
->setActionData([
|
||||||
|
'severity' => SupportRequest::SEVERITY_HIGH,
|
||||||
|
'summary' => 'Policy sync failed after the latest tenant refresh.',
|
||||||
|
'reproduction_notes' => 'Open the tenant dashboard after a failed sync and request support from the header action.',
|
||||||
|
'contact_name' => 'Ops On Call',
|
||||||
|
'contact_email' => 'ops@example.test',
|
||||||
|
])
|
||||||
|
->callMountedAction()
|
||||||
|
->assertHasNoActionErrors()
|
||||||
|
->assertNotified('Support request submitted');
|
||||||
|
|
||||||
|
$supportRequest = SupportRequest::query()->sole();
|
||||||
|
|
||||||
|
expect($supportRequest->internal_reference)->toMatch('/^SR-[0-9A-HJKMNP-TV-Z]{26}$/')
|
||||||
|
->and($supportRequest->workspace_id)->toBe((int) $tenant->workspace_id)
|
||||||
|
->and($supportRequest->tenant_id)->toBe((int) $tenant->getKey())
|
||||||
|
->and($supportRequest->initiated_by_user_id)->toBe((int) $user->getKey())
|
||||||
|
->and($supportRequest->primary_context_type)->toBe(SupportRequest::PRIMARY_CONTEXT_TENANT)
|
||||||
|
->and($supportRequest->operation_run_id)->toBeNull()
|
||||||
|
->and($supportRequest->attachment_mode)->toBe(SupportRequest::ATTACHMENT_MODE_DIAGNOSTIC_SNAPSHOT_ATTACHED)
|
||||||
|
->and($supportRequest->severity)->toBe(SupportRequest::SEVERITY_HIGH)
|
||||||
|
->and($supportRequest->summary)->toBe('Policy sync failed after the latest tenant refresh.')
|
||||||
|
->and($supportRequest->reproduction_notes)->toContain('failed sync')
|
||||||
|
->and($supportRequest->contact_name)->toBe('Ops On Call')
|
||||||
|
->and($supportRequest->contact_email)->toBe('ops@example.test')
|
||||||
|
->and(data_get($supportRequest->context_envelope, 'primary_context.type'))->toBe('tenant')
|
||||||
|
->and(data_get($supportRequest->context_envelope, 'primary_context.tenant_id'))->toBe((int) $tenant->getKey())
|
||||||
|
->and(data_get($supportRequest->context_envelope, 'diagnostic_snapshot'))->toBeArray();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('stores canonical context only when the creator cannot view support diagnostics', function (): void {
|
||||||
|
$tenant = Tenant::factory()->create();
|
||||||
|
[$user, $tenant] = createUserWithTenant(tenant: $tenant, role: 'owner');
|
||||||
|
|
||||||
|
mock(CapabilityResolver::class, function ($mock) use ($tenant): void {
|
||||||
|
$mock->shouldReceive('primeMemberships')->andReturnNull();
|
||||||
|
$mock->shouldReceive('isMember')
|
||||||
|
->andReturnUsing(static fn ($user, Tenant $resolvedTenant): bool => (int) $resolvedTenant->getKey() === (int) $tenant->getKey());
|
||||||
|
|
||||||
|
$mock->shouldReceive('can')
|
||||||
|
->andReturnUsing(static function ($user, Tenant $resolvedTenant, string $capability) use ($tenant): bool {
|
||||||
|
expect((int) $resolvedTenant->getKey())->toBe((int) $tenant->getKey());
|
||||||
|
|
||||||
|
return match ($capability) {
|
||||||
|
Capabilities::SUPPORT_REQUESTS_CREATE => true,
|
||||||
|
Capabilities::SUPPORT_DIAGNOSTICS_VIEW => false,
|
||||||
|
default => true,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
tenantSupportRequestComponent($user, $tenant)
|
||||||
|
->assertActionVisible('requestSupport')
|
||||||
|
->assertActionEnabled('requestSupport')
|
||||||
|
->mountAction('requestSupport')
|
||||||
|
->setActionData([
|
||||||
|
'summary' => 'Need help reviewing the latest tenant support context.',
|
||||||
|
])
|
||||||
|
->callMountedAction()
|
||||||
|
->assertHasNoActionErrors()
|
||||||
|
->assertNotified('Support request submitted');
|
||||||
|
|
||||||
|
$supportRequest = SupportRequest::query()->sole();
|
||||||
|
|
||||||
|
expect($supportRequest->severity)->toBe(SupportRequest::SEVERITY_NORMAL)
|
||||||
|
->and($supportRequest->contact_name)->toBe($user->name)
|
||||||
|
->and($supportRequest->contact_email)->toBe($user->email)
|
||||||
|
->and($supportRequest->attachment_mode)->toBe(SupportRequest::ATTACHMENT_MODE_CANONICAL_CONTEXT_ONLY)
|
||||||
|
->and(data_get($supportRequest->context_envelope, 'diagnostic_snapshot'))->toBeNull()
|
||||||
|
->and(data_get($supportRequest->context_envelope, 'omissions.0.reason'))->toBe('omitted_without_support_diagnostics_view');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps tenant dashboard support requests deny-as-not-found for workspace members without tenant entitlement', function (): void {
|
||||||
|
$tenant = Tenant::factory()->create();
|
||||||
|
$user = User::factory()->create();
|
||||||
|
|
||||||
|
WorkspaceMembership::factory()->create([
|
||||||
|
'workspace_id' => (int) $tenant->workspace_id,
|
||||||
|
'user_id' => (int) $user->getKey(),
|
||||||
|
'role' => 'operator',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->actingAs($user)
|
||||||
|
->withSession([WorkspaceContext::SESSION_KEY => (int) $tenant->workspace_id])
|
||||||
|
->get(TenantDashboard::getUrl(panel: 'tenant', tenant: $tenant))
|
||||||
|
->assertNotFound();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns forbidden for entitled tenant members without support request capability', function (): void {
|
||||||
|
$tenant = Tenant::factory()->create();
|
||||||
|
[$user, $tenant] = createUserWithTenant(tenant: $tenant, role: 'readonly');
|
||||||
|
|
||||||
|
tenantSupportRequestComponent($user, $tenant)
|
||||||
|
->assertActionVisible('requestSupport')
|
||||||
|
->assertActionDisabled('requestSupport')
|
||||||
|
->call('authorizeTenantSupportRequest')
|
||||||
|
->assertForbidden();
|
||||||
|
|
||||||
|
expect(SupportRequest::query()->count())->toBe(0);
|
||||||
|
});
|
||||||
@ -0,0 +1,109 @@
|
|||||||
|
<?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,7 +5,9 @@
|
|||||||
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;
|
||||||
|
|
||||||
@ -119,3 +121,38 @@
|
|||||||
->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');
|
||||||
|
});
|
||||||
|
|||||||
@ -0,0 +1,192 @@
|
|||||||
|
<?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']);
|
||||||
|
});
|
||||||
@ -0,0 +1,20 @@
|
|||||||
|
<?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'],
|
||||||
|
]);
|
||||||
@ -0,0 +1,199 @@
|
|||||||
|
<?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,
|
||||||
|
]);
|
||||||
|
});
|
||||||
@ -0,0 +1,155 @@
|
|||||||
|
<?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');
|
||||||
|
});
|
||||||
@ -0,0 +1,34 @@
|
|||||||
|
<?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',
|
||||||
|
]);
|
||||||
|
});
|
||||||
@ -0,0 +1,54 @@
|
|||||||
|
<?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']);
|
||||||
|
});
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue
Block a user