Compare commits

..

6 Commits

Author SHA1 Message Date
Ahmed Darrazi
6383f205a1 chore: commit all changes (automated) 2026-04-27T21:17:40Z
Some checks failed
PR Fast Feedback / fast-feedback (pull_request) Failing after 1m3s
2026-04-27 23:17:40 +02:00
Ahmed Darrazi
0739018ee5 Merge remote-tracking branch 'origin/dev' into platform-dev 2026-04-27 19:36:43 +02:00
Ahmed Darrazi
9a02261f5c Merge remote-tracking branch 'origin/dev' into platform-dev 2026-04-27 15:03:58 +02:00
Ahmed Darrazi
65ec1d5904 Merge remote-tracking branch 'origin/dev' into platform-dev 2026-04-27 10:33:23 +02:00
Ahmed Darrazi
f05857c276 Merge remote-tracking branch 'origin/dev' into platform-dev 2026-04-27 02:13:30 +02:00
Ahmed Darrazi
9f5d3293c5 Merge remote-tracking branch 'origin/dev' into platform-dev 2026-04-26 22:53:42 +02:00
122 changed files with 231 additions and 12774 deletions

View File

@ -260,10 +260,6 @@ ## Active Technologies
- PostgreSQL via existing `managed_tenant_onboarding_sessions`, `provider_connections`, `operation_runs`, and stored permission-posture data; no new persistence planned (240-tenant-onboarding-readiness) - PostgreSQL via existing `managed_tenant_onboarding_sessions`, `provider_connections`, `operation_runs`, and stored permission-posture data; no new persistence planned (240-tenant-onboarding-readiness)
- PHP 8.4 (Laravel 12) + Laravel 12 + Filament v5 + Livewire v4 + Pest; existing `OperationRunLinks`, `GovernanceRunDiagnosticSummaryBuilder`, `ProviderReasonTranslator`, `RelatedNavigationResolver`, `RedactionIntegrity`, `WorkspaceAuditLogger` (241-support-diagnostic-pack) - PHP 8.4 (Laravel 12) + Laravel 12 + Filament v5 + Livewire v4 + Pest; existing `OperationRunLinks`, `GovernanceRunDiagnosticSummaryBuilder`, `ProviderReasonTranslator`, `RelatedNavigationResolver`, `RedactionIntegrity`, `WorkspaceAuditLogger` (241-support-diagnostic-pack)
- PostgreSQL via existing `operation_runs`, `provider_connections`, `findings`, `stored_reports`, `tenant_reviews`, `review_packs`, and `audit_logs`; no new persistence planned (241-support-diagnostic-pack) - PostgreSQL via existing `operation_runs`, `provider_connections`, `findings`, `stored_reports`, `tenant_reviews`, `review_packs`, and `audit_logs`; no new persistence planned (241-support-diagnostic-pack)
- PHP 8.4, Laravel 12 + Filament v5, Livewire v4, Pest v4, existing review/evidence/review-pack/audit/RBAC support services (249-customer-review-workspace)
- PostgreSQL via existing `tenant_reviews`, `review_packs`, `evidence_snapshots`, findings / finding-exception truth, workspace memberships, and `audit_logs`; no new persistence planned (249-customer-review-workspace)
- PHP 8.4 (Laravel 12) + Filament v5 + Livewire v4, existing workspace settings stack (`SettingsRegistry`, `SettingsResolver`, `SettingsWriter`), `WorkspaceEntitlementResolver`, `ReviewPackService`, system directory detail page (251-commercial-entitlements-billing-state)
- PostgreSQL via existing `workspace_settings` rows plus existing audit log records; no new table or billing/account model (251-commercial-entitlements-billing-state)
- PHP 8.4.15 (feat/005-bulk-operations) - PHP 8.4.15 (feat/005-bulk-operations)
@ -298,9 +294,9 @@ ## Code Style
PHP 8.4.15: Follow standard conventions PHP 8.4.15: Follow standard conventions
## Recent Changes ## Recent Changes
- 251-commercial-entitlements-billing-state: Added PHP 8.4 (Laravel 12) + Filament v5 + Livewire v4, existing workspace settings stack (`SettingsRegistry`, `SettingsResolver`, `SettingsWriter`), `WorkspaceEntitlementResolver`, `ReviewPackService`, system directory detail page
- 249-customer-review-workspace: Added PHP 8.4, Laravel 12 + Filament v5, Livewire v4, Pest v4, existing review/evidence/review-pack/audit/RBAC support services
- 241-support-diagnostic-pack: Added PHP 8.4 (Laravel 12) + Laravel 12 + Filament v5 + Livewire v4 + Pest; existing `OperationRunLinks`, `GovernanceRunDiagnosticSummaryBuilder`, `ProviderReasonTranslator`, `RelatedNavigationResolver`, `RedactionIntegrity`, `WorkspaceAuditLogger` - 241-support-diagnostic-pack: Added PHP 8.4 (Laravel 12) + Laravel 12 + Filament v5 + Livewire v4 + Pest; existing `OperationRunLinks`, `GovernanceRunDiagnosticSummaryBuilder`, `ProviderReasonTranslator`, `RelatedNavigationResolver`, `RedactionIntegrity`, `WorkspaceAuditLogger`
- 240-tenant-onboarding-readiness: Added PHP 8.4 (Laravel 12) + Laravel 12 + Filament v5 + Livewire v4 + Pest; existing onboarding services (`OnboardingLifecycleService`, `OnboardingDraftStageResolver`), provider connection summary, verification assist, and Ops-UX helpers
- 239-canonical-operation-type-source-of-truth: Added PHP 8.4.15, Laravel 12, Filament v5, Livewire v4 + existing `App\Support\OperationCatalog`, `App\Support\OperationRunType`, `App\Services\OperationRunService`, `App\Services\Providers\ProviderOperationRegistry`, `App\Services\Providers\ProviderOperationStartGate`, `App\Filament\Resources\OperationRunResource`, `App\Filament\Pages\Workspaces\ManagedTenantOnboardingWizard`, `App\Support\Filament\FilterOptionCatalog`, `App\Support\OpsUx\OperationUxPresenter`, `App\Support\References\Resolvers\OperationRunReferenceResolver`, `App\Services\Audit\AuditEventBuilder`, Pest v4
<!-- MANUAL ADDITIONS START --> <!-- MANUAL ADDITIONS START -->
### Pre-production compatibility check ### Pre-production compatibility check

View File

@ -9,9 +9,4 @@
class Login extends BaseLogin class Login extends BaseLogin
{ {
protected string $view = 'filament.pages.auth.login'; protected string $view = 'filament.pages.auth.login';
public function getTitle(): string
{
return __('localization.auth.sign_in_microsoft');
}
} }

View File

@ -1,494 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Filament\Pages\Governance;
use App\Filament\Pages\Findings\FindingsIntakeQueue;
use App\Filament\Pages\Findings\MyFindingsInbox;
use App\Models\Tenant;
use App\Models\User;
use App\Models\Workspace;
use App\Services\Auth\CapabilityResolver;
use App\Services\Auth\WorkspaceCapabilityResolver;
use App\Services\TenantReviews\TenantReviewRegisterService;
use App\Support\Auth\Capabilities;
use App\Support\GovernanceInbox\GovernanceInboxSectionBuilder;
use App\Support\Navigation\CanonicalNavigationContext;
use App\Support\Ui\ActionSurface\ActionSurfaceDeclaration;
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceInspectAffordance;
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceProfile;
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceSlot;
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceType;
use App\Support\Workspaces\WorkspaceContext;
use BackedEnum;
use Filament\Facades\Filament;
use Filament\Pages\Page;
use Illuminate\Support\Str;
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
use UnitEnum;
class GovernanceInbox extends Page
{
protected static bool $isDiscovered = false;
protected static string|BackedEnum|null $navigationIcon = 'heroicon-o-inbox-stack';
protected static string|UnitEnum|null $navigationGroup = 'Governance';
protected static ?string $navigationLabel = 'Governance inbox';
protected static ?int $navigationSort = 5;
protected static ?string $title = 'Governance inbox';
protected static ?string $slug = 'governance/inbox';
protected string $view = 'filament.pages.governance.governance-inbox';
/**
* @var array<int, Tenant>|null
*/
private ?array $authorizedTenants = null;
/**
* @var array<int, Tenant>|null
*/
private ?array $visibleFindingTenants = null;
/**
* @var array<int, Tenant>|null
*/
private ?array $reviewTenants = null;
/**
* @var array<string, mixed>|null
*/
private ?array $inboxPayload = null;
/**
* @var array<string, mixed>|null
*/
private ?array $unfilteredInboxPayload = null;
private ?Workspace $workspace = null;
private ?bool $visibleAlertsFamily = null;
public ?int $tenantId = null;
public ?string $family = null;
public static function actionSurfaceDeclaration(): ActionSurfaceDeclaration
{
return ActionSurfaceDeclaration::forPage(ActionSurfaceProfile::ListOnlyReadOnly, ActionSurfaceType::ReadOnlyRegistryReport)
->satisfy(ActionSurfaceSlot::ListHeader, 'Header actions keep the workspace decision surface calm and read-only.')
->satisfy(ActionSurfaceSlot::InspectAffordance, ActionSurfaceInspectAffordance::ClickableRow->value)
->exempt(ActionSurfaceSlot::ListRowMoreMenu, 'The governance inbox routes into existing source surfaces instead of exposing row-level secondary actions.')
->exempt(ActionSurfaceSlot::ListBulkMoreGroup, 'The governance inbox does not expose bulk actions.')
->satisfy(ActionSurfaceSlot::ListEmptyState, 'Empty states stay calm and capability-safe when no visible attention exists.')
->exempt(ActionSurfaceSlot::DetailHeader, 'The governance inbox owns no local detail surface in v1.');
}
public function mount(): void
{
$this->authorizeWorkspaceMembership();
$this->applyRequestedTenantPrefilter();
$this->family = $this->resolveRequestedFamily();
$this->ensureAtLeastOneVisibleFamily();
$this->ensureRequestedFamilyIsVisible();
}
/**
* @return array<string, mixed>
*/
public function appliedScope(): array
{
$selectedTenant = $this->selectedTenant();
$availableFamilies = collect($this->availableFamilies())
->keyBy('key');
return [
'workspace_label' => $this->workspace()?->name,
'tenant_label' => $selectedTenant?->name,
'tenant_prefilter_source' => $selectedTenant instanceof Tenant ? 'explicit_filter' : 'none',
'family_key' => $this->family,
'family_label' => $this->family !== null
? ($availableFamilies->get($this->family)['label'] ?? Str::headline($this->family))
: 'All attention',
'total_count' => (int) ($this->inboxPayload()['total_count'] ?? 0),
];
}
/**
* @return list<array{key: string, label: string, count: int}>
*/
public function availableFamilies(): array
{
return $this->inboxPayload()['available_families'] ?? [];
}
/**
* @return list<array<string, mixed>>
*/
public function sections(): array
{
return $this->inboxPayload()['sections'] ?? [];
}
/**
* @return array<string, mixed>
*/
public function calmEmptyState(): array
{
if ($this->tenantFilterAloneExcludesRows()) {
return [
'title' => 'This tenant filter is hiding other visible attention',
'body' => 'The current tenant scope is calm, but other visible tenants in this workspace still have governance attention.',
'action_label' => 'Clear tenant filter',
'action_url' => $this->pageUrl(['tenant' => null]),
];
}
return [
'title' => 'No visible governance attention right now',
'body' => 'The current workspace scope is calm across the visible governance families.',
'action_label' => null,
'action_url' => null,
];
}
public function hasTenantPrefilter(): bool
{
return $this->selectedTenant() instanceof Tenant;
}
public function isActiveFamily(?string $familyKey): bool
{
return $this->family === $familyKey;
}
public function pageUrl(array $overrides = []): string
{
$selectedTenant = $this->selectedTenant();
$resolvedTenant = array_key_exists('tenant', $overrides)
? $overrides['tenant']
: ($selectedTenant?->getKey() !== null ? (string) $selectedTenant->getKey() : null);
$resolvedFamily = array_key_exists('family', $overrides)
? $overrides['family']
: $this->family;
return static::getUrl(
panel: 'admin',
parameters: array_filter([
'tenant_id' => is_string($resolvedTenant) && $resolvedTenant !== '' ? $resolvedTenant : null,
'family' => is_string($resolvedFamily) && $resolvedFamily !== '' ? $resolvedFamily : null,
], static fn (mixed $value): bool => $value !== null && $value !== ''),
);
}
public function navigationContext(): CanonicalNavigationContext
{
return new CanonicalNavigationContext(
sourceSurface: 'governance.inbox',
canonicalRouteName: static::getRouteName(Filament::getPanel('admin')),
tenantId: $this->tenantId,
backLinkLabel: 'Back to governance inbox',
backLinkUrl: $this->pageUrl(),
);
}
private function authorizeWorkspaceMembership(): void
{
$user = auth()->user();
$workspace = $this->workspace();
if (! $user instanceof User) {
abort(403);
}
if (! $workspace instanceof Workspace) {
throw new NotFoundHttpException;
}
$resolver = app(WorkspaceCapabilityResolver::class);
if (! $resolver->isMember($user, $workspace)) {
throw new NotFoundHttpException;
}
}
private function ensureAtLeastOneVisibleFamily(): void
{
if (
$this->hasVisibleOperationsFamily()
|| $this->visibleFindingTenants() !== []
|| $this->reviewTenants() !== []
|| $this->hasVisibleAlertsFamily()
) {
return;
}
abort(403);
}
private function ensureRequestedFamilyIsVisible(): void
{
if ($this->family === null) {
return;
}
if (in_array($this->family, collect($this->availableFamilies())->pluck('key')->all(), true)) {
return;
}
throw new NotFoundHttpException;
}
private function hasVisibleOperationsFamily(): bool
{
return $this->authorizedTenants() !== [];
}
private function hasVisibleAlertsFamily(): bool
{
if (is_bool($this->visibleAlertsFamily)) {
return $this->visibleAlertsFamily;
}
$user = auth()->user();
$workspace = $this->workspace();
if (! $user instanceof User || ! $workspace instanceof Workspace) {
return $this->visibleAlertsFamily = false;
}
return $this->visibleAlertsFamily = app(WorkspaceCapabilityResolver::class)->can($user, $workspace, Capabilities::ALERTS_VIEW);
}
/**
* @return array<int, Tenant>
*/
private function visibleFindingTenants(): array
{
if ($this->visibleFindingTenants !== null) {
return $this->visibleFindingTenants;
}
$user = auth()->user();
$tenants = $this->authorizedTenants();
if (! $user instanceof User || $tenants === []) {
return $this->visibleFindingTenants = [];
}
$resolver = app(CapabilityResolver::class);
$resolver->primeMemberships(
$user,
array_map(static fn (Tenant $tenant): int => (int) $tenant->getKey(), $tenants),
);
return $this->visibleFindingTenants = array_values(array_filter(
$tenants,
fn (Tenant $tenant): bool => $resolver->can($user, $tenant, Capabilities::TENANT_FINDINGS_VIEW),
));
}
/**
* @return array<int, Tenant>
*/
private function reviewTenants(): array
{
if ($this->reviewTenants !== null) {
return $this->reviewTenants;
}
$user = auth()->user();
$workspace = $this->workspace();
if (! $user instanceof User || ! $workspace instanceof Workspace) {
return $this->reviewTenants = [];
}
$service = app(TenantReviewRegisterService::class);
if (! $service->canAccessWorkspace($user, $workspace)) {
return $this->reviewTenants = [];
}
return $this->reviewTenants = $service->authorizedTenants($user, $workspace);
}
/**
* @return array<int, Tenant>
*/
private function authorizedTenants(): array
{
if ($this->authorizedTenants !== null) {
return $this->authorizedTenants;
}
$user = auth()->user();
$workspace = $this->workspace();
if (! $user instanceof User || ! $workspace instanceof Workspace) {
return $this->authorizedTenants = [];
}
return $this->authorizedTenants = $user->tenants()
->where('tenants.workspace_id', (int) $workspace->getKey())
->where('tenants.status', 'active')
->orderBy('tenants.name')
->get(['tenants.id', 'tenants.name', 'tenants.external_id', 'tenants.workspace_id'])
->all();
}
private function applyRequestedTenantPrefilter(): void
{
$requestedTenant = request()->query('tenant_id', request()->query('tenant'));
if (! is_string($requestedTenant) && ! is_numeric($requestedTenant)) {
return;
}
foreach ($this->authorizedTenants() as $tenant) {
if ((string) $tenant->getKey() !== (string) $requestedTenant && (string) $tenant->external_id !== (string) $requestedTenant) {
continue;
}
$this->tenantId = (int) $tenant->getKey();
return;
}
throw new NotFoundHttpException;
}
private function resolveRequestedFamily(): ?string
{
$family = request()->query('family');
if (! is_string($family)) {
return null;
}
return in_array($family, [
'assigned_findings',
'intake_findings',
'stale_operations',
'alert_delivery_failures',
'review_follow_up',
], true) ? $family : null;
}
private function workspace(): ?Workspace
{
if ($this->workspace instanceof Workspace) {
return $this->workspace;
}
$workspaceId = app(WorkspaceContext::class)->currentWorkspaceId(request());
if (! is_int($workspaceId)) {
return null;
}
return $this->workspace = Workspace::query()->whereKey($workspaceId)->first();
}
/**
* @return array<string, mixed>
*/
private function inboxPayload(): array
{
if (is_array($this->inboxPayload)) {
return $this->inboxPayload;
}
$user = auth()->user();
$workspace = $this->workspace();
if (! $user instanceof User || ! $workspace instanceof Workspace) {
return $this->inboxPayload = [
'sections' => [],
'available_families' => [],
'family_counts' => [],
'total_count' => 0,
];
}
return $this->inboxPayload = app(GovernanceInboxSectionBuilder::class)->build(
user: $user,
workspace: $workspace,
authorizedTenants: $this->authorizedTenants(),
visibleFindingTenants: $this->visibleFindingTenants(),
reviewTenants: $this->reviewTenants(),
canViewAlerts: $this->hasVisibleAlertsFamily(),
selectedTenant: $this->selectedTenant(),
selectedFamily: $this->family,
navigationContext: $this->navigationContext(),
);
}
/**
* @return array<string, mixed>
*/
private function unfilteredInboxPayload(): array
{
if (is_array($this->unfilteredInboxPayload)) {
return $this->unfilteredInboxPayload;
}
$user = auth()->user();
$workspace = $this->workspace();
if (! $user instanceof User || ! $workspace instanceof Workspace) {
return $this->unfilteredInboxPayload = [
'sections' => [],
'available_families' => [],
'family_counts' => [],
'total_count' => 0,
];
}
return $this->unfilteredInboxPayload = app(GovernanceInboxSectionBuilder::class)->build(
user: $user,
workspace: $workspace,
authorizedTenants: $this->authorizedTenants(),
visibleFindingTenants: $this->visibleFindingTenants(),
reviewTenants: $this->reviewTenants(),
canViewAlerts: $this->hasVisibleAlertsFamily(),
selectedTenant: null,
selectedFamily: null,
navigationContext: $this->navigationContext(),
);
}
private function selectedTenant(): ?Tenant
{
if (! is_int($this->tenantId)) {
return null;
}
foreach ($this->authorizedTenants() as $tenant) {
if ((int) $tenant->getKey() === $this->tenantId) {
return $tenant;
}
}
return null;
}
private function tenantFilterAloneExcludesRows(): bool
{
if (! is_int($this->tenantId) || $this->family !== null) {
return false;
}
if ($this->sections() !== []) {
return false;
}
return (int) ($this->unfilteredInboxPayload()['total_count'] ?? 0) > 0;
}
}

View File

@ -1,515 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Filament\Pages\Reviews;
use App\Filament\Resources\TenantReviewResource;
use App\Models\Tenant;
use App\Models\ReviewPack;
use App\Models\TenantReview;
use App\Models\User;
use App\Models\Workspace;
use App\Services\ReviewPackService;
use App\Services\TenantReviews\TenantReviewRegisterService;
use App\Support\Auth\Capabilities;
use App\Support\Findings\FindingOutcomeSemantics;
use App\Support\Filament\TablePaginationProfiles;
use App\Support\ReviewPackStatus;
use App\Support\Ui\GovernanceArtifactTruth\ArtifactTruthEnvelope;
use App\Support\Ui\GovernanceArtifactTruth\ArtifactTruthPresenter;
use App\Support\Ui\GovernanceArtifactTruth\CompressedGovernanceOutcome;
use App\Support\Ui\GovernanceArtifactTruth\SurfaceCompressionContext;
use App\Support\Workspaces\WorkspaceContext;
use BackedEnum;
use Filament\Actions\Action;
use Filament\Pages\Page;
use Filament\Tables\Columns\TextColumn;
use Filament\Tables\Concerns\InteractsWithTable;
use Filament\Tables\Contracts\HasTable;
use Filament\Tables\Filters\SelectFilter;
use Filament\Tables\Table;
use Illuminate\Database\Eloquent\Builder;
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
use UnitEnum;
class CustomerReviewWorkspace extends Page implements HasTable
{
use InteractsWithTable;
public const string DETAIL_CONTEXT_QUERY_KEY = 'customer_workspace';
private const string SOURCE_SURFACE = 'customer_review_workspace';
protected static bool $isDiscovered = false;
protected static string|BackedEnum|null $navigationIcon = 'heroicon-o-document-text';
protected static string|UnitEnum|null $navigationGroup = 'Reporting';
protected static ?string $navigationLabel = 'Customer reviews';
protected static ?int $navigationSort = 44;
protected static ?string $title = 'Customer Review Workspace';
protected static ?string $slug = 'reviews/workspace';
protected string $view = 'filament.pages.reviews.customer-review-workspace';
public static function getNavigationGroup(): string
{
return __('localization.review.reporting');
}
public static function getNavigationLabel(): string
{
return __('localization.review.customer_reviews');
}
public function getTitle(): string
{
return __('localization.review.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(__('localization.review.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(__('localization.review.tenant'))->searchable()->sortable(),
TextColumn::make('latest_review')
->label(__('localization.review.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(__('localization.review.key_findings'))
->getStateUsing(fn (Tenant $record): string => $this->findingSummary($record))
->wrap(),
TextColumn::make('accepted_risk_summary')
->label(__('localization.review.accepted_risks'))
->getStateUsing(fn (Tenant $record): string => $this->acceptedRiskSummary($record))
->wrap(),
TextColumn::make('published_at')
->label(__('localization.review.published'))
->getStateUsing(fn (Tenant $record): ?string => $this->latestPublishedAt($record)?->toDateTimeString())
->dateTime()
->placeholder('—'),
TextColumn::make('review_pack_state')
->label(__('localization.review.review_pack'))
->getStateUsing(fn (Tenant $record): string => $this->reviewPackAvailability($record)),
])
->filters([
SelectFilter::make('tenant_id')
->label(__('localization.review.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(__('localization.review.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(__('localization.review.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(__('localization.review.no_entitled_tenants'))
->emptyStateDescription(fn (): string => $this->hasActiveFilters()
? __('localization.review.clear_filters_description')
: __('localization.review.adjust_filters_description'))
->emptyStateActions([
Action::make('clear_filters_empty')
->label(__('localization.review.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 ?? __('localization.review.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 __('localization.review.no_published_review_available');
}
$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.' '.__('localization.review.terminal_outcomes').': '.$findingOutcomeSummary.'.');
}
private function findingSummary(Tenant $tenant): string
{
$review = $this->latestPublishedReview($tenant);
if (! $review instanceof TenantReview) {
return __('localization.review.no_published_review_available');
}
$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 __('localization.review.no_findings_recorded');
}
if ($terminalOutcomes === null) {
return __('localization.review.findings_count_summary', ['count' => $findingCount]);
}
return __('localization.review.findings_count_with_outcomes', [
'count' => $findingCount,
'outcomes' => $terminalOutcomes,
]);
}
private function acceptedRiskSummary(Tenant $tenant): string
{
$review = $this->latestPublishedReview($tenant);
if (! $review instanceof TenantReview) {
return __('localization.review.no_published_review_available');
}
$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 => __('localization.review.no_accepted_risks_recorded'),
$warningCount > 0 => __('localization.review.accepted_risks_need_follow_up', ['warnings' => $warningCount, 'total' => $statusMarkedCount]),
$validGovernedCount > 0 => __('localization.review.accepted_risks_governed', ['count' => $validGovernedCount]),
default => __('localization.review.accepted_risks_on_record', ['count' => $statusMarkedCount]),
};
}
private function reviewPackAvailability(Tenant $tenant): string
{
$pack = $this->latestReviewPack($tenant);
if (! $pack instanceof ReviewPack) {
return __('localization.review.unavailable');
}
if ($pack->status !== ReviewPackStatus::Ready->value) {
return __('localization.review.unavailable');
}
if ($pack->expires_at !== null && $pack->expires_at->isPast()) {
return __('localization.review.unavailable');
}
return __('localization.review.available');
}
}

View File

@ -178,23 +178,9 @@ public function table(Table $table): Table
&& 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)) ->disabled(fn (TenantReview $record): bool => (bool) (app(ReviewPackService::class)->reviewPackGenerationDecisionForTenant($record->tenant)['is_blocked'] ?? false))
->tooltip(function (TenantReview $record): ?string { ->tooltip(fn (TenantReview $record): ?string => (bool) (app(ReviewPackService::class)->reviewPackGenerationDecisionForTenant($record->tenant)['is_blocked'] ?? false)
$decision = app(ReviewPackService::class)->reviewPackGenerationDecisionForTenant($record->tenant); ? (string) (app(ReviewPackService::class)->reviewPackGenerationDecisionForTenant($record->tenant)['block_reason'] ?? '')
: null)
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([])

View File

@ -12,7 +12,6 @@
use App\Services\Auth\WorkspaceCapabilityResolver; use App\Services\Auth\WorkspaceCapabilityResolver;
use App\Services\Entitlements\WorkspaceEntitlementResolver; use App\Services\Entitlements\WorkspaceEntitlementResolver;
use App\Services\Entitlements\WorkspacePlanProfileCatalog; use App\Services\Entitlements\WorkspacePlanProfileCatalog;
use App\Services\Localization\LocaleResolver;
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;
@ -59,7 +58,6 @@ class WorkspaceSettings extends Page
*/ */
private const SETTING_FIELDS = [ private const SETTING_FIELDS = [
'ai_policy_mode' => ['domain' => 'ai', 'key' => 'policy_mode', 'type' => 'string'], 'ai_policy_mode' => ['domain' => 'ai', 'key' => 'policy_mode', 'type' => 'string'],
'localization_default_locale' => ['domain' => 'localization', 'key' => 'default_locale', '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'],
@ -155,22 +153,17 @@ protected function getHeaderActions(): array
{ {
return [ return [
Action::make('save') Action::make('save')
->label(__('localization.workspace.save')) ->label('Save')
->action(function (): void { ->action(function (): void {
$this->save(); $this->save();
}) })
->disabled(fn (): bool => ! $this->currentUserCanManage()) ->disabled(fn (): bool => ! $this->currentUserCanManage())
->tooltip(fn (): ?string => $this->currentUserCanManage() ->tooltip(fn (): ?string => $this->currentUserCanManage()
? null ? null
: __('localization.workspace.no_manage_permission')), : 'You do not have permission to manage workspace settings.'),
]; ];
} }
public function getTitle(): string
{
return __('localization.workspace.title');
}
public static function actionSurfaceDeclaration(): ActionSurfaceDeclaration public static function actionSurfaceDeclaration(): ActionSurfaceDeclaration
{ {
return ActionSurfaceDeclaration::forPage(ActionSurfaceProfile::ListOnlyReadOnly) return ActionSurfaceDeclaration::forPage(ActionSurfaceProfile::ListOnlyReadOnly)
@ -215,18 +208,6 @@ public function content(Schema $schema): Schema
return $schema return $schema
->statePath('data') ->statePath('data')
->schema([ ->schema([
Section::make(__('localization.workspace.section'))
->description($this->sectionDescription('localization', __('localization.workspace.section_description')))
->schema([
Select::make('localization_default_locale')
->label(__('localization.workspace.default_locale_label'))
->options(LocaleResolver::localeOptions())
->placeholder(__('localization.workspace.default_locale_placeholder'))
->native(false)
->disabled(fn (): bool => ! $this->currentUserCanManage())
->helperText(fn (): string => $this->localeDefaultHelperText())
->hintAction($this->makeResetAction('localization_default_locale')),
]),
Section::make('Workspace entitlements') Section::make('Workspace entitlements')
->description($this->sectionDescription('entitlements', 'Select a plan profile and optional first-slice overrides for onboarding activation and review pack generation.')) ->description($this->sectionDescription('entitlements', 'Select a plan profile and optional first-slice overrides for onboarding activation and review pack generation.'))
->columns(2) ->columns(2)
@ -526,7 +507,7 @@ public function save(): void
$this->loadFormState(); $this->loadFormState();
Notification::make() Notification::make()
->title($changedSettingsCount > 0 ? __('localization.notifications.workspace_settings_saved') : __('localization.notifications.workspace_settings_unchanged')) ->title($changedSettingsCount > 0 ? 'Workspace settings saved' : 'No settings changes to save')
->success() ->success()
->send(); ->send();
} }
@ -545,7 +526,7 @@ public function resetSetting(string $field): void
if ($this->workspaceOverrideForField($field) === null) { if ($this->workspaceOverrideForField($field) === null) {
Notification::make() Notification::make()
->title(__('localization.notifications.setting_already_default')) ->title('Setting already uses default')
->success() ->success()
->send(); ->send();
@ -562,7 +543,7 @@ public function resetSetting(string $field): void
$this->loadFormState(); $this->loadFormState();
Notification::make() Notification::make()
->title(__('localization.notifications.workspace_setting_reset')) ->title('Workspace setting reset to default')
->success() ->success()
->send(); ->send();
} }
@ -711,17 +692,18 @@ private function sectionDescription(string $domain, string $baseDescription): st
/** @var Carbon $updatedAt */ /** @var Carbon $updatedAt */
$updatedAt = $meta['updated_at']; $updatedAt = $meta['updated_at'];
return __('localization.workspace.last_modified_by', [ return sprintf(
'description' => $baseDescription, '%s — Last modified by %s, %s.',
'user' => $meta['user_name'], $baseDescription,
'time' => $updatedAt->diffForHumans(), $meta['user_name'],
]); $updatedAt->diffForHumans(),
);
} }
private function makeResetAction(string $field): Action private function makeResetAction(string $field): Action
{ {
return Action::make('reset_'.$field) return Action::make('reset_'.$field)
->label(__('localization.workspace.reset')) ->label('Reset')
->color('danger') ->color('danger')
->requiresConfirmation() ->requiresConfirmation()
->action(function () use ($field): void { ->action(function () use ($field): void {
@ -736,15 +718,15 @@ private function makeResetAction(string $field): Action
->disabled(fn (): bool => ! $this->currentUserCanManage() || ! $this->canResetField($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 __('localization.workspace.no_manage_permission'); return 'You do not have permission to manage workspace settings.';
} }
if (! $this->canResetField($field)) { if (! $this->canResetField($field)) {
if ($this->isEntitlementOverrideValueField($field)) { if ($this->isEntitlementOverrideValueField($field)) {
return __('localization.workspace.no_workspace_override'); return 'No workspace override to reset.';
} }
return __('localization.workspace.no_workspace_override'); return 'No workspace override to reset.';
} }
return null; return null;
@ -966,29 +948,6 @@ private function helperTextFor(string $field): string
return sprintf('Effective value: %s.', $effectiveValue); return sprintf('Effective value: %s.', $effectiveValue);
} }
private function localeDefaultHelperText(): string
{
$resolved = $this->resolvedSettings['localization_default_locale'] ?? null;
if (! is_array($resolved)) {
return '';
}
$effectiveLocale = LocaleResolver::normalize($resolved['value'] ?? null) ?? 'en';
$localeLabel = LocaleResolver::localeOptions()[$effectiveLocale] ?? strtoupper($effectiveLocale);
if (! $this->hasWorkspaceOverride('localization_default_locale')) {
return __('localization.workspace.default_locale_helper_unset', [
'locale' => $localeLabel,
'source' => $this->sourceLabel((string) ($resolved['source'] ?? 'system_default')),
]);
}
return __('localization.workspace.default_locale_helper_set', [
'locale' => $localeLabel,
]);
}
private function slaFieldHelperText(string $severity): string private function slaFieldHelperText(string $severity): string
{ {
$resolved = $this->resolvedSettings['findings_sla_days'] ?? null; $resolved = $this->resolvedSettings['findings_sla_days'] ?? null;
@ -1394,9 +1353,9 @@ private function formatValueForDisplay(string $field, mixed $value): string
private function sourceLabel(string $source): string private function sourceLabel(string $source): string
{ {
return match ($source) { return match ($source) {
'workspace_override' => __('localization.source.workspace_override'), 'workspace_override' => 'workspace override',
'tenant_override' => 'tenant override', 'tenant_override' => 'tenant override',
default => __('localization.source.system_default'), default => 'system default',
}; };
} }

View File

@ -42,11 +42,6 @@ class TenantDashboard extends Dashboard
*/ */
public array $supportDiagnosticsAuditKeys = []; public array $supportDiagnosticsAuditKeys = [];
public function getTitle(): string
{
return __('localization.dashboard.tenant_title');
}
/** /**
* @param array<mixed> $parameters * @param array<mixed> $parameters
*/ */
@ -95,38 +90,38 @@ public function authorizeTenantSupportRequest(): void
private function requestSupportAction(): Action private function requestSupportAction(): Action
{ {
$action = Action::make('requestSupport') $action = Action::make('requestSupport')
->label(__('localization.dashboard.request_support')) ->label('Request support')
->icon('heroicon-o-paper-airplane') ->icon('heroicon-o-paper-airplane')
->color('gray') ->color('gray')
->slideOver() ->slideOver()
->stickyModalHeader() ->stickyModalHeader()
->modalHeading(__('localization.dashboard.support_request_heading')) ->modalHeading('Request support')
->modalDescription(__('localization.dashboard.support_request_description')) ->modalDescription('Share a concise summary and TenantAtlas will attach redacted context from existing records.')
->modalSubmitActionLabel(__('localization.dashboard.submit_request')) ->modalSubmitActionLabel('Submit request')
->form([ ->form([
Placeholder::make('included_context') Placeholder::make('included_context')
->label(__('localization.dashboard.included_context')) ->label('Included context')
->content(fn (): string => $this->tenantSupportRequestAttachmentSummary()) ->content(fn (): string => $this->tenantSupportRequestAttachmentSummary())
->columnSpanFull(), ->columnSpanFull(),
Select::make('severity') Select::make('severity')
->label(__('localization.dashboard.severity')) ->label('Severity')
->options(SupportRequest::severityOptions()) ->options(SupportRequest::severityOptions())
->default(SupportRequest::SEVERITY_NORMAL) ->default(SupportRequest::SEVERITY_NORMAL)
->required() ->required()
->native(false), ->native(false),
TextInput::make('summary') TextInput::make('summary')
->label(__('localization.dashboard.summary')) ->label('Summary')
->required() ->required()
->columnSpanFull(), ->columnSpanFull(),
Textarea::make('reproduction_notes') Textarea::make('reproduction_notes')
->label(__('localization.dashboard.reproduction_notes')) ->label('Reproduction notes')
->rows(4) ->rows(4)
->columnSpanFull(), ->columnSpanFull(),
TextInput::make('contact_name') TextInput::make('contact_name')
->label(__('localization.dashboard.contact_name')) ->label('Contact name')
->default(fn (): ?string => $this->resolveDashboardActor()->name), ->default(fn (): ?string => $this->resolveDashboardActor()->name),
TextInput::make('contact_email') TextInput::make('contact_email')
->label(__('localization.dashboard.contact_email')) ->label('Contact email')
->email() ->email()
->default(fn (): ?string => $this->resolveDashboardActor()->email), ->default(fn (): ?string => $this->resolveDashboardActor()->email),
]) ])
@ -137,7 +132,7 @@ private function requestSupportAction(): Action
$supportRequest = app(SupportRequestSubmissionService::class)->submitForTenant($tenant, $actor, $data); $supportRequest = app(SupportRequestSubmissionService::class)->submitForTenant($tenant, $actor, $data);
Notification::make() Notification::make()
->title(__('localization.dashboard.support_request_submitted')) ->title('Support request submitted')
->body('Reference '.$supportRequest->internal_reference) ->body('Reference '.$supportRequest->internal_reference)
->success() ->success()
->send(); ->send();
@ -151,16 +146,16 @@ private function requestSupportAction(): Action
private function openSupportDiagnosticsAction(): Action private function openSupportDiagnosticsAction(): Action
{ {
$action = Action::make('openSupportDiagnostics') $action = Action::make('openSupportDiagnostics')
->label(__('localization.dashboard.open_support_diagnostics')) ->label('Open support diagnostics')
->icon('heroicon-o-lifebuoy') ->icon('heroicon-o-lifebuoy')
->color('gray') ->color('gray')
->modal() ->modal()
->slideOver() ->slideOver()
->stickyModalHeader() ->stickyModalHeader()
->modalHeading(__('localization.dashboard.support_diagnostics')) ->modalHeading('Support diagnostics')
->modalDescription(__('localization.dashboard.support_diagnostics_description')) ->modalDescription('Redacted tenant context from existing records.')
->modalSubmitAction(false) ->modalSubmitAction(false)
->modalCancelAction(fn (Action $action): Action => $action->label(__('localization.dashboard.close'))) ->modalCancelAction(fn (Action $action): Action => $action->label('Close'))
->mountUsing(function (): void { ->mountUsing(function (): void {
$this->auditTenantSupportDiagnosticsOpen(); $this->auditTenantSupportDiagnosticsOpen();
}) })

View File

@ -30,7 +30,6 @@
use App\Services\Onboarding\OnboardingDraftResolver; use App\Services\Onboarding\OnboardingDraftResolver;
use App\Services\Onboarding\OnboardingDraftStageResolver; use App\Services\Onboarding\OnboardingDraftStageResolver;
use App\Services\Onboarding\OnboardingLifecycleService; use App\Services\Onboarding\OnboardingLifecycleService;
use App\Services\Entitlements\WorkspaceCommercialLifecycleResolver;
use App\Services\Entitlements\WorkspaceEntitlementResolver; use App\Services\Entitlements\WorkspaceEntitlementResolver;
use App\Services\OperationRunService; use App\Services\OperationRunService;
use App\Services\Providers\ProviderConnectionMutationService; use App\Services\Providers\ProviderConnectionMutationService;
@ -4552,30 +4551,27 @@ private function completionSummaryEntitlementDecision(): array
return []; return [];
} }
return app(WorkspaceCommercialLifecycleResolver::class)->actionDecision( return app(WorkspaceEntitlementResolver::class)->resolve(
$this->workspace, $this->workspace,
WorkspaceCommercialLifecycleResolver::ACTION_MANAGED_TENANT_ACTIVATION, WorkspaceEntitlementResolver::KEY_MANAGED_TENANT_ACTIVATION_LIMIT,
); );
} }
private function completionSummaryEntitlementBlocked(): bool private function completionSummaryEntitlementBlocked(): bool
{ {
return ($this->completionSummaryEntitlementDecision()['outcome'] ?? null) === WorkspaceCommercialLifecycleResolver::OUTCOME_BLOCK; return (bool) ($this->completionSummaryEntitlementDecision()['is_blocked'] ?? false);
} }
private function completionSummaryEntitlementSummary(): string private function completionSummaryEntitlementSummary(): string
{ {
$decision = $this->completionSummaryEntitlementDecision(); $decision = $this->completionSummaryEntitlementDecision();
$entitlementDecision = is_array($decision['entitlement_decision'] ?? null) ? $decision['entitlement_decision'] : []; $currentUsage = (int) ($decision['current_usage'] ?? 0);
$currentUsage = (int) ($entitlementDecision['current_usage'] ?? 0); $effectiveValue = (int) ($decision['effective_value'] ?? 0);
$effectiveValue = (int) ($entitlementDecision['effective_value'] ?? 0); $sourceLabel = $this->completionSummaryEntitlementSourceLabel($decision);
$sourceLabel = $this->completionSummaryEntitlementSourceLabel($entitlementDecision);
$stateLabel = is_string($decision['state_label'] ?? null) ? $decision['state_label'] : 'Active paid';
return sprintf( return sprintf(
'%s - %s - %d active of %d allowed (%s)', '%s - %d active of %d allowed (%s)',
$this->completionSummaryEntitlementBlocked() ? 'Blocked' : 'Allowed', $this->completionSummaryEntitlementBlocked() ? 'Blocked' : 'Allowed',
$stateLabel,
$currentUsage, $currentUsage,
$effectiveValue, $effectiveValue,
$sourceLabel, $sourceLabel,
@ -4585,15 +4581,13 @@ private function completionSummaryEntitlementSummary(): string
private function completionSummaryEntitlementDetail(): string private function completionSummaryEntitlementDetail(): string
{ {
$decision = $this->completionSummaryEntitlementDecision(); $decision = $this->completionSummaryEntitlementDecision();
$entitlementDecision = is_array($decision['entitlement_decision'] ?? null) ? $decision['entitlement_decision'] : []; $currentUsage = (int) ($decision['current_usage'] ?? 0);
$currentUsage = (int) ($entitlementDecision['current_usage'] ?? 0); $effectiveValue = (int) ($decision['effective_value'] ?? 0);
$effectiveValue = (int) ($entitlementDecision['effective_value'] ?? 0); $remainingCapacity = (int) ($decision['remaining_capacity'] ?? 0);
$remainingCapacity = (int) ($entitlementDecision['remaining_capacity'] ?? 0); $sourceLabel = $this->completionSummaryEntitlementSourceLabel($decision);
$sourceLabel = $this->completionSummaryEntitlementSourceLabel($entitlementDecision);
$rationale = is_string($decision['rationale'] ?? null) ? $decision['rationale'] : null; $rationale = is_string($decision['rationale'] ?? null) ? $decision['rationale'] : null;
$message = sprintf( $message = sprintf(
'%s Current usage is %d active managed tenant%s out of %d allowed. Source: %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,
$currentUsage === 1 ? '' : 's', $currentUsage === 1 ? '' : 's',
$effectiveValue, $effectiveValue,
@ -4612,7 +4606,7 @@ private function completionSummaryEntitlementDetail(): string
} }
} }
if ($rationale !== null && $rationale !== '' && ($decision['source'] ?? null) === WorkspaceCommercialLifecycleResolver::SOURCE_WORKSPACE_SETTING) { if ($rationale !== null && $rationale !== '' && ($decision['source'] ?? null) === 'workspace_override') {
$message .= ' Rationale: '.$rationale; $message .= ' Rationale: '.$rationale;
} }
@ -4988,7 +4982,7 @@ public function completeOnboarding(): void
if ($this->completionSummaryEntitlementBlocked()) { if ($this->completionSummaryEntitlementBlocked()) {
Notification::make() Notification::make()
->title('Activation unavailable') ->title('Activation limit reached')
->body($this->completionSummaryEntitlementDetail()) ->body($this->completionSummaryEntitlementDetail())
->warning() ->warning()
->send(); ->send();

View File

@ -6,7 +6,6 @@
use App\Filament\Concerns\InteractsWithTenantOwnedRecords; use App\Filament\Concerns\InteractsWithTenantOwnedRecords;
use App\Filament\Concerns\ResolvesPanelTenantContext; use App\Filament\Concerns\ResolvesPanelTenantContext;
use App\Filament\Pages\Reviews\CustomerReviewWorkspace;
use App\Filament\Resources\EvidenceSnapshotResource\Pages; use App\Filament\Resources\EvidenceSnapshotResource\Pages;
use App\Filament\Resources\ReviewPackResource; use App\Filament\Resources\ReviewPackResource;
use App\Models\EvidenceSnapshot; use App\Models\EvidenceSnapshot;
@ -268,20 +267,6 @@ public static function relatedContextEntries(EvidenceSnapshot $record): array
)->toArray(); )->toArray();
} }
if ($record->tenant instanceof Tenant) {
$entries[] = RelatedContextEntry::available(
key: 'customer_review_workspace',
label: 'Customer workspace',
value: $record->tenant->name,
secondaryValue: 'Open the customer-safe review workspace prefiltered to this tenant.',
targetUrl: CustomerReviewWorkspace::tenantPrefilterUrl($record->tenant),
targetKind: 'canonical_page',
priority: 30,
actionLabel: 'Open customer workspace',
contextBadge: 'Reporting',
)->toArray();
}
return $entries; return $entries;
} }

View File

@ -75,6 +75,8 @@ class FindingResource extends Resource
protected static string|UnitEnum|null $navigationGroup = 'Governance'; protected static string|UnitEnum|null $navigationGroup = 'Governance';
protected static ?string $navigationLabel = 'Findings';
public static function shouldRegisterNavigation(): bool public static function shouldRegisterNavigation(): bool
{ {
if (Filament::getCurrentPanel()?->getId() === 'admin') { if (Filament::getCurrentPanel()?->getId() === 'admin') {
@ -84,26 +86,6 @@ public static function shouldRegisterNavigation(): bool
return parent::shouldRegisterNavigation(); return parent::shouldRegisterNavigation();
} }
public static function getNavigationLabel(): string
{
return __('localization.navigation.findings');
}
public static function getNavigationGroup(): string
{
return __('localization.navigation.governance');
}
public static function getModelLabel(): string
{
return __('localization.navigation.findings');
}
public static function getPluralModelLabel(): string
{
return __('localization.navigation.findings');
}
public static function canViewAny(): bool public static function canViewAny(): bool
{ {
$tenant = static::resolveTenantContextForCurrentPanel(); $tenant = static::resolveTenantContextForCurrentPanel();

View File

@ -77,15 +77,15 @@ public function getTabs(): array
$stats = FindingResource::findingStatsForCurrentTenant(); $stats = FindingResource::findingStatsForCurrentTenant();
return [ return [
'all' => Tab::make(__('localization.findings.all')) 'all' => Tab::make('All')
->icon('heroicon-m-list-bullet'), ->icon('heroicon-m-list-bullet'),
'needs_action' => Tab::make(__('localization.findings.needs_action')) 'needs_action' => Tab::make('Needs action')
->icon('heroicon-m-exclamation-triangle') ->icon('heroicon-m-exclamation-triangle')
->modifyQueryUsing(fn (Builder $query): Builder => $query ->modifyQueryUsing(fn (Builder $query): Builder => $query
->whereIn('status', Finding::openStatusesForQuery())) ->whereIn('status', Finding::openStatusesForQuery()))
->badge($stats['open'] > 0 ? $stats['open'] : null) ->badge($stats['open'] > 0 ? $stats['open'] : null)
->badgeColor('warning'), ->badgeColor('warning'),
'overdue' => Tab::make(__('localization.findings.overdue')) 'overdue' => Tab::make('Overdue')
->icon('heroicon-m-clock') ->icon('heroicon-m-clock')
->modifyQueryUsing(fn (Builder $query): Builder => $query ->modifyQueryUsing(fn (Builder $query): Builder => $query
->whereIn('status', Finding::openStatusesForQuery()) ->whereIn('status', Finding::openStatusesForQuery())
@ -93,11 +93,11 @@ public function getTabs(): array
->where('due_at', '<', now())) ->where('due_at', '<', now()))
->badge($stats['overdue'] > 0 ? $stats['overdue'] : null) ->badge($stats['overdue'] > 0 ? $stats['overdue'] : null)
->badgeColor('danger'), ->badgeColor('danger'),
'risk_accepted' => Tab::make(__('localization.findings.risk_accepted')) 'risk_accepted' => Tab::make('Risk accepted')
->icon('heroicon-m-shield-check') ->icon('heroicon-m-shield-check')
->modifyQueryUsing(fn (Builder $query): Builder => $query ->modifyQueryUsing(fn (Builder $query): Builder => $query
->where('status', Finding::STATUS_RISK_ACCEPTED)), ->where('status', Finding::STATUS_RISK_ACCEPTED)),
'resolved' => Tab::make(__('localization.findings.resolved')) 'resolved' => Tab::make('Resolved')
->icon('heroicon-m-archive-box') ->icon('heroicon-m-archive-box')
->modifyQueryUsing(fn (Builder $query): Builder => $query ->modifyQueryUsing(fn (Builder $query): Builder => $query
->whereIn('status', [Finding::STATUS_RESOLVED, Finding::STATUS_CLOSED])), ->whereIn('status', [Finding::STATUS_RESOLVED, Finding::STATUS_CLOSED])),

View File

@ -44,7 +44,7 @@ protected function getHeaderActions(): array
->primaryListAction(CrossResourceNavigationMatrix::SOURCE_FINDING, $this->getRecord())?->isAvailable() ?? false)) ->primaryListAction(CrossResourceNavigationMatrix::SOURCE_FINDING, $this->getRecord())?->isAvailable() ?? false))
->color('gray'), ->color('gray'),
Actions\Action::make('open_approval_queue') Actions\Action::make('open_approval_queue')
->label(__('localization.findings.open_approval_queue')) ->label('Open approval queue')
->icon('heroicon-o-arrow-top-right-on-square') ->icon('heroicon-o-arrow-top-right-on-square')
->color('gray') ->color('gray')
->visible(function (): bool { ->visible(function (): bool {
@ -61,7 +61,7 @@ protected function getHeaderActions(): array
: null; : null;
}), }),
Actions\ActionGroup::make(FindingResource::workflowActions()) Actions\ActionGroup::make(FindingResource::workflowActions())
->label(__('localization.findings.actions')) ->label('Actions')
->icon('heroicon-o-ellipsis-vertical') ->icon('heroicon-o-ellipsis-vertical')
->color('gray'), ->color('gray'),
]); ]);

View File

@ -5,7 +5,6 @@
use App\Filament\Concerns\ResolvesPanelTenantContext; use App\Filament\Concerns\ResolvesPanelTenantContext;
use App\Exceptions\Entitlements\WorkspaceEntitlementBlockedException; 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;
@ -196,13 +195,6 @@ public static function infolist(Schema $schema): Schema
? TenantReviewResource::tenantScopedUrl('view', ['record' => $record->tenantReview], $record->tenant) ? TenantReviewResource::tenantScopedUrl('view', ['record' => $record->tenantReview], $record->tenant)
: null) : null)
->placeholder('—'), ->placeholder('—'),
TextEntry::make('customer_workspace')
->label('Customer workspace')
->state(fn (): string => 'Open workspace')
->url(fn (ReviewPack $record): ?string => $record->tenant instanceof Tenant
? CustomerReviewWorkspace::tenantPrefilterUrl($record->tenant)
: null)
->placeholder('—'),
TextEntry::make('summary.review_status') TextEntry::make('summary.review_status')
->label('Review status') ->label('Review status')
->badge() ->badge()
@ -575,19 +567,6 @@ public static function reviewPackGenerationBlockReason(?Tenant $tenant = null):
return is_string($reason) && $reason !== '' ? $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 public static function reviewPackGenerationActionTooltip(?Tenant $tenant = null): ?string
{ {
$tenant ??= static::currentTenantContext(); $tenant ??= static::currentTenantContext();
@ -597,7 +576,6 @@ public static function reviewPackGenerationActionTooltip(?Tenant $tenant = null)
return AuthUiTooltips::insufficientPermission(); return AuthUiTooltips::insufficientPermission();
} }
return static::reviewPackGenerationBlockReason($tenant) return static::reviewPackGenerationBlockReason($tenant);
?? static::reviewPackGenerationWarningReason($tenant);
} }
} }

View File

@ -6,7 +6,6 @@
use App\Filament\Concerns\InteractsWithTenantOwnedRecords; use App\Filament\Concerns\InteractsWithTenantOwnedRecords;
use App\Filament\Concerns\ResolvesPanelTenantContext; use App\Filament\Concerns\ResolvesPanelTenantContext;
use App\Filament\Pages\Reviews\CustomerReviewWorkspace;
use App\Filament\Resources\TenantReviewResource\Pages; use App\Filament\Resources\TenantReviewResource\Pages;
use App\Exceptions\Entitlements\WorkspaceEntitlementBlockedException; use App\Exceptions\Entitlements\WorkspaceEntitlementBlockedException;
use App\Models\EvidenceSnapshot; use App\Models\EvidenceSnapshot;
@ -85,26 +84,6 @@ public static function shouldRegisterNavigation(): bool
return Filament::getCurrentPanel()?->getId() === 'tenant'; return Filament::getCurrentPanel()?->getId() === 'tenant';
} }
public static function getNavigationGroup(): string
{
return __('localization.review.reporting');
}
public static function getNavigationLabel(): string
{
return __('localization.review.reviews');
}
public static function getModelLabel(): string
{
return __('localization.review.review');
}
public static function getPluralModelLabel(): string
{
return __('localization.review.reviews');
}
public static function canViewAny(): bool public static function canViewAny(): bool
{ {
$tenant = static::resolveTenantContextForCurrentPanel(); $tenant = static::resolveTenantContextForCurrentPanel();
@ -173,7 +152,7 @@ public static function form(Schema $schema): Schema
public static function infolist(Schema $schema): Schema public static function infolist(Schema $schema): Schema
{ {
return $schema->schema([ return $schema->schema([
Section::make(__('localization.review.outcome_summary')) Section::make('Outcome summary')
->schema([ ->schema([
ViewEntry::make('artifact_truth') ViewEntry::make('artifact_truth')
->hiddenLabel() ->hiddenLabel()
@ -182,7 +161,7 @@ public static function infolist(Schema $schema): Schema
->columnSpanFull(), ->columnSpanFull(),
]) ])
->columnSpanFull(), ->columnSpanFull(),
Section::make(__('localization.review.review')) Section::make('Review')
->schema([ ->schema([
TextEntry::make('status') TextEntry::make('status')
->badge() ->badge()
@ -191,23 +170,23 @@ public static function infolist(Schema $schema): Schema
->icon(BadgeRenderer::icon(BadgeDomain::TenantReviewStatus)) ->icon(BadgeRenderer::icon(BadgeDomain::TenantReviewStatus))
->iconColor(BadgeRenderer::iconColor(BadgeDomain::TenantReviewStatus)), ->iconColor(BadgeRenderer::iconColor(BadgeDomain::TenantReviewStatus)),
TextEntry::make('completeness_state') TextEntry::make('completeness_state')
->label(__('localization.review.completeness')) ->label('Completeness')
->badge() ->badge()
->formatStateUsing(BadgeRenderer::label(BadgeDomain::TenantReviewCompleteness)) ->formatStateUsing(BadgeRenderer::label(BadgeDomain::TenantReviewCompleteness))
->color(BadgeRenderer::color(BadgeDomain::TenantReviewCompleteness)) ->color(BadgeRenderer::color(BadgeDomain::TenantReviewCompleteness))
->icon(BadgeRenderer::icon(BadgeDomain::TenantReviewCompleteness)) ->icon(BadgeRenderer::icon(BadgeDomain::TenantReviewCompleteness))
->iconColor(BadgeRenderer::iconColor(BadgeDomain::TenantReviewCompleteness)), ->iconColor(BadgeRenderer::iconColor(BadgeDomain::TenantReviewCompleteness)),
TextEntry::make('tenant.name')->label(__('localization.review.tenant')), TextEntry::make('tenant.name')->label('Tenant'),
TextEntry::make('generated_at')->dateTime()->placeholder('—'), TextEntry::make('generated_at')->dateTime()->placeholder('—'),
TextEntry::make('published_at')->dateTime()->placeholder('—'), TextEntry::make('published_at')->dateTime()->placeholder('—'),
TextEntry::make('evidenceSnapshot.id') TextEntry::make('evidenceSnapshot.id')
->label(__('localization.review.evidence_snapshot')) ->label('Evidence snapshot')
->formatStateUsing(fn (?int $state): string => $state ? '#'.$state : '—') ->formatStateUsing(fn (?int $state): string => $state ? '#'.$state : '—')
->url(fn (TenantReview $record): ?string => $record->evidenceSnapshot ->url(fn (TenantReview $record): ?string => $record->evidenceSnapshot
? EvidenceSnapshotResource::getUrl('view', ['record' => $record->evidenceSnapshot], tenant: $record->tenant) ? EvidenceSnapshotResource::getUrl('view', ['record' => $record->evidenceSnapshot], tenant: $record->tenant)
: null), : null),
TextEntry::make('currentExportReviewPack.id') TextEntry::make('currentExportReviewPack.id')
->label(__('localization.review.current_export')) ->label('Current export')
->formatStateUsing(fn (?int $state): string => $state ? '#'.$state : '—') ->formatStateUsing(fn (?int $state): string => $state ? '#'.$state : '—')
->url(fn (TenantReview $record): ?string => $record->currentExportReviewPack ->url(fn (TenantReview $record): ?string => $record->currentExportReviewPack
? ReviewPackResource::getUrl('view', ['record' => $record->currentExportReviewPack], tenant: $record->tenant) ? ReviewPackResource::getUrl('view', ['record' => $record->currentExportReviewPack], tenant: $record->tenant)
@ -221,7 +200,7 @@ public static function infolist(Schema $schema): Schema
]) ])
->columns(2) ->columns(2)
->columnSpanFull(), ->columnSpanFull(),
Section::make(__('localization.review.executive_posture')) Section::make('Executive posture')
->schema([ ->schema([
ViewEntry::make('review_summary') ViewEntry::make('review_summary')
->hiddenLabel() ->hiddenLabel()
@ -230,21 +209,21 @@ public static function infolist(Schema $schema): Schema
->columnSpanFull(), ->columnSpanFull(),
]) ])
->columnSpanFull(), ->columnSpanFull(),
Section::make(__('localization.review.sections')) Section::make('Sections')
->schema([ ->schema([
RepeatableEntry::make('sections') RepeatableEntry::make('sections')
->hiddenLabel() ->hiddenLabel()
->schema([ ->schema([
TextEntry::make('title'), TextEntry::make('title'),
TextEntry::make('completeness_state') TextEntry::make('completeness_state')
->label(__('localization.review.completeness')) ->label('Completeness')
->badge() ->badge()
->formatStateUsing(BadgeRenderer::label(BadgeDomain::TenantReviewCompleteness)) ->formatStateUsing(BadgeRenderer::label(BadgeDomain::TenantReviewCompleteness))
->color(BadgeRenderer::color(BadgeDomain::TenantReviewCompleteness)) ->color(BadgeRenderer::color(BadgeDomain::TenantReviewCompleteness))
->icon(BadgeRenderer::icon(BadgeDomain::TenantReviewCompleteness)) ->icon(BadgeRenderer::icon(BadgeDomain::TenantReviewCompleteness))
->iconColor(BadgeRenderer::iconColor(BadgeDomain::TenantReviewCompleteness)), ->iconColor(BadgeRenderer::iconColor(BadgeDomain::TenantReviewCompleteness)),
TextEntry::make('measured_at')->dateTime()->placeholder('—'), TextEntry::make('measured_at')->dateTime()->placeholder('—'),
Section::make(__('localization.review.details')) Section::make('Details')
->schema([ ->schema([
ViewEntry::make('section_payload') ViewEntry::make('section_payload')
->hiddenLabel() ->hiddenLabel()
@ -266,7 +245,7 @@ public static function table(Table $table): Table
{ {
$exportExecutivePackAction = UiEnforcement::forTableAction( $exportExecutivePackAction = UiEnforcement::forTableAction(
Actions\Action::make('export_executive_pack') Actions\Action::make('export_executive_pack')
->label(__('localization.review.export_executive_pack')) ->label('Export executive pack')
->icon('heroicon-o-arrow-down-tray') ->icon('heroicon-o-arrow-down-tray')
->visible(fn (TenantReview $record): bool => in_array($record->status, [ ->visible(fn (TenantReview $record): bool => in_array($record->status, [
TenantReviewStatus::Ready->value, TenantReviewStatus::Ready->value,
@ -298,7 +277,7 @@ public static function table(Table $table): Table
->iconColor(BadgeRenderer::iconColor(BadgeDomain::TenantReviewStatus)) ->iconColor(BadgeRenderer::iconColor(BadgeDomain::TenantReviewStatus))
->sortable(), ->sortable(),
Tables\Columns\TextColumn::make('outcome') Tables\Columns\TextColumn::make('outcome')
->label(__('localization.review.outcome')) ->label('Outcome')
->badge() ->badge()
->getStateUsing(fn (TenantReview $record): string => static::compressedOutcome($record)->primaryLabel) ->getStateUsing(fn (TenantReview $record): string => static::compressedOutcome($record)->primaryLabel)
->color(fn (TenantReview $record): string => static::compressedOutcome($record)->primaryBadge->color) ->color(fn (TenantReview $record): string => static::compressedOutcome($record)->primaryBadge->color)
@ -309,10 +288,10 @@ public static function table(Table $table): Table
Tables\Columns\TextColumn::make('generated_at')->dateTime()->placeholder('—')->sortable(), Tables\Columns\TextColumn::make('generated_at')->dateTime()->placeholder('—')->sortable(),
Tables\Columns\TextColumn::make('published_at')->dateTime()->placeholder('—')->sortable(), Tables\Columns\TextColumn::make('published_at')->dateTime()->placeholder('—')->sortable(),
Tables\Columns\IconColumn::make('summary.has_ready_export') Tables\Columns\IconColumn::make('summary.has_ready_export')
->label(__('localization.review.export')) ->label('Export')
->boolean(), ->boolean(),
Tables\Columns\TextColumn::make('next_step') Tables\Columns\TextColumn::make('next_step')
->label(__('localization.review.next_step')) ->label('Next step')
->getStateUsing(fn (TenantReview $record): string => static::compressedOutcome($record)->nextActionText) ->getStateUsing(fn (TenantReview $record): string => static::compressedOutcome($record)->nextActionText)
->wrap(), ->wrap(),
Tables\Columns\TextColumn::make('fingerprint') Tables\Columns\TextColumn::make('fingerprint')
@ -326,18 +305,18 @@ public static function table(Table $table): Table
->all()), ->all()),
Tables\Filters\SelectFilter::make('completeness_state') Tables\Filters\SelectFilter::make('completeness_state')
->options(BadgeCatalog::options(BadgeDomain::TenantReviewCompleteness, TenantReviewCompletenessState::values())), ->options(BadgeCatalog::options(BadgeDomain::TenantReviewCompleteness, TenantReviewCompletenessState::values())),
\App\Support\Filament\FilterPresets::dateRange('review_date', __('localization.review.review_date'), 'generated_at'), \App\Support\Filament\FilterPresets::dateRange('review_date', 'Review date', 'generated_at'),
]) ])
->actions([ ->actions([
$exportExecutivePackAction, $exportExecutivePackAction,
]) ])
->bulkActions([]) ->bulkActions([])
->emptyStateHeading(__('localization.review.no_tenant_reviews_yet')) ->emptyStateHeading('No tenant reviews yet')
->emptyStateDescription(__('localization.review.create_first_review_description')) ->emptyStateDescription('Create the first review from an anchored evidence snapshot to start the recurring review history for this tenant.')
->emptyStateActions([ ->emptyStateActions([
static::makeCreateReviewAction( static::makeCreateReviewAction(
name: 'create_first_review', name: 'create_first_review',
label: __('localization.review.create_first_review'), label: 'Create first review',
icon: 'heroicon-o-plus', icon: 'heroicon-o-plus',
), ),
]); ]);
@ -356,23 +335,19 @@ public static function makeCreateReviewAction(
string $label = 'Create review', string $label = 'Create review',
string $icon = 'heroicon-o-plus', string $icon = 'heroicon-o-plus',
): Actions\Action { ): Actions\Action {
$label = $label === 'Create review'
? __('localization.review.create_review')
: $label;
return UiEnforcement::forAction( return UiEnforcement::forAction(
Actions\Action::make($name) Actions\Action::make($name)
->label($label) ->label($label)
->icon($icon) ->icon($icon)
->form([ ->form([
Section::make(__('localization.review.evidence_basis')) Section::make('Evidence basis')
->schema([ ->schema([
Select::make('evidence_snapshot_id') Select::make('evidence_snapshot_id')
->label(__('localization.review.evidence_snapshot')) ->label('Evidence snapshot')
->required() ->required()
->options(fn (): array => static::evidenceSnapshotOptions()) ->options(fn (): array => static::evidenceSnapshotOptions())
->searchable() ->searchable()
->helperText(__('localization.review.evidence_basis_helper')), ->helperText('Choose the anchored evidence snapshot for this review.'),
]), ]),
]) ])
->action(fn (array $data): mixed => static::executeCreateReview($data)), ->action(fn (array $data): mixed => static::executeCreateReview($data)),
@ -390,7 +365,7 @@ public static function executeCreateReview(array $data): void
$user = auth()->user(); $user = auth()->user();
if (! $tenant instanceof Tenant || ! $user instanceof User) { if (! $tenant instanceof Tenant || ! $user instanceof User) {
Notification::make()->danger()->title(__('localization.review.unable_create_missing_context'))->send(); Notification::make()->danger()->title('Unable to create review — missing context.')->send();
return; return;
} }
@ -412,7 +387,7 @@ public static function executeCreateReview(array $data): void
: null; : null;
if (! $snapshot instanceof EvidenceSnapshot) { if (! $snapshot instanceof EvidenceSnapshot) {
Notification::make()->danger()->title(__('localization.review.select_valid_evidence_snapshot'))->send(); Notification::make()->danger()->title('Select a valid evidence snapshot.')->send();
return; return;
} }
@ -420,7 +395,7 @@ public static function executeCreateReview(array $data): void
try { try {
$review = app(TenantReviewService::class)->create($tenant, $snapshot, $user); $review = app(TenantReviewService::class)->create($tenant, $snapshot, $user);
} catch (\Throwable $throwable) { } catch (\Throwable $throwable) {
Notification::make()->danger()->title(__('localization.review.unable_create_review'))->body($throwable->getMessage())->send(); Notification::make()->danger()->title('Unable to create review')->body($throwable->getMessage())->send();
return; return;
} }
@ -430,11 +405,11 @@ public static function executeCreateReview(array $data): void
if (! $review->wasRecentlyCreated) { if (! $review->wasRecentlyCreated) {
Notification::make() Notification::make()
->success() ->success()
->title(__('localization.review.review_already_available')) ->title('Review already available')
->body(__('localization.review.review_already_available_body')) ->body('A matching mutable review already exists for this evidence basis.')
->actions([ ->actions([
Actions\Action::make('view_review') Actions\Action::make('view_review')
->label(__('localization.review.view_review')) ->label('View review')
->url(static::tenantScopedUrl('view', ['record' => $review], $tenant)), ->url(static::tenantScopedUrl('view', ['record' => $review], $tenant)),
]) ])
->send(); ->send();
@ -443,12 +418,12 @@ public static function executeCreateReview(array $data): void
} }
$toast = OperationUxPresenter::queuedToast(OperationRunType::TenantReviewCompose->value) $toast = OperationUxPresenter::queuedToast(OperationRunType::TenantReviewCompose->value)
->body(__('localization.review.review_composing_background')); ->body('The review is being composed in the background.');
if ($review->operation_run_id) { if ($review->operation_run_id) {
$toast->actions([ $toast->actions([
Actions\Action::make('view_run') Actions\Action::make('view_run')
->label(__('localization.review.open_operation')) ->label('Open operation')
->url(OperationRunLinks::tenantlessView((int) $review->operation_run_id)), ->url(OperationRunLinks::tenantlessView((int) $review->operation_run_id)),
]); ]);
} }
@ -488,19 +463,6 @@ public static function reviewPackGenerationBlockReason(?Tenant $tenant = null):
return is_string($reason) && $reason !== '' ? $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 public static function reviewPackGenerationActionTooltip(?Tenant $tenant = null): ?string
{ {
$tenant ??= static::panelTenantContext(); $tenant ??= static::panelTenantContext();
@ -510,8 +472,7 @@ public static function reviewPackGenerationActionTooltip(?Tenant $tenant = null)
return AuthUiTooltips::insufficientPermission(); return AuthUiTooltips::insufficientPermission();
} }
return static::reviewPackGenerationBlockReason($tenant) return static::reviewPackGenerationBlockReason($tenant);
?? static::reviewPackGenerationWarningReason($tenant);
} }
public static function executeExport(TenantReview $review): void public static function executeExport(TenantReview $review): void
@ -520,7 +481,7 @@ public static function executeExport(TenantReview $review): void
$user = auth()->user(); $user = auth()->user();
if (! $user instanceof User || ! $review->tenant instanceof Tenant) { if (! $user instanceof User || ! $review->tenant instanceof Tenant) {
Notification::make()->danger()->title(__('localization.review.unable_export_missing_context'))->send(); Notification::make()->danger()->title('Unable to export review — missing context.')->send();
return; return;
} }
@ -537,7 +498,7 @@ public static function executeExport(TenantReview $review): void
if ($service->checkActiveRunForReview($review)) { if ($service->checkActiveRunForReview($review)) {
OperationUxPresenter::alreadyQueuedToast(OperationRunType::ReviewPackGenerate->value) OperationUxPresenter::alreadyQueuedToast(OperationRunType::ReviewPackGenerate->value)
->body(__('localization.review.export_already_queued_body')) ->body('An executive pack export is already queued or running for this review.')
->send(); ->send();
return; return;
@ -549,11 +510,11 @@ public static function executeExport(TenantReview $review): void
'include_operations' => true, 'include_operations' => true,
]); ]);
} catch (WorkspaceEntitlementBlockedException $exception) { } catch (WorkspaceEntitlementBlockedException $exception) {
Notification::make()->warning()->title(__('localization.review.executive_pack_export_unavailable'))->body($exception->getMessage())->send(); Notification::make()->warning()->title('Executive pack export unavailable')->body($exception->getMessage())->send();
return; return;
} catch (\Throwable $throwable) { } catch (\Throwable $throwable) {
Notification::make()->danger()->title(__('localization.review.unable_export_executive_pack'))->body($throwable->getMessage())->send(); Notification::make()->danger()->title('Unable to export executive pack')->body($throwable->getMessage())->send();
return; return;
} }
@ -564,11 +525,11 @@ public static function executeExport(TenantReview $review): void
if (! $pack->wasRecentlyCreated) { if (! $pack->wasRecentlyCreated) {
Notification::make() Notification::make()
->success() ->success()
->title(__('localization.review.executive_pack_already_available')) ->title('Executive pack already available')
->body(__('localization.review.executive_pack_already_available_body')) ->body('A matching executive pack already exists for this review.')
->actions([ ->actions([
Actions\Action::make('view_pack') Actions\Action::make('view_pack')
->label(__('localization.review.view_pack')) ->label('View pack')
->url(ReviewPackResource::getUrl('view', ['record' => $pack], tenant: $review->tenant)), ->url(ReviewPackResource::getUrl('view', ['record' => $pack], tenant: $review->tenant)),
]) ])
->send(); ->send();
@ -577,7 +538,7 @@ public static function executeExport(TenantReview $review): void
} }
OperationUxPresenter::queuedToast(OperationRunType::ReviewPackGenerate->value) OperationUxPresenter::queuedToast(OperationRunType::ReviewPackGenerate->value)
->body(__('localization.review.executive_pack_generating_background')) ->body('The executive pack is being generated in the background.')
->send(); ->send();
} }
@ -617,7 +578,7 @@ private static function evidenceSnapshotOptions(): array
'#%d · %s · %s', '#%d · %s · %s',
(int) $snapshot->getKey(), (int) $snapshot->getKey(),
BadgeCatalog::spec(BadgeDomain::EvidenceCompleteness, $snapshot->completeness_state)->label, BadgeCatalog::spec(BadgeDomain::EvidenceCompleteness, $snapshot->completeness_state)->label,
$snapshot->generated_at?->format('Y-m-d H:i') ?? __('localization.review.pending') $snapshot->generated_at?->format('Y-m-d H:i') ?? 'Pending'
), ),
]) ])
->all(); ->all();
@ -641,7 +602,7 @@ private static function summaryPresentation(TenantReview $record): array
$findingOutcomes = is_array($summary['finding_outcomes'] ?? null) ? $summary['finding_outcomes'] : []; $findingOutcomes = is_array($summary['finding_outcomes'] ?? null) ? $summary['finding_outcomes'] : [];
if ($findingOutcomeSummary !== null) { if ($findingOutcomeSummary !== null) {
$highlights[] = __('localization.review.terminal_outcomes').': '.$findingOutcomeSummary.'.'; $highlights[] = 'Terminal outcomes: '.$findingOutcomeSummary.'.';
} }
return [ return [
@ -653,12 +614,12 @@ private static function summaryPresentation(TenantReview $record): array
'publish_blockers' => is_array($summary['publish_blockers'] ?? null) ? $summary['publish_blockers'] : [], 'publish_blockers' => is_array($summary['publish_blockers'] ?? null) ? $summary['publish_blockers'] : [],
'context_links' => static::summaryContextLinks($record), 'context_links' => static::summaryContextLinks($record),
'metrics' => [ 'metrics' => [
['label' => __('localization.review.findings'), 'value' => (string) ($summary['finding_count'] ?? 0)], ['label' => 'Findings', 'value' => (string) ($summary['finding_count'] ?? 0)],
['label' => __('localization.review.reports'), 'value' => (string) ($summary['report_count'] ?? 0)], ['label' => 'Reports', 'value' => (string) ($summary['report_count'] ?? 0)],
['label' => __('localization.review.operations'), 'value' => (string) ($summary['operation_count'] ?? 0)], ['label' => 'Operations', 'value' => (string) ($summary['operation_count'] ?? 0)],
['label' => __('localization.review.sections'), 'value' => (string) ($summary['section_count'] ?? 0)], ['label' => 'Sections', 'value' => (string) ($summary['section_count'] ?? 0)],
['label' => __('localization.review.pending_verification'), 'value' => (string) ($findingOutcomes[FindingOutcomeSemantics::OUTCOME_RESOLVED_PENDING_VERIFICATION] ?? 0)], ['label' => 'Pending verification', 'value' => (string) ($findingOutcomes[FindingOutcomeSemantics::OUTCOME_RESOLVED_PENDING_VERIFICATION] ?? 0)],
['label' => __('localization.review.verified_cleared'), 'value' => (string) ($findingOutcomes[FindingOutcomeSemantics::OUTCOME_VERIFIED_CLEARED] ?? 0)], ['label' => 'Verified cleared', 'value' => (string) ($findingOutcomes[FindingOutcomeSemantics::OUTCOME_VERIFIED_CLEARED] ?? 0)],
], ],
]; ];
} }
@ -672,37 +633,28 @@ private static function summaryContextLinks(TenantReview $record): array
if (is_numeric($record->operation_run_id)) { if (is_numeric($record->operation_run_id)) {
$links[] = [ $links[] = [
'title' => __('localization.review.operation'), 'title' => 'Operation',
'label' => __('localization.review.open_operation'), 'label' => 'Open operation',
'url' => OperationRunLinks::tenantlessView((int) $record->operation_run_id), 'url' => OperationRunLinks::tenantlessView((int) $record->operation_run_id),
'description' => __('localization.review.operation_description'), 'description' => 'Inspect the latest review composition or refresh run.',
]; ];
} }
if ($record->currentExportReviewPack && $record->tenant) { if ($record->currentExportReviewPack && $record->tenant) {
$links[] = [ $links[] = [
'title' => __('localization.review.executive_pack'), 'title' => 'Executive pack',
'label' => __('localization.review.view_executive_pack'), 'label' => 'View executive pack',
'url' => ReviewPackResource::getUrl('view', ['record' => $record->currentExportReviewPack], tenant: $record->tenant), 'url' => ReviewPackResource::getUrl('view', ['record' => $record->currentExportReviewPack], tenant: $record->tenant),
'description' => __('localization.review.executive_pack_description'), 'description' => 'Open the current export that belongs to this review.',
];
}
if ($record->tenant) {
$links[] = [
'title' => __('localization.review.customer_workspace'),
'label' => __('localization.review.open_customer_workspace'),
'url' => CustomerReviewWorkspace::tenantPrefilterUrl($record->tenant),
'description' => __('localization.review.customer_workspace_description'),
]; ];
} }
if ($record->evidenceSnapshot && $record->tenant) { if ($record->evidenceSnapshot && $record->tenant) {
$links[] = [ $links[] = [
'title' => __('localization.review.evidence_snapshot'), 'title' => 'Evidence snapshot',
'label' => __('localization.review.view_evidence_snapshot'), 'label' => 'View evidence snapshot',
'url' => EvidenceSnapshotResource::getUrl('view', ['record' => $record->evidenceSnapshot], tenant: $record->tenant), 'url' => EvidenceSnapshotResource::getUrl('view', ['record' => $record->evidenceSnapshot], tenant: $record->tenant),
'description' => __('localization.review.evidence_snapshot_description'), 'description' => 'Return to the evidence basis behind this review.',
]; ];
} }

View File

@ -4,15 +4,12 @@
namespace App\Filament\Resources\TenantReviewResource\Pages; namespace App\Filament\Resources\TenantReviewResource\Pages;
use App\Filament\Pages\Reviews\CustomerReviewWorkspace;
use App\Filament\Resources\TenantReviewResource; use App\Filament\Resources\TenantReviewResource;
use App\Models\Tenant; use App\Models\Tenant;
use App\Models\TenantReview; use App\Models\TenantReview;
use App\Models\User; use App\Models\User;
use App\Services\Audit\WorkspaceAuditLogger;
use App\Services\TenantReviews\TenantReviewLifecycleService; use App\Services\TenantReviews\TenantReviewLifecycleService;
use App\Services\TenantReviews\TenantReviewService; use App\Services\TenantReviews\TenantReviewService;
use App\Support\Audit\AuditActionId;
use App\Support\Auth\Capabilities; use App\Support\Auth\Capabilities;
use App\Support\Rbac\UiEnforcement; use App\Support\Rbac\UiEnforcement;
use App\Support\TenantReviewStatus; use App\Support\TenantReviewStatus;
@ -27,13 +24,6 @@ class ViewTenantReview extends ViewRecord
{ {
protected static string $resource = TenantReviewResource::class; protected static string $resource = TenantReviewResource::class;
public function mount(int|string $record): void
{
parent::mount($record);
$this->auditCustomerWorkspaceOpen();
}
protected function resolveRecord(int|string $key): Model protected function resolveRecord(int|string $key): Model
{ {
return TenantReviewResource::resolveScopedRecordOrFail($key); return TenantReviewResource::resolveScopedRecordOrFail($key);
@ -79,7 +69,7 @@ protected function getHeaderActions(): array
->label('Danger') ->label('Danger')
->icon('heroicon-o-archive-box') ->icon('heroicon-o-archive-box')
->color('danger') ->color('danger')
->visible(fn (): bool => ! $this->isCustomerWorkspaceView() && ! $this->record->statusEnum()->isTerminal()), ->visible(fn (): bool => ! $this->record->statusEnum()->isTerminal()),
])); ]));
} }
@ -95,10 +85,6 @@ private function primaryLifecycleAction(): ?Actions\Action
private function primaryLifecycleActionName(): ?string private function primaryLifecycleActionName(): ?string
{ {
if ($this->isCustomerWorkspaceView()) {
return null;
}
if ((string) $this->record->status === TenantReviewStatus::Published->value) { if ((string) $this->record->status === TenantReviewStatus::Published->value) {
return 'export_executive_pack'; return 'export_executive_pack';
} }
@ -136,10 +122,6 @@ private function secondaryLifecycleActions(): array
*/ */
private function secondaryLifecycleActionNames(): array private function secondaryLifecycleActionNames(): array
{ {
if ($this->isCustomerWorkspaceView()) {
return [];
}
$names = []; $names = [];
if ($this->record->isMutable()) { if ($this->record->isMutable()) {
@ -196,6 +178,7 @@ private function refreshReviewAction(): Actions\Action
}), }),
) )
->requireCapability(Capabilities::TENANT_REVIEW_MANAGE) ->requireCapability(Capabilities::TENANT_REVIEW_MANAGE)
->preserveVisibility()
->apply(); ->apply();
} }
@ -342,39 +325,4 @@ private function archiveReviewAction(): Actions\Action
->preserveVisibility() ->preserveVisibility()
->apply(); ->apply();
} }
private function isCustomerWorkspaceView(): bool
{
return request()->boolean(CustomerReviewWorkspace::DETAIL_CONTEXT_QUERY_KEY);
}
private function auditCustomerWorkspaceOpen(): void
{
if (! $this->isCustomerWorkspaceView()) {
return;
}
$user = auth()->user();
$tenant = $this->record->tenant;
if (! $user instanceof User || ! $tenant instanceof Tenant) {
return;
}
app(WorkspaceAuditLogger::class)->log(
workspace: $tenant->workspace,
action: AuditActionId::TenantReviewOpened,
context: [
'metadata' => [
'review_id' => (int) $this->record->getKey(),
'source_surface' => 'customer_review_workspace',
],
],
actor: $user,
resourceType: 'tenant_review',
resourceId: (string) $this->record->getKey(),
targetLabel: sprintf('Tenant review #%d', (int) $this->record->getKey()),
tenant: $tenant,
);
}
} }

View File

@ -28,11 +28,6 @@ class Dashboard extends BaseDashboard
{ {
public string $window = SystemConsoleWindow::LastDay; public string $window = SystemConsoleWindow::LastDay;
public function getTitle(): string
{
return __('localization.dashboard.system_title');
}
/** /**
* @param array<mixed> $parameters * @param array<mixed> $parameters
*/ */
@ -114,12 +109,12 @@ protected function getHeaderActions(): array
return [ return [
Action::make('set_window') Action::make('set_window')
->label(__('localization.dashboard.time_window')) ->label('Time window')
->icon('heroicon-o-clock') ->icon('heroicon-o-clock')
->color('gray') ->color('gray')
->form([ ->form([
Select::make('window') Select::make('window')
->label(__('localization.dashboard.window')) ->label('Window')
->options(SystemConsoleWindow::options()) ->options(SystemConsoleWindow::options())
->default($this->window) ->default($this->window)
->required(), ->required(),
@ -135,7 +130,7 @@ protected function getHeaderActions(): array
}), }),
Action::make('enter_break_glass') Action::make('enter_break_glass')
->label(__('localization.dashboard.enter_break_glass')) ->label('Enter break-glass mode')
->color('danger') ->color('danger')
->visible(fn (): bool => $canUseBreakGlass && ! $breakGlass->isActive()) ->visible(fn (): bool => $canUseBreakGlass && ! $breakGlass->isActive())
->requiresConfirmation() ->requiresConfirmation()
@ -163,13 +158,13 @@ protected function getHeaderActions(): array
$breakGlass->start($user, (string) ($data['reason'] ?? '')); $breakGlass->start($user, (string) ($data['reason'] ?? ''));
Notification::make() Notification::make()
->title(__('localization.dashboard.recovery_mode_enabled')) ->title('Recovery mode enabled')
->success() ->success()
->send(); ->send();
}), }),
Action::make('exit_break_glass') Action::make('exit_break_glass')
->label(__('localization.dashboard.exit_break_glass')) ->label('Exit break-glass')
->color('gray') ->color('gray')
->visible(fn (): bool => $canUseBreakGlass && $breakGlass->isActive()) ->visible(fn (): bool => $canUseBreakGlass && $breakGlass->isActive())
->requiresConfirmation() ->requiresConfirmation()
@ -185,7 +180,7 @@ protected function getHeaderActions(): array
$breakGlass->exit($user); $breakGlass->exit($user);
Notification::make() Notification::make()
->title(__('localization.dashboard.recovery_mode_ended')) ->title('Recovery mode ended')
->success() ->success()
->send(); ->send();
}), }),

View File

@ -9,19 +9,13 @@
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\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;
@ -100,77 +94,6 @@ public function workspaceEntitlementSummary(): array
return app(WorkspaceEntitlementResolver::class)->summary($this->workspace); 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},

View File

@ -5,7 +5,6 @@
namespace App\Filament\Widgets\Tenant; namespace App\Filament\Widgets\Tenant;
use App\Exceptions\Entitlements\WorkspaceEntitlementBlockedException; 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;
@ -81,14 +80,6 @@ public function generatePack(bool $includePii = true, bool $includeOperations =
return; 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())
@ -171,9 +162,6 @@ protected function getViewData(): array
$generationBlockReason = is_string($generationEntitlement['block_reason'] ?? null) $generationBlockReason = is_string($generationEntitlement['block_reason'] ?? null)
? $generationEntitlement['block_reason'] ? $generationEntitlement['block_reason']
: null; : null;
$generationWarningReason = is_string($generationEntitlement['warning_reason'] ?? null)
? $generationEntitlement['warning_reason']
: null;
$latestPack = ReviewPack::query() $latestPack = ReviewPack::query()
->with(['tenantReview', 'operationRun']) ->with(['tenantReview', 'operationRun'])
@ -192,8 +180,6 @@ protected function getViewData(): array
'canManage' => $canManage, 'canManage' => $canManage,
'generationBlocked' => $generationBlocked, 'generationBlocked' => $generationBlocked,
'generationBlockReason' => $generationBlockReason, 'generationBlockReason' => $generationBlockReason,
'generationWarningReason' => $generationWarningReason,
'customerWorkspaceUrl' => $canView ? CustomerReviewWorkspace::tenantPrefilterUrl($tenant) : null,
'downloadUrl' => null, 'downloadUrl' => null,
'failedReason' => null, 'failedReason' => null,
'reviewUrl' => null, 'reviewUrl' => null,
@ -244,8 +230,6 @@ protected function getViewData(): array
'canManage' => $canManage, 'canManage' => $canManage,
'generationBlocked' => $generationBlocked, 'generationBlocked' => $generationBlocked,
'generationBlockReason' => $generationBlockReason, 'generationBlockReason' => $generationBlockReason,
'generationWarningReason' => $generationWarningReason,
'customerWorkspaceUrl' => $canView ? CustomerReviewWorkspace::tenantPrefilterUrl($tenant) : null,
'downloadUrl' => $downloadUrl, 'downloadUrl' => $downloadUrl,
'failedReason' => $failedReason, 'failedReason' => $failedReason,
'failedReasonDetail' => $failedReasonDetail, 'failedReasonDetail' => $failedReasonDetail,
@ -278,8 +262,6 @@ private function emptyState(): array
'canManage' => false, 'canManage' => false,
'generationBlocked' => false, 'generationBlocked' => false,
'generationBlockReason' => null, 'generationBlockReason' => null,
'generationWarningReason' => null,
'customerWorkspaceUrl' => null,
'downloadUrl' => null, 'downloadUrl' => null,
'failedReason' => null, 'failedReason' => null,
'failedReasonDetail' => null, 'failedReasonDetail' => null,

View File

@ -1,80 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Http\Controllers;
use App\Models\User;
use App\Services\Localization\LocaleResolver;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Http\Response;
use Illuminate\Support\Facades\App;
use Illuminate\Validation\ValidationException;
class LocalizationController extends Controller
{
public function context(Request $request, LocaleResolver $resolver): JsonResponse
{
$plane = $request->query('plane');
$context = $request->attributes->get(LocaleResolver::REQUEST_ATTRIBUTE);
if (is_string($plane) && $plane !== '') {
$context = $resolver->resolve($request, $plane);
}
return response()->json(is_array($context) ? $context : $resolver->resolve($request));
}
public function updateOverride(Request $request): RedirectResponse
{
$locale = LocaleResolver::normalize($request->input('locale'));
if ($locale === null) {
throw ValidationException::withMessages([
'locale' => [__('localization.validation.unsupported_locale')],
]);
}
$request->session()->put(LocaleResolver::SESSION_OVERRIDE_KEY, $locale);
App::setLocale($locale);
return back()->with('status', __('localization.notifications.locale_override_saved'));
}
public function clearOverride(Request $request, LocaleResolver $resolver): RedirectResponse
{
$request->session()->forget(LocaleResolver::SESSION_OVERRIDE_KEY);
App::setLocale($resolver->resolve($request)['locale']);
return back()->with('status', __('localization.notifications.locale_override_cleared'));
}
public function updateUserPreference(Request $request, LocaleResolver $resolver): RedirectResponse
{
$user = $request->user();
abort_unless($user instanceof User, Response::HTTP_NOT_FOUND);
$rawLocale = $request->input('preferred_locale');
$locale = $rawLocale === null || $rawLocale === ''
? null
: LocaleResolver::normalize($rawLocale);
if ($rawLocale !== null && $rawLocale !== '' && $locale === null) {
throw ValidationException::withMessages([
'preferred_locale' => [__('localization.validation.unsupported_locale')],
]);
}
$user->forceFill(['preferred_locale' => $locale])->save();
$user->refresh();
App::setLocale($resolver->resolve($request)['locale']);
return back()->with('status', $locale === null
? __('localization.notifications.user_preference_cleared')
: __('localization.notifications.user_preference_saved'));
}
}

View File

@ -4,12 +4,7 @@
namespace App\Http\Controllers; namespace App\Http\Controllers;
use App\Models\Tenant;
use App\Models\ReviewPack; use App\Models\ReviewPack;
use App\Models\User;
use App\Services\Audit\WorkspaceAuditLogger;
use App\Support\Audit\AuditActionId;
use App\Support\Auth\Capabilities;
use App\Support\ReviewPackStatus; use App\Support\ReviewPackStatus;
use Illuminate\Http\Request; use Illuminate\Http\Request;
use Illuminate\Support\Facades\Storage; use Illuminate\Support\Facades\Storage;
@ -20,21 +15,6 @@ class ReviewPackDownloadController extends Controller
{ {
public function __invoke(Request $request, ReviewPack $reviewPack): StreamedResponse public function __invoke(Request $request, ReviewPack $reviewPack): StreamedResponse
{ {
$user = $request->user();
$tenant = $reviewPack->tenant;
if (! $user instanceof User || ! $tenant instanceof Tenant) {
throw new NotFoundHttpException;
}
if (! $user->canAccessTenant($tenant)) {
throw new NotFoundHttpException;
}
if (! $user->can(Capabilities::REVIEW_PACK_VIEW, $tenant)) {
abort(403);
}
if ($reviewPack->status !== ReviewPackStatus::Ready->value) { if ($reviewPack->status !== ReviewPackStatus::Ready->value) {
throw new NotFoundHttpException; throw new NotFoundHttpException;
} }
@ -49,26 +29,7 @@ public function __invoke(Request $request, ReviewPack $reviewPack): StreamedResp
throw new NotFoundHttpException; throw new NotFoundHttpException;
} }
app(WorkspaceAuditLogger::class)->log( $tenant = $reviewPack->tenant;
workspace: $tenant->workspace,
action: AuditActionId::ReviewPackDownloaded,
context: [
'metadata' => [
'review_pack_id' => (int) $reviewPack->getKey(),
'tenant_review_id' => $reviewPack->tenant_review_id !== null
? (int) $reviewPack->tenant_review_id
: null,
'source_surface' => (string) $request->query('source_surface', 'review_pack'),
],
],
actor: $user,
resourceType: 'review_pack',
resourceId: (string) $reviewPack->getKey(),
targetLabel: sprintf('Review pack #%d', (int) $reviewPack->getKey()),
tenant: $tenant,
operationRunId: $reviewPack->operation_run_id,
);
$filename = sprintf( $filename = sprintf(
'review-pack-%s-%s.zip', 'review-pack-%s-%s.zip',
$tenant?->external_id ?? 'unknown', $tenant?->external_id ?? 'unknown',

View File

@ -1,29 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Http\Middleware;
use App\Services\Localization\LocaleResolver;
use Closure;
use Illuminate\Http\Request;
use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\App;
use Symfony\Component\HttpFoundation\Response;
class ApplyResolvedLocale
{
public function __construct(private LocaleResolver $resolver) {}
public function handle(Request $request, Closure $next, ?string $plane = null): Response
{
$context = $this->resolver->resolve($request, $plane);
App::setLocale($context['locale']);
Carbon::setLocale($context['locale']);
$request->attributes->set(LocaleResolver::REQUEST_ATTRIBUTE, $context);
return $next($request);
}
}

View File

@ -39,7 +39,6 @@ class User extends Authenticatable implements FilamentUser, HasDefaultTenant, Ha
'password', 'password',
'entra_tenant_id', 'entra_tenant_id',
'entra_object_id', 'entra_object_id',
'preferred_locale',
]; ];
/** /**

View File

@ -7,13 +7,11 @@
use App\Filament\Pages\ChooseWorkspace; use App\Filament\Pages\ChooseWorkspace;
use App\Filament\Pages\Findings\FindingsHygieneReport; use App\Filament\Pages\Findings\FindingsHygieneReport;
use App\Filament\Pages\Findings\FindingsIntakeQueue; use App\Filament\Pages\Findings\FindingsIntakeQueue;
use App\Filament\Pages\Governance\GovernanceInbox;
use App\Filament\Pages\Findings\MyFindingsInbox; use App\Filament\Pages\Findings\MyFindingsInbox;
use App\Filament\Pages\InventoryCoverage; use App\Filament\Pages\InventoryCoverage;
use App\Filament\Pages\Monitoring\FindingExceptionsQueue; use App\Filament\Pages\Monitoring\FindingExceptionsQueue;
use App\Filament\Pages\NoAccess; use App\Filament\Pages\NoAccess;
use App\Filament\Pages\Reviews\ReviewRegister; use App\Filament\Pages\Reviews\ReviewRegister;
use App\Filament\Pages\Reviews\CustomerReviewWorkspace;
use App\Filament\Pages\Settings\WorkspaceSettings; use App\Filament\Pages\Settings\WorkspaceSettings;
use App\Filament\Pages\TenantRequiredPermissions; use App\Filament\Pages\TenantRequiredPermissions;
use App\Filament\Pages\WorkspaceOverview; use App\Filament\Pages\WorkspaceOverview;
@ -79,16 +77,16 @@ public function panel(Panel $panel): Panel
]) ])
->navigationItems([ ->navigationItems([
WorkspaceOverview::navigationItem(), WorkspaceOverview::navigationItem(),
NavigationItem::make(fn (): string => __('localization.navigation.integrations')) NavigationItem::make('Integrations')
->url(fn (): string => route('filament.admin.resources.provider-connections.index')) ->url(fn (): string => route('filament.admin.resources.provider-connections.index'))
->icon('heroicon-o-link') ->icon('heroicon-o-link')
->group(fn (): string => __('localization.navigation.settings')) ->group('Settings')
->sort(15) ->sort(15)
->visible(fn (): bool => ProviderConnectionResource::canViewAny()), ->visible(fn (): bool => ProviderConnectionResource::canViewAny()),
NavigationItem::make(fn (): string => __('localization.navigation.settings')) NavigationItem::make('Settings')
->url(fn (): string => WorkspaceSettings::getUrl(panel: 'admin')) ->url(fn (): string => WorkspaceSettings::getUrl(panel: 'admin'))
->icon('heroicon-o-cog-6-tooth') ->icon('heroicon-o-cog-6-tooth')
->group(fn (): string => __('localization.navigation.settings')) ->group('Settings')
->sort(20) ->sort(20)
->visible(function (): bool { ->visible(function (): bool {
$user = auth()->user(); $user = auth()->user();
@ -115,12 +113,12 @@ public function panel(Panel $panel): Panel
return $resolver->isMember($user, $workspace) return $resolver->isMember($user, $workspace)
&& $resolver->can($user, $workspace, Capabilities::WORKSPACE_SETTINGS_VIEW); && $resolver->can($user, $workspace, Capabilities::WORKSPACE_SETTINGS_VIEW);
}), }),
NavigationItem::make(fn (): string => __('localization.navigation.manage_workspaces')) NavigationItem::make('Manage workspaces')
->url(function (): string { ->url(function (): string {
return route('filament.admin.resources.workspaces.index'); return route('filament.admin.resources.workspaces.index');
}) })
->icon('heroicon-o-squares-2x2') ->icon('heroicon-o-squares-2x2')
->group(fn (): string => __('localization.navigation.settings')) ->group('Settings')
->sort(10) ->sort(10)
->visible(function (): bool { ->visible(function (): bool {
$user = auth()->user(); $user = auth()->user();
@ -136,15 +134,15 @@ public function panel(Panel $panel): Panel
->whereIn('role', $roles) ->whereIn('role', $roles)
->exists(); ->exists();
}), }),
NavigationItem::make(fn (): string => __('localization.navigation.operations')) NavigationItem::make('Operations')
->url(fn (): string => route('admin.operations.index')) ->url(fn (): string => route('admin.operations.index'))
->icon('heroicon-o-queue-list') ->icon('heroicon-o-queue-list')
->group(fn (): string => __('localization.navigation.monitoring')) ->group('Monitoring')
->sort(10), ->sort(10),
NavigationItem::make(fn (): string => __('localization.navigation.audit_log')) NavigationItem::make('Audit Log')
->url(fn (): string => route('admin.monitoring.audit-log')) ->url(fn (): string => route('admin.monitoring.audit-log'))
->icon('heroicon-o-clipboard-document-list') ->icon('heroicon-o-clipboard-document-list')
->group(fn (): string => __('localization.navigation.monitoring')) ->group('Monitoring')
->sort(30), ->sort(30),
]) ])
->renderHook( ->renderHook(
@ -181,12 +179,10 @@ public function panel(Panel $panel): Panel
InventoryCoverage::class, InventoryCoverage::class,
TenantRequiredPermissions::class, TenantRequiredPermissions::class,
WorkspaceSettings::class, WorkspaceSettings::class,
GovernanceInbox::class,
FindingsHygieneReport::class, FindingsHygieneReport::class,
FindingsIntakeQueue::class, FindingsIntakeQueue::class,
MyFindingsInbox::class, MyFindingsInbox::class,
FindingExceptionsQueue::class, FindingExceptionsQueue::class,
CustomerReviewWorkspace::class,
ReviewRegister::class, ReviewRegister::class,
]) ])
->widgets([ ->widgets([
@ -210,7 +206,6 @@ public function panel(Panel $panel): Panel
DisableBladeIconComponents::class, DisableBladeIconComponents::class,
DispatchServingFilamentEvent::class, DispatchServingFilamentEvent::class,
]) ])
->middleware(['apply-resolved-locale:admin'], isPersistent: true)
->authMiddleware([ ->authMiddleware([
Authenticate::class, Authenticate::class,
]); ]);

View File

@ -42,14 +42,6 @@ public function panel(Panel $panel): Panel
PanelsRenderHook::BODY_START, PanelsRenderHook::BODY_START,
fn () => view('filament.system.components.break-glass-banner')->render(), fn () => view('filament.system.components.break-glass-banner')->render(),
) )
->renderHook(
PanelsRenderHook::TOPBAR_START,
fn () => view('filament.partials.locale-switcher', [
'plane' => 'system',
'showPreference' => false,
'embedded' => false,
])->render(),
)
->discoverPages(in: app_path('Filament/System/Pages'), for: 'App\\Filament\\System\\Pages') ->discoverPages(in: app_path('Filament/System/Pages'), for: 'App\\Filament\\System\\Pages')
->pages([ ->pages([
Dashboard::class, Dashboard::class,
@ -67,7 +59,6 @@ public function panel(Panel $panel): Panel
DisableBladeIconComponents::class, DisableBladeIconComponents::class,
DispatchServingFilamentEvent::class, DispatchServingFilamentEvent::class,
]) ])
->middleware(['apply-resolved-locale:system'], isPersistent: true)
->authMiddleware([ ->authMiddleware([
Authenticate::class, Authenticate::class,
'ensure-platform-capability:'.PlatformCapabilities::ACCESS_SYSTEM_PANEL, 'ensure-platform-capability:'.PlatformCapabilities::ACCESS_SYSTEM_PANEL,

View File

@ -50,20 +50,20 @@ public function panel(Panel $panel): Panel
'primary' => Color::Indigo, 'primary' => Color::Indigo,
]) ])
->navigationItems([ ->navigationItems([
NavigationItem::make(fn (): string => __('localization.navigation.operations')) NavigationItem::make(OperationRunLinks::collectionLabel())
->url(fn (): string => route('admin.operations.index')) ->url(fn (): string => route('admin.operations.index'))
->icon('heroicon-o-queue-list') ->icon('heroicon-o-queue-list')
->group(fn (): string => __('localization.navigation.monitoring')) ->group('Monitoring')
->sort(10), ->sort(10),
NavigationItem::make(fn (): string => __('localization.navigation.alerts')) NavigationItem::make('Alerts')
->url(fn (): string => url('/admin/alerts')) ->url(fn (): string => url('/admin/alerts'))
->icon('heroicon-o-bell-alert') ->icon('heroicon-o-bell-alert')
->group(fn (): string => __('localization.navigation.monitoring')) ->group('Monitoring')
->sort(20), ->sort(20),
NavigationItem::make(fn (): string => __('localization.navigation.audit_log')) NavigationItem::make('Audit Log')
->url(fn (): string => route('admin.monitoring.audit-log')) ->url(fn (): string => route('admin.monitoring.audit-log'))
->icon('heroicon-o-clipboard-document-list') ->icon('heroicon-o-clipboard-document-list')
->group(fn (): string => __('localization.navigation.monitoring')) ->group('Monitoring')
->sort(30), ->sort(30),
]) ])
->renderHook( ->renderHook(
@ -111,7 +111,6 @@ public function panel(Panel $panel): Panel
DisableBladeIconComponents::class, DisableBladeIconComponents::class,
DispatchServingFilamentEvent::class, DispatchServingFilamentEvent::class,
]) ])
->middleware(['apply-resolved-locale:tenant'], isPersistent: true)
->authMiddleware([ ->authMiddleware([
Authenticate::class, Authenticate::class,
]); ]);

View File

@ -1,410 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Services\Entitlements;
use App\Models\AuditLog;
use App\Models\Tenant;
use App\Models\Workspace;
use App\Models\WorkspaceSetting;
use App\Services\Settings\SettingsResolver;
use App\Support\Audit\AuditActionId;
use Carbon\CarbonInterface;
final class WorkspaceCommercialLifecycleResolver
{
public const SETTING_DOMAIN = WorkspaceEntitlementResolver::SETTING_DOMAIN;
public const SETTING_COMMERCIAL_LIFECYCLE_STATE = 'commercial_lifecycle_state';
public const SETTING_COMMERCIAL_LIFECYCLE_REASON = 'commercial_lifecycle_reason';
public const STATE_TRIAL = 'trial';
public const STATE_GRACE = 'grace';
public const STATE_ACTIVE_PAID = 'active_paid';
public const STATE_SUSPENDED_READ_ONLY = 'suspended_read_only';
public const SOURCE_DEFAULT_ACTIVE_PAID = 'default_active_paid';
public const SOURCE_WORKSPACE_SETTING = 'workspace_setting';
public const ACTION_MANAGED_TENANT_ACTIVATION = 'managed_tenant_activation';
public const ACTION_REVIEW_PACK_START = 'review_pack_start';
public const ACTION_REVIEW_HISTORY_READ = 'review_history_read';
public const ACTION_EVIDENCE_READ = 'evidence_read';
public const ACTION_GENERATED_PACK_READ = 'generated_pack_read';
public const OUTCOME_ALLOW = 'allow';
public const OUTCOME_WARN = 'warn';
public const OUTCOME_BLOCK = 'block';
public const OUTCOME_ALLOW_READ_ONLY = 'allow_read_only';
public const REASON_FAMILY_ENTITLEMENT_SUBSTRATE = 'entitlement_substrate';
public const REASON_FAMILY_COMMERCIAL_LIFECYCLE = 'commercial_lifecycle';
public function __construct(
private readonly SettingsResolver $settingsResolver,
private readonly WorkspaceEntitlementResolver $workspaceEntitlementResolver,
) {}
/**
* @return list<string>
*/
public static function stateIds(): array
{
return [
self::STATE_TRIAL,
self::STATE_GRACE,
self::STATE_ACTIVE_PAID,
self::STATE_SUSPENDED_READ_ONLY,
];
}
/**
* @return array<string, string>
*/
public static function stateLabels(): array
{
return [
self::STATE_TRIAL => 'Trial',
self::STATE_GRACE => 'Grace',
self::STATE_ACTIVE_PAID => 'Active paid',
self::STATE_SUSPENDED_READ_ONLY => 'Suspended / read-only',
];
}
/**
* @return array<string, string>
*/
public static function stateDescriptions(): array
{
return [
self::STATE_TRIAL => 'Expansion and review-pack starts are available while the workspace is in trial.',
self::STATE_GRACE => 'New managed-tenant activation is frozen, but review-pack starts remain available with a warning.',
self::STATE_ACTIVE_PAID => 'Expansion and review-pack starts are available for the active paid workspace.',
self::STATE_SUSPENDED_READ_ONLY => 'New activation and review-pack starts are blocked while existing review, evidence, and generated-pack access remains read-only.',
];
}
/**
* @return array<string, mixed>
*/
public function summary(Workspace $workspace): array
{
$lifecycle = $this->resolve($workspace);
return $lifecycle + [
'entitlement_summary' => $this->workspaceEntitlementResolver->summary($workspace),
'action_decisions' => [
self::ACTION_MANAGED_TENANT_ACTIVATION => $this->actionDecision($workspace, self::ACTION_MANAGED_TENANT_ACTIVATION, $lifecycle),
self::ACTION_REVIEW_PACK_START => $this->actionDecision($workspace, self::ACTION_REVIEW_PACK_START, $lifecycle),
self::ACTION_REVIEW_HISTORY_READ => $this->actionDecision($workspace, self::ACTION_REVIEW_HISTORY_READ, $lifecycle),
self::ACTION_EVIDENCE_READ => $this->actionDecision($workspace, self::ACTION_EVIDENCE_READ, $lifecycle),
self::ACTION_GENERATED_PACK_READ => $this->actionDecision($workspace, self::ACTION_GENERATED_PACK_READ, $lifecycle),
],
];
}
/**
* @return array{
* workspace_id: int,
* state: string,
* state_label: string,
* source: string,
* source_label: string,
* rationale: string|null,
* description: string,
* last_changed_at: CarbonInterface|null,
* last_changed_by: string|null
* }
*/
public function resolve(Workspace $workspace): array
{
$stateSetting = $this->settingsResolver->resolveDetailed(
workspace: $workspace,
domain: self::SETTING_DOMAIN,
key: self::SETTING_COMMERCIAL_LIFECYCLE_STATE,
);
$rawState = is_string($stateSetting['value'] ?? null)
? strtolower(trim((string) $stateSetting['value']))
: null;
$state = in_array($rawState, self::stateIds(), true)
? $rawState
: self::STATE_ACTIVE_PAID;
$source = ($stateSetting['source'] ?? null) === 'workspace_override' && $rawState !== null
? self::SOURCE_WORKSPACE_SETTING
: self::SOURCE_DEFAULT_ACTIVE_PAID;
$rationale = $this->settingsResolver->resolveValue(
workspace: $workspace,
domain: self::SETTING_DOMAIN,
key: self::SETTING_COMMERCIAL_LIFECYCLE_REASON,
);
$labels = self::stateLabels();
$descriptions = self::stateDescriptions();
$lastChanged = $this->lastChangedMetadata($workspace);
return [
'workspace_id' => (int) $workspace->getKey(),
'state' => $state,
'state_label' => $labels[$state],
'source' => $source,
'source_label' => $source === self::SOURCE_WORKSPACE_SETTING
? 'workspace setting'
: 'default active paid',
'rationale' => is_string($rationale) && trim($rationale) !== '' ? trim($rationale) : null,
'description' => $descriptions[$state],
'last_changed_at' => $lastChanged['last_changed_at'],
'last_changed_by' => $lastChanged['last_changed_by'],
];
}
/**
* @param array<string, mixed>|null $lifecycle
* @return array<string, mixed>
*/
public function actionDecision(Workspace $workspace, string $actionKey, ?array $lifecycle = null): array
{
$lifecycle ??= $this->resolve($workspace);
return match ($actionKey) {
self::ACTION_MANAGED_TENANT_ACTIVATION => $this->managedTenantActivationDecision($workspace, $lifecycle),
self::ACTION_REVIEW_PACK_START => $this->reviewPackStartDecision($workspace, $lifecycle),
self::ACTION_REVIEW_HISTORY_READ,
self::ACTION_EVIDENCE_READ,
self::ACTION_GENERATED_PACK_READ => $this->readOnlyDecision($actionKey, $lifecycle),
default => throw new \InvalidArgumentException(sprintf('Unknown commercial lifecycle action key: %s', $actionKey)),
};
}
/**
* @return array<string, mixed>
*/
public function reviewPackStartDecisionForTenant(Tenant $tenant): array
{
$tenant->loadMissing('workspace');
return $this->actionDecision($tenant->workspace, self::ACTION_REVIEW_PACK_START);
}
/**
* @param array<string, mixed> $lifecycle
* @return array<string, mixed>
*/
private function managedTenantActivationDecision(Workspace $workspace, array $lifecycle): array
{
$substrateDecision = $this->workspaceEntitlementResolver->resolve(
$workspace,
WorkspaceEntitlementResolver::KEY_MANAGED_TENANT_ACTIVATION_LIMIT,
);
if ((bool) ($substrateDecision['is_blocked'] ?? false)) {
return $this->decision(
lifecycle: $lifecycle,
actionKey: self::ACTION_MANAGED_TENANT_ACTIVATION,
outcome: self::OUTCOME_BLOCK,
reasonFamily: self::REASON_FAMILY_ENTITLEMENT_SUBSTRATE,
message: (string) ($substrateDecision['block_reason'] ?? 'Workspace entitlement currently blocks managed tenant activation.'),
substrateDecision: $substrateDecision,
);
}
return match ($lifecycle['state']) {
self::STATE_GRACE => $this->decision(
lifecycle: $lifecycle,
actionKey: self::ACTION_MANAGED_TENANT_ACTIVATION,
outcome: self::OUTCOME_BLOCK,
reasonFamily: self::REASON_FAMILY_COMMERCIAL_LIFECYCLE,
message: 'New managed-tenant activation is frozen while this workspace is in grace.',
substrateDecision: $substrateDecision,
),
self::STATE_SUSPENDED_READ_ONLY => $this->decision(
lifecycle: $lifecycle,
actionKey: self::ACTION_MANAGED_TENANT_ACTIVATION,
outcome: self::OUTCOME_BLOCK,
reasonFamily: self::REASON_FAMILY_COMMERCIAL_LIFECYCLE,
message: 'This workspace is suspended / read-only. New managed-tenant activation is blocked, but existing review and evidence history remains available.',
substrateDecision: $substrateDecision,
),
default => $this->decision(
lifecycle: $lifecycle,
actionKey: self::ACTION_MANAGED_TENANT_ACTIVATION,
outcome: self::OUTCOME_ALLOW,
reasonFamily: null,
message: 'Managed-tenant activation is available for this workspace commercial state.',
substrateDecision: $substrateDecision,
),
};
}
/**
* @param array<string, mixed> $lifecycle
* @return array<string, mixed>
*/
private function reviewPackStartDecision(Workspace $workspace, array $lifecycle): array
{
$substrateDecision = $this->workspaceEntitlementResolver->resolve(
$workspace,
WorkspaceEntitlementResolver::KEY_REVIEW_PACK_GENERATION_ENABLED,
);
if ((bool) ($substrateDecision['is_blocked'] ?? false)) {
return $this->decision(
lifecycle: $lifecycle,
actionKey: self::ACTION_REVIEW_PACK_START,
outcome: self::OUTCOME_BLOCK,
reasonFamily: self::REASON_FAMILY_ENTITLEMENT_SUBSTRATE,
message: (string) ($substrateDecision['block_reason'] ?? 'Workspace entitlement currently blocks review pack generation.'),
substrateDecision: $substrateDecision,
);
}
return match ($lifecycle['state']) {
self::STATE_GRACE => $this->decision(
lifecycle: $lifecycle,
actionKey: self::ACTION_REVIEW_PACK_START,
outcome: self::OUTCOME_WARN,
reasonFamily: self::REASON_FAMILY_COMMERCIAL_LIFECYCLE,
message: 'Workspace is in grace. Review-pack starts remain available, but managed-tenant expansion is frozen.',
substrateDecision: $substrateDecision,
),
self::STATE_SUSPENDED_READ_ONLY => $this->decision(
lifecycle: $lifecycle,
actionKey: self::ACTION_REVIEW_PACK_START,
outcome: self::OUTCOME_BLOCK,
reasonFamily: self::REASON_FAMILY_COMMERCIAL_LIFECYCLE,
message: 'This workspace is suspended / read-only. New review-pack starts are blocked, but existing review packs, evidence, and review history remain available.',
substrateDecision: $substrateDecision,
),
default => $this->decision(
lifecycle: $lifecycle,
actionKey: self::ACTION_REVIEW_PACK_START,
outcome: self::OUTCOME_ALLOW,
reasonFamily: null,
message: 'Review-pack starts are available for this workspace commercial state.',
substrateDecision: $substrateDecision,
),
};
}
/**
* @param array<string, mixed> $lifecycle
* @return array<string, mixed>
*/
private function readOnlyDecision(string $actionKey, array $lifecycle): array
{
if (($lifecycle['state'] ?? null) === self::STATE_SUSPENDED_READ_ONLY) {
return $this->decision(
lifecycle: $lifecycle,
actionKey: $actionKey,
outcome: self::OUTCOME_ALLOW_READ_ONLY,
reasonFamily: self::REASON_FAMILY_COMMERCIAL_LIFECYCLE,
message: 'Suspended / read-only workspaces keep existing review, evidence, and generated-pack consumption available under current RBAC.',
substrateDecision: null,
);
}
return $this->decision(
lifecycle: $lifecycle,
actionKey: $actionKey,
outcome: self::OUTCOME_ALLOW,
reasonFamily: null,
message: 'Read-only history remains available under current RBAC.',
substrateDecision: null,
);
}
/**
* @param array<string, mixed> $lifecycle
* @param array<string, mixed>|null $substrateDecision
* @return array<string, mixed>
*/
private function decision(
array $lifecycle,
string $actionKey,
string $outcome,
?string $reasonFamily,
string $message,
?array $substrateDecision,
): array {
return [
'workspace_id' => (int) ($lifecycle['workspace_id'] ?? 0),
'action_key' => $actionKey,
'outcome' => $outcome,
'is_blocked' => $outcome === self::OUTCOME_BLOCK,
'is_warning' => $outcome === self::OUTCOME_WARN,
'block_reason' => $outcome === self::OUTCOME_BLOCK ? $message : null,
'warning_reason' => $outcome === self::OUTCOME_WARN ? $message : null,
'message' => $message,
'reason_family' => $reasonFamily,
'state' => (string) $lifecycle['state'],
'state_label' => (string) $lifecycle['state_label'],
'source' => (string) $lifecycle['source'],
'source_label' => (string) $lifecycle['source_label'],
'rationale' => $lifecycle['rationale'] ?? null,
'entitlement_decision' => $substrateDecision,
];
}
/**
* @return array{last_changed_at: CarbonInterface|null, last_changed_by: string|null}
*/
private function lastChangedMetadata(Workspace $workspace): array
{
$audit = AuditLog::query()
->where('workspace_id', (int) $workspace->getKey())
->where('action', AuditActionId::WorkspaceSettingUpdated->value)
->where('resource_type', 'workspace_setting')
->where('resource_id', self::SETTING_DOMAIN.'.'.self::SETTING_COMMERCIAL_LIFECYCLE_STATE)
->latest('recorded_at')
->latest('id')
->first();
if ($audit instanceof AuditLog) {
return [
'last_changed_at' => $audit->recorded_at,
'last_changed_by' => $audit->actorDisplayLabel(),
];
}
$record = WorkspaceSetting::query()
->where('workspace_id', (int) $workspace->getKey())
->where('domain', self::SETTING_DOMAIN)
->whereIn('key', [
self::SETTING_COMMERCIAL_LIFECYCLE_STATE,
self::SETTING_COMMERCIAL_LIFECYCLE_REASON,
])
->with('updatedByUser:id,name')
->latest('updated_at')
->latest('id')
->first();
if (! $record instanceof WorkspaceSetting) {
return [
'last_changed_at' => null,
'last_changed_by' => null,
];
}
return [
'last_changed_at' => $record->updated_at,
'last_changed_by' => $record->updatedByUser?->name,
];
}
}

View File

@ -1,215 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Services\Localization;
use App\Models\User;
use App\Models\Workspace;
use App\Services\Settings\SettingsResolver;
use App\Support\Workspaces\WorkspaceContext;
use Illuminate\Http\Request;
class LocaleResolver
{
public const SESSION_OVERRIDE_KEY = 'tenantpilot.locale_override';
public const REQUEST_ATTRIBUTE = 'tenantpilot.resolved_locale';
public const SETTING_DOMAIN = 'localization';
public const SETTING_DEFAULT_LOCALE = 'default_locale';
public const SOURCE_EXPLICIT_OVERRIDE = 'explicit_override';
public const SOURCE_USER_PREFERENCE = 'user_preference';
public const SOURCE_WORKSPACE_DEFAULT = 'workspace_default';
public const SOURCE_SYSTEM_DEFAULT = 'system_default';
/**
* @var list<string>
*/
private const SUPPORTED_LOCALES = ['en', 'de'];
public function __construct(
private SettingsResolver $settingsResolver,
private WorkspaceContext $workspaceContext,
) {}
/**
* @return list<string>
*/
public static function supportedLocales(): array
{
return self::SUPPORTED_LOCALES;
}
/**
* @return array<string, string>
*/
public static function localeOptions(): array
{
return [
'en' => __('localization.locales.en'),
'de' => __('localization.locales.de'),
];
}
public static function isSupported(mixed $locale): bool
{
return self::normalize($locale) !== null;
}
public static function normalize(mixed $locale): ?string
{
if (! is_string($locale)) {
return null;
}
$normalized = strtolower(trim($locale));
return in_array($normalized, self::SUPPORTED_LOCALES, true) ? $normalized : null;
}
/**
* @return array{
* locale: string,
* source: string,
* fallback_locale: string,
* user_preference_locale: ?string,
* workspace_default_locale: ?string,
* machine_artifacts_invariant: true
* }
*/
public function resolve(Request $request, ?string $plane = null): array
{
$plane = $this->normalizePlane($plane, $request);
$explicitOverride = $this->explicitOverride($request);
$systemDefault = (string) config('app.fallback_locale', 'en');
if ($plane === 'system') {
return $this->resolveFromSources(
explicitOverride: $explicitOverride,
userPreference: null,
workspaceDefault: null,
systemDefault: $systemDefault,
includeUserPreference: false,
includeWorkspaceDefault: false,
);
}
$user = $request->user();
$userPreference = $user instanceof User ? $user->preferred_locale : null;
$workspaceDefault = $this->workspaceDefault($request);
return $this->resolveFromSources(
explicitOverride: $explicitOverride,
userPreference: $userPreference,
workspaceDefault: $workspaceDefault,
systemDefault: $systemDefault,
includeUserPreference: true,
includeWorkspaceDefault: true,
);
}
/**
* @return array{
* locale: string,
* source: string,
* fallback_locale: string,
* user_preference_locale: ?string,
* workspace_default_locale: ?string,
* machine_artifacts_invariant: true
* }
*/
public function resolveFromSources(
mixed $explicitOverride,
mixed $userPreference,
mixed $workspaceDefault,
mixed $systemDefault,
bool $includeUserPreference = true,
bool $includeWorkspaceDefault = true,
): array {
$fallbackLocale = self::normalize(config('app.fallback_locale', 'en')) ?? 'en';
$candidates = [
self::SOURCE_EXPLICIT_OVERRIDE => self::normalize($explicitOverride),
];
if ($includeUserPreference) {
$candidates[self::SOURCE_USER_PREFERENCE] = self::normalize($userPreference);
}
if ($includeWorkspaceDefault) {
$candidates[self::SOURCE_WORKSPACE_DEFAULT] = self::normalize($workspaceDefault);
}
$candidates[self::SOURCE_SYSTEM_DEFAULT] = self::normalize($systemDefault) ?? $fallbackLocale;
foreach ($candidates as $source => $locale) {
if ($locale !== null) {
return [
'locale' => $locale,
'source' => $source,
'fallback_locale' => $fallbackLocale,
'user_preference_locale' => $includeUserPreference ? self::normalize($userPreference) : null,
'workspace_default_locale' => $includeWorkspaceDefault ? self::normalize($workspaceDefault) : null,
'machine_artifacts_invariant' => true,
];
}
}
return [
'locale' => $fallbackLocale,
'source' => self::SOURCE_SYSTEM_DEFAULT,
'fallback_locale' => $fallbackLocale,
'user_preference_locale' => $includeUserPreference ? self::normalize($userPreference) : null,
'workspace_default_locale' => $includeWorkspaceDefault ? self::normalize($workspaceDefault) : null,
'machine_artifacts_invariant' => true,
];
}
private function explicitOverride(Request $request): ?string
{
$queryLocale = self::normalize($request->query('locale'));
if ($queryLocale !== null) {
return $queryLocale;
}
if (! $request->hasSession()) {
return null;
}
return self::normalize($request->session()->get(self::SESSION_OVERRIDE_KEY));
}
private function workspaceDefault(Request $request): ?string
{
$workspace = $this->workspaceContext->currentWorkspace($request);
if (! $workspace instanceof Workspace) {
return null;
}
return self::normalize($this->settingsResolver->resolveValue(
workspace: $workspace,
domain: self::SETTING_DOMAIN,
key: self::SETTING_DEFAULT_LOCALE,
));
}
private function normalizePlane(?string $plane, Request $request): string
{
$plane = strtolower(trim((string) $plane));
if (in_array($plane, ['admin', 'tenant', 'system'], true)) {
return $plane;
}
return $request->is('system', 'system/*') ? 'system' : 'admin';
}
}

View File

@ -14,7 +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\Entitlements\WorkspaceEntitlementResolver;
use App\Services\Evidence\EvidenceResolutionRequest; use App\Services\Evidence\EvidenceResolutionRequest;
use App\Services\Evidence\EvidenceSnapshotResolver; use App\Services\Evidence\EvidenceSnapshotResolver;
use App\Support\Audit\AuditActionId; use App\Support\Audit\AuditActionId;
@ -30,7 +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 WorkspaceEntitlementResolver $workspaceEntitlementResolver,
private ProductTelemetryRecorder $productTelemetryRecorder, private ProductTelemetryRecorder $productTelemetryRecorder,
) {} ) {}
@ -234,16 +234,14 @@ public function computeFingerprint(Tenant $tenant, array $options): string
/** /**
* Generate a signed download URL for a review pack. * Generate a signed download URL for a review pack.
*
* @param array<string, scalar|null> $parameters
*/ */
public function generateDownloadUrl(ReviewPack $pack, array $parameters = []): string public function generateDownloadUrl(ReviewPack $pack): string
{ {
$ttlMinutes = (int) config('tenantpilot.review_pack.download_url_ttl_minutes', 60); $ttlMinutes = (int) config('tenantpilot.review_pack.download_url_ttl_minutes', 60);
return URL::signedRoute( return URL::signedRoute(
'admin.review-packs.download', 'admin.review-packs.download',
array_merge(['reviewPack' => $pack->getKey()], $parameters), ['reviewPack' => $pack->getKey()],
now()->addMinutes($ttlMinutes), now()->addMinutes($ttlMinutes),
); );
} }
@ -253,22 +251,10 @@ public function generateDownloadUrl(ReviewPack $pack, array $parameters = []): s
*/ */
public function reviewPackGenerationDecisionForTenant(Tenant $tenant): array public function reviewPackGenerationDecisionForTenant(Tenant $tenant): array
{ {
$tenant->loadMissing('workspace'); return $this->workspaceEntitlementResolver->resolve(
$decision = $this->workspaceCommercialLifecycleResolver->actionDecision(
$tenant->workspace, $tenant->workspace,
WorkspaceCommercialLifecycleResolver::ACTION_REVIEW_PACK_START, WorkspaceEntitlementResolver::KEY_REVIEW_PACK_GENERATION_ENABLED,
); );
$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

View File

@ -4,7 +4,6 @@
namespace App\Services\Settings; namespace App\Services\Settings;
use App\Models\PlatformUser;
use App\Models\Tenant; use App\Models\Tenant;
use App\Models\TenantSetting; use App\Models\TenantSetting;
use App\Models\User; use App\Models\User;
@ -12,14 +11,11 @@
use App\Models\WorkspaceSetting; use App\Models\WorkspaceSetting;
use App\Services\Audit\WorkspaceAuditLogger; use App\Services\Audit\WorkspaceAuditLogger;
use App\Services\Auth\WorkspaceCapabilityResolver; use App\Services\Auth\WorkspaceCapabilityResolver;
use App\Services\Entitlements\WorkspaceCommercialLifecycleResolver;
use App\Support\Audit\AuditActionId; use App\Support\Audit\AuditActionId;
use App\Support\Auth\Capabilities; use App\Support\Auth\Capabilities;
use App\Support\Auth\PlatformCapabilities;
use App\Support\Settings\SettingDefinition; use App\Support\Settings\SettingDefinition;
use App\Support\Settings\SettingsRegistry; use App\Support\Settings\SettingsRegistry;
use Illuminate\Auth\Access\AuthorizationException; use Illuminate\Auth\Access\AuthorizationException;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Validator; use Illuminate\Support\Facades\Validator;
use Illuminate\Validation\ValidationException; use Illuminate\Validation\ValidationException;
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException; use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
@ -37,7 +33,27 @@ public function updateWorkspaceSetting(User $actor, Workspace $workspace, string
{ {
$this->authorizeManage($actor, $workspace); $this->authorizeManage($actor, $workspace);
$result = $this->persistWorkspaceSetting($workspace, $domain, $key, $value, (int) $actor->getKey()); $definition = $this->requireDefinition($domain, $key);
$normalizedValue = $this->validatedValue($definition, $value);
$existing = WorkspaceSetting::query()
->where('workspace_id', (int) $workspace->getKey())
->where('domain', $domain)
->where('key', $key)
->first();
$beforeValue = $existing instanceof WorkspaceSetting
? $this->decodeStoredValue($existing->getAttribute('value'))
: null;
$setting = WorkspaceSetting::query()->updateOrCreate([
'workspace_id' => (int) $workspace->getKey(),
'domain' => $domain,
'key' => $key,
], [
'value' => $normalizedValue,
'updated_by_user_id' => (int) $actor->getKey(),
]);
$this->resolver->clearCache(); $this->resolver->clearCache();
@ -51,7 +67,7 @@ public function updateWorkspaceSetting(User $actor, Workspace $workspace, string
'scope' => 'workspace', 'scope' => 'workspace',
'domain' => $domain, 'domain' => $domain,
'key' => $key, 'key' => $key,
'before_value' => $result['before_value'], 'before_value' => $beforeValue,
'after_value' => $afterValue, 'after_value' => $afterValue,
], ],
], ],
@ -60,79 +76,7 @@ public function updateWorkspaceSetting(User $actor, Workspace $workspace, string
resourceId: $domain.'.'.$key, resourceId: $domain.'.'.$key,
); );
return $result['setting']; return $setting;
}
public function updateWorkspaceCommercialLifecycle(
PlatformUser $actor,
Workspace $workspace,
string $state,
string $reason,
): void {
$state = strtolower(trim($state));
$reason = trim($reason);
if (! $actor->hasCapability(PlatformCapabilities::COMMERCIAL_LIFECYCLE_MANAGE)) {
throw new AuthorizationException('Missing commercial lifecycle manage capability.');
}
if ($reason === '') {
throw ValidationException::withMessages([
'reason' => ['A rationale is required when changing commercial lifecycle state.'],
]);
}
DB::transaction(function () use ($actor, $workspace, $state, $reason): void {
$stateResult = $this->persistWorkspaceSetting(
workspace: $workspace,
domain: WorkspaceCommercialLifecycleResolver::SETTING_DOMAIN,
key: WorkspaceCommercialLifecycleResolver::SETTING_COMMERCIAL_LIFECYCLE_STATE,
value: $state,
updatedByUserId: null,
);
$reasonResult = $this->persistWorkspaceSetting(
workspace: $workspace,
domain: WorkspaceCommercialLifecycleResolver::SETTING_DOMAIN,
key: WorkspaceCommercialLifecycleResolver::SETTING_COMMERCIAL_LIFECYCLE_REASON,
value: $reason,
updatedByUserId: null,
);
$this->resolver->clearCache();
$afterState = $this->resolver->resolveValue(
$workspace,
WorkspaceCommercialLifecycleResolver::SETTING_DOMAIN,
WorkspaceCommercialLifecycleResolver::SETTING_COMMERCIAL_LIFECYCLE_STATE,
);
$afterReason = $this->resolver->resolveValue(
$workspace,
WorkspaceCommercialLifecycleResolver::SETTING_DOMAIN,
WorkspaceCommercialLifecycleResolver::SETTING_COMMERCIAL_LIFECYCLE_REASON,
);
$this->auditLogger->log(
workspace: $workspace,
action: AuditActionId::WorkspaceSettingUpdated->value,
context: [
'metadata' => [
'scope' => 'workspace',
'domain' => WorkspaceCommercialLifecycleResolver::SETTING_DOMAIN,
'key' => WorkspaceCommercialLifecycleResolver::SETTING_COMMERCIAL_LIFECYCLE_STATE,
'before_state' => $stateResult['before_value'],
'after_state' => $afterState,
'before_reason' => $reasonResult['before_value'],
'after_reason' => $afterReason,
],
],
actor: $actor,
resourceType: 'workspace_setting',
resourceId: WorkspaceCommercialLifecycleResolver::SETTING_DOMAIN.'.'.WorkspaceCommercialLifecycleResolver::SETTING_COMMERCIAL_LIFECYCLE_STATE,
targetLabel: 'Commercial lifecycle state',
);
});
} }
public function resetWorkspaceSetting(User $actor, Workspace $workspace, string $domain, string $key): void public function resetWorkspaceSetting(User $actor, Workspace $workspace, string $domain, string $key): void
@ -230,39 +174,6 @@ private function requireDefinition(string $domain, string $key): SettingDefiniti
]); ]);
} }
/**
* @return array{setting: WorkspaceSetting, before_value: mixed}
*/
private function persistWorkspaceSetting(Workspace $workspace, string $domain, string $key, mixed $value, ?int $updatedByUserId): array
{
$definition = $this->requireDefinition($domain, $key);
$normalizedValue = $this->validatedValue($definition, $value);
$existing = WorkspaceSetting::query()
->where('workspace_id', (int) $workspace->getKey())
->where('domain', $domain)
->where('key', $key)
->first();
$beforeValue = $existing instanceof WorkspaceSetting
? $this->decodeStoredValue($existing->getAttribute('value'))
: null;
$setting = WorkspaceSetting::query()->updateOrCreate([
'workspace_id' => (int) $workspace->getKey(),
'domain' => $domain,
'key' => $key,
], [
'value' => $normalizedValue,
'updated_by_user_id' => $updatedByUserId,
]);
return [
'setting' => $setting,
'before_value' => $beforeValue,
];
}
private function validatedValue(SettingDefinition $definition, mixed $value): mixed private function validatedValue(SettingDefinition $definition, mixed $value): mixed
{ {
$validator = Validator::make( $validator = Validator::make(

View File

@ -12,7 +12,6 @@
use App\Services\Auth\RoleCapabilityMap; use App\Services\Auth\RoleCapabilityMap;
use App\Support\Auth\Capabilities; use App\Support\Auth\Capabilities;
use Illuminate\Database\Eloquent\Builder; use Illuminate\Database\Eloquent\Builder;
use Illuminate\Support\Facades\DB;
final class TenantReviewRegisterService final class TenantReviewRegisterService
{ {
@ -44,55 +43,6 @@ public function query(User $user, Workspace $workspace): Builder
->latest('id'); ->latest('id');
} }
public function latestPublishedQuery(User $user, Workspace $workspace): Builder
{
$tenantIds = array_keys($this->authorizedTenants($user, $workspace));
$rankedReviews = TenantReview::query()
->select([
'tenant_reviews.id',
'tenant_reviews.tenant_id',
'tenant_reviews.published_at',
'tenant_reviews.generated_at',
])
->selectRaw('ROW_NUMBER() OVER (PARTITION BY tenant_id ORDER BY published_at DESC, generated_at DESC, id DESC) as rn')
->forWorkspace((int) $workspace->getKey())
->whereIn('tenant_id', $tenantIds === [] ? [-1] : $tenantIds)
->published();
$latestPublishedIds = DB::query()
->fromSub($rankedReviews, 'ranked_tenant_reviews')
->where('rn', 1)
->select('id');
return TenantReview::query()
->with(['tenant', 'evidenceSnapshot', 'currentExportReviewPack'])
->forWorkspace((int) $workspace->getKey())
->whereIn('tenant_reviews.id', $latestPublishedIds)
->orderByDesc('published_at')
->orderByDesc('generated_at')
->orderByDesc('id');
}
public function customerWorkspaceTenantQuery(User $user, Workspace $workspace): Builder
{
$tenantIds = array_keys($this->authorizedTenants($user, $workspace));
return Tenant::query()
->where('workspace_id', (int) $workspace->getKey())
->whereIn('id', $tenantIds === [] ? [-1] : $tenantIds)
->with([
'tenantReviews' => fn ($query) => $query
->with(['tenant', 'evidenceSnapshot', 'currentExportReviewPack'])
->published()
->orderByDesc('published_at')
->orderByDesc('generated_at')
->orderByDesc('id')
->limit(1),
])
->orderBy('name');
}
public function canAccessWorkspace(User $user, Workspace $workspace): bool public function canAccessWorkspace(User $user, Workspace $workspace): bool
{ {
return WorkspaceMembership::query() return WorkspaceMembership::query()

View File

@ -94,10 +94,8 @@ 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';
@ -240,10 +238,8 @@ 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',
@ -332,10 +328,8 @@ private static function summaries(): array
self::TenantReviewRefreshed->value => 'Tenant review refreshed', self::TenantReviewRefreshed->value => 'Tenant review refreshed',
self::TenantReviewPublished->value => 'Tenant review published', self::TenantReviewPublished->value => 'Tenant review published',
self::TenantReviewArchived->value => 'Tenant review archived', self::TenantReviewArchived->value => 'Tenant review archived',
self::TenantReviewOpened->value => 'Tenant review opened',
self::TenantReviewExported->value => 'Tenant review exported', self::TenantReviewExported->value => 'Tenant review exported',
self::TenantReviewSuccessorCreated->value => 'Tenant review next cycle created', self::TenantReviewSuccessorCreated->value => 'Tenant review next cycle created',
self::ReviewPackDownloaded->value => 'Review pack downloaded',
self::SupportDiagnosticsOpened->value => 'Support diagnostics opened', self::SupportDiagnosticsOpened->value => 'Support diagnostics opened',
self::SupportRequestCreated->value => 'Support request created', self::SupportRequestCreated->value => 'Support request created',
self::AiExecutionDecisionEvaluated->value => 'AI execution decision evaluated', self::AiExecutionDecisionEvaluated->value => 'AI execution decision evaluated',

View File

@ -18,8 +18,6 @@ class PlatformCapabilities
public const DIRECTORY_VIEW = 'platform.directory.view'; public const DIRECTORY_VIEW = 'platform.directory.view';
public const COMMERCIAL_LIFECYCLE_MANAGE = 'platform.commercial_lifecycle.manage';
public const OPERATIONS_VIEW = 'platform.operations.view'; public const OPERATIONS_VIEW = 'platform.operations.view';
public const OPERATIONS_MANAGE = 'platform.operations.manage'; public const OPERATIONS_MANAGE = 'platform.operations.manage';

View File

@ -57,7 +57,6 @@ final class BadgeCatalog
BadgeDomain::BaselineProfileStatus->value => Domains\BaselineProfileStatusBadge::class, BadgeDomain::BaselineProfileStatus->value => Domains\BaselineProfileStatusBadge::class,
BadgeDomain::FindingType->value => Domains\FindingTypeBadge::class, BadgeDomain::FindingType->value => Domains\FindingTypeBadge::class,
BadgeDomain::ReviewPackStatus->value => Domains\ReviewPackStatusBadge::class, BadgeDomain::ReviewPackStatus->value => Domains\ReviewPackStatusBadge::class,
BadgeDomain::CommercialLifecycleState->value => Domains\CommercialLifecycleStateBadge::class,
BadgeDomain::EvidenceSnapshotStatus->value => Domains\EvidenceSnapshotStatusBadge::class, BadgeDomain::EvidenceSnapshotStatus->value => Domains\EvidenceSnapshotStatusBadge::class,
BadgeDomain::EvidenceCompleteness->value => Domains\EvidenceCompletenessBadge::class, BadgeDomain::EvidenceCompleteness->value => Domains\EvidenceCompletenessBadge::class,
BadgeDomain::TenantReviewStatus->value => Domains\TenantReviewStatusBadge::class, BadgeDomain::TenantReviewStatus->value => Domains\TenantReviewStatusBadge::class,

View File

@ -48,7 +48,6 @@ enum BadgeDomain: string
case BaselineProfileStatus = 'baseline_profile_status'; case BaselineProfileStatus = 'baseline_profile_status';
case FindingType = 'finding_type'; case FindingType = 'finding_type';
case ReviewPackStatus = 'review_pack_status'; case ReviewPackStatus = 'review_pack_status';
case CommercialLifecycleState = 'commercial_lifecycle_state';
case EvidenceSnapshotStatus = 'evidence_snapshot_status'; case EvidenceSnapshotStatus = 'evidence_snapshot_status';
case EvidenceCompleteness = 'evidence_completeness'; case EvidenceCompleteness = 'evidence_completeness';
case TenantReviewStatus = 'tenant_review_status'; case TenantReviewStatus = 'tenant_review_status';

View File

@ -1,26 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Support\Badges\Domains;
use App\Services\Entitlements\WorkspaceCommercialLifecycleResolver;
use App\Support\Badges\BadgeCatalog;
use App\Support\Badges\BadgeMapper;
use App\Support\Badges\BadgeSpec;
final class CommercialLifecycleStateBadge implements BadgeMapper
{
public function spec(mixed $value): BadgeSpec
{
$state = BadgeCatalog::normalizeState($value);
return match ($state) {
WorkspaceCommercialLifecycleResolver::STATE_TRIAL => new BadgeSpec('Trial', 'info', 'heroicon-m-clock'),
WorkspaceCommercialLifecycleResolver::STATE_GRACE => new BadgeSpec('Grace', 'warning', 'heroicon-m-exclamation-triangle'),
WorkspaceCommercialLifecycleResolver::STATE_ACTIVE_PAID => new BadgeSpec('Active paid', 'success', 'heroicon-m-check-circle'),
WorkspaceCommercialLifecycleResolver::STATE_SUSPENDED_READ_ONLY => new BadgeSpec('Suspended / read-only', 'danger', 'heroicon-m-lock-closed'),
default => BadgeSpec::unknown(),
};
}
}

View File

@ -1,888 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Support\GovernanceInbox;
use App\Filament\Pages\Findings\FindingsIntakeQueue;
use App\Filament\Pages\Findings\MyFindingsInbox;
use App\Filament\Pages\Reviews\CustomerReviewWorkspace;
use App\Filament\Resources\AlertDeliveryResource;
use App\Filament\Resources\FindingExceptionResource;
use App\Filament\Resources\FindingResource;
use App\Filament\Resources\TenantResource;
use App\Filament\Resources\TenantReviewResource;
use App\Models\AlertDelivery;
use App\Models\Finding;
use App\Models\OperationRun;
use App\Models\Tenant;
use App\Models\TenantTriageReview;
use App\Models\User;
use App\Models\Workspace;
use App\Services\TenantReviews\TenantReviewRegisterService;
use App\Support\Auth\Capabilities;
use App\Support\BackupHealth\TenantBackupHealthAssessment;
use App\Support\BackupHealth\TenantBackupHealthResolver;
use App\Support\Navigation\CanonicalNavigationContext;
use App\Support\OperationRunLinks;
use App\Support\PortfolioTriage\PortfolioArrivalContextToken;
use App\Support\PortfolioTriage\TenantTriageReviewStateResolver;
use App\Support\RestoreSafety\RestoreSafetyResolver;
use App\Support\Tenants\TenantRecoveryTriagePresentation;
use Illuminate\Support\Str;
final readonly class GovernanceInboxSectionBuilder
{
private const PREVIEW_LIMIT = 3;
/**
* @var list<string>
*/
private const FAMILY_ORDER = [
'assigned_findings',
'intake_findings',
'stale_operations',
'alert_delivery_failures',
'review_follow_up',
];
public function __construct(
private TenantBackupHealthResolver $backupHealthResolver,
private RestoreSafetyResolver $restoreSafetyResolver,
private TenantTriageReviewStateResolver $tenantTriageReviewStateResolver,
private TenantReviewRegisterService $tenantReviewRegisterService,
) {}
/**
* @param array<int, Tenant> $authorizedTenants
* @param array<int, Tenant> $visibleFindingTenants
* @param array<int, Tenant> $reviewTenants
* @return array{
* sections: list<array<string, mixed>>,
* available_families: list<array{key: string, label: string, count: int}>,
* family_counts: array<string, int>,
* total_count: int,
* }
*/
public function build(
User $user,
Workspace $workspace,
array $authorizedTenants,
array $visibleFindingTenants,
array $reviewTenants,
bool $canViewAlerts,
?Tenant $selectedTenant = null,
?string $selectedFamily = null,
?CanonicalNavigationContext $navigationContext = null,
): array {
$authorizedTenantsById = $this->indexTenants($authorizedTenants);
$visibleFindingTenantsById = $this->indexTenants($visibleFindingTenants);
$reviewTenantsById = $this->indexTenants($reviewTenants);
$allSections = [];
$availableFamilies = [];
$familyCounts = [];
if ($visibleFindingTenantsById !== []) {
$assignedSection = $this->assignedFindingsSection(
user: $user,
visibleFindingTenants: $visibleFindingTenantsById,
selectedTenant: $selectedTenant,
navigationContext: $navigationContext,
);
$allSections[$assignedSection['key']] = $assignedSection;
$availableFamilies[] = [
'key' => $assignedSection['key'],
'label' => $assignedSection['label'],
'count' => $assignedSection['count'],
];
$familyCounts[$assignedSection['key']] = $assignedSection['count'];
$intakeSection = $this->intakeFindingsSection(
visibleFindingTenants: $visibleFindingTenantsById,
selectedTenant: $selectedTenant,
navigationContext: $navigationContext,
);
$allSections[$intakeSection['key']] = $intakeSection;
$availableFamilies[] = [
'key' => $intakeSection['key'],
'label' => $intakeSection['label'],
'count' => $intakeSection['count'],
];
$familyCounts[$intakeSection['key']] = $intakeSection['count'];
}
if ($authorizedTenantsById !== []) {
$operationsSection = $this->operationsSection(
workspace: $workspace,
authorizedTenants: $authorizedTenantsById,
selectedTenant: $selectedTenant,
navigationContext: $navigationContext,
);
$allSections[$operationsSection['key']] = $operationsSection;
$availableFamilies[] = [
'key' => $operationsSection['key'],
'label' => $operationsSection['label'],
'count' => $operationsSection['count'],
];
$familyCounts[$operationsSection['key']] = $operationsSection['count'];
}
if ($canViewAlerts) {
$alertsSection = $this->alertsSection(
workspace: $workspace,
authorizedTenants: $authorizedTenantsById,
selectedTenant: $selectedTenant,
navigationContext: $navigationContext,
);
$allSections[$alertsSection['key']] = $alertsSection;
$availableFamilies[] = [
'key' => $alertsSection['key'],
'label' => $alertsSection['label'],
'count' => $alertsSection['count'],
];
$familyCounts[$alertsSection['key']] = $alertsSection['count'];
}
if ($reviewTenantsById !== []) {
$reviewSection = $this->reviewFollowUpSection(
user: $user,
workspace: $workspace,
reviewTenants: $reviewTenantsById,
selectedTenant: $selectedTenant,
navigationContext: $navigationContext,
);
$allSections[$reviewSection['key']] = $reviewSection;
$availableFamilies[] = [
'key' => $reviewSection['key'],
'label' => $reviewSection['label'],
'count' => $reviewSection['count'],
];
$familyCounts[$reviewSection['key']] = $reviewSection['count'];
}
$sections = [];
foreach (self::FAMILY_ORDER as $familyKey) {
$section = $allSections[$familyKey] ?? null;
if (! is_array($section)) {
continue;
}
if ($selectedFamily !== null) {
if ($familyKey === $selectedFamily) {
$sections[] = $section;
}
continue;
}
if ((int) ($section['count'] ?? 0) > 0) {
$sections[] = $section;
}
}
return [
'sections' => $sections,
'available_families' => $availableFamilies,
'family_counts' => $familyCounts,
'total_count' => array_sum($familyCounts),
];
}
/**
* @param array<int, Tenant> $tenants
* @return array<int, Tenant>
*/
private function indexTenants(array $tenants): array
{
$indexed = [];
foreach ($tenants as $tenant) {
$indexed[(int) $tenant->getKey()] = $tenant;
}
return $indexed;
}
/**
* @param array<int, Tenant> $visibleFindingTenants
* @return array<string, mixed>
*/
private function assignedFindingsSection(
User $user,
array $visibleFindingTenants,
?Tenant $selectedTenant,
?CanonicalNavigationContext $navigationContext,
): array {
$baseQuery = $this->assignedFindingsQuery($user, $visibleFindingTenants, $selectedTenant);
$count = (clone $baseQuery)->count();
$overdueCount = (clone $baseQuery)
->whereNotNull('due_at')
->where('due_at', '<', now())
->count();
$entries = $this->orderedAssignedFindingsQuery(clone $baseQuery)
->limit(self::PREVIEW_LIMIT)
->get()
->map(fn (Finding $finding): array => $this->findingEntry($finding, 'assigned_findings', $navigationContext, 10))
->all();
return [
'key' => 'assigned_findings',
'label' => 'Assigned findings',
'count' => $count,
'summary' => $this->assignedFindingsSummary($count, $overdueCount),
'dominant_action_label' => 'Open my findings',
'dominant_action_url' => $this->appendQuery(
MyFindingsInbox::getUrl(
panel: 'admin',
parameters: array_filter([
'tenant' => $selectedTenant?->external_id,
], static fn (mixed $value): bool => is_string($value) && $value !== ''),
),
$navigationContext?->toQuery() ?? [],
),
'entries' => $entries,
'empty_state' => $selectedTenant instanceof Tenant
? 'No assigned findings match this tenant filter right now.'
: 'No assigned findings are visible right now.',
];
}
/**
* @param array<int, Tenant> $visibleFindingTenants
* @return array<string, mixed>
*/
private function intakeFindingsSection(
array $visibleFindingTenants,
?Tenant $selectedTenant,
?CanonicalNavigationContext $navigationContext,
): array {
$baseQuery = $this->intakeFindingsQuery($visibleFindingTenants, $selectedTenant);
$count = (clone $baseQuery)->count();
$needsTriageCount = (clone $baseQuery)
->whereIn('status', [Finding::STATUS_NEW, Finding::STATUS_REOPENED])
->count();
$entries = $this->orderedIntakeFindingsQuery(clone $baseQuery)
->limit(self::PREVIEW_LIMIT)
->get()
->map(fn (Finding $finding): array => $this->findingEntry($finding, 'intake_findings', $navigationContext, 20))
->all();
return [
'key' => 'intake_findings',
'label' => 'Findings intake',
'count' => $count,
'summary' => $this->intakeFindingsSummary($count, $needsTriageCount),
'dominant_action_label' => 'Open findings intake',
'dominant_action_url' => $this->appendQuery(
FindingsIntakeQueue::getUrl(
panel: 'admin',
parameters: array_filter([
'tenant' => $selectedTenant?->external_id,
'view' => $needsTriageCount > 0 ? 'needs_triage' : null,
], static fn (mixed $value): bool => $value !== null && $value !== ''),
),
$navigationContext?->toQuery() ?? [],
),
'entries' => $entries,
'empty_state' => $selectedTenant instanceof Tenant
? 'No intake findings match this tenant filter right now.'
: 'No intake findings are visible right now.',
];
}
/**
* @param array<int, Tenant> $authorizedTenants
* @return array<string, mixed>
*/
private function operationsSection(
Workspace $workspace,
array $authorizedTenants,
?Tenant $selectedTenant,
?CanonicalNavigationContext $navigationContext,
): array {
$terminalQuery = $this->terminalOperationsQuery($workspace, $authorizedTenants, $selectedTenant);
$staleQuery = $this->staleOperationsQuery($workspace, $authorizedTenants, $selectedTenant);
$terminalCount = (clone $terminalQuery)->count();
$staleCount = (clone $staleQuery)->count();
$entries = array_merge(
(clone $terminalQuery)->latest('completed_at')->latest('id')->limit(self::PREVIEW_LIMIT)->get()->all(),
(clone $staleQuery)->latest('created_at')->latest('id')->limit(self::PREVIEW_LIMIT)->get()->all(),
);
$entries = collect($entries)
->unique(fn (OperationRun $run): int => (int) $run->getKey())
->sortBy([
fn (OperationRun $run): int => $run->problemClass() === OperationRun::PROBLEM_CLASS_TERMINAL_FOLLOW_UP ? 0 : 1,
fn (OperationRun $run): int => -1 * (int) $run->getKey(),
])
->take(self::PREVIEW_LIMIT)
->map(fn (OperationRun $run): array => $this->operationEntry($run, $navigationContext))
->values()
->all();
$dominantProblemClass = $terminalCount > 0
? OperationRun::PROBLEM_CLASS_TERMINAL_FOLLOW_UP
: OperationRun::PROBLEM_CLASS_ACTIVE_STALE_ATTENTION;
return [
'key' => 'stale_operations',
'label' => 'Operations follow-up',
'count' => $terminalCount + $staleCount,
'summary' => $this->operationsSummary($terminalCount, $staleCount),
'dominant_action_label' => $terminalCount > 0 ? 'Open terminal follow-up' : 'Open stale operations',
'dominant_action_url' => OperationRunLinks::index(
tenant: $selectedTenant,
context: $navigationContext,
problemClass: $dominantProblemClass,
),
'entries' => $entries,
'empty_state' => $selectedTenant instanceof Tenant
? 'No stale or terminal follow-up operations match this tenant filter right now.'
: 'No stale or terminal follow-up operations are visible right now.',
];
}
/**
* @param array<int, Tenant> $authorizedTenants
* @return array<string, mixed>
*/
private function alertsSection(
Workspace $workspace,
array $authorizedTenants,
?Tenant $selectedTenant,
?CanonicalNavigationContext $navigationContext,
): array {
$baseQuery = $this->alertsQuery($workspace, $authorizedTenants, $selectedTenant);
$count = (clone $baseQuery)->count();
$entries = (clone $baseQuery)
->latest('created_at')
->latest('id')
->limit(self::PREVIEW_LIMIT)
->get()
->map(fn (AlertDelivery $delivery): array => $this->alertEntry($delivery, $navigationContext))
->all();
return [
'key' => 'alert_delivery_failures',
'label' => 'Alert delivery failures',
'count' => $count,
'summary' => $this->alertsSummary($count),
'dominant_action_label' => 'Open alert deliveries',
'dominant_action_url' => $this->appendQuery(
AlertDeliveryResource::getUrl(panel: 'admin'),
array_replace_recursive(
$navigationContext?->toQuery() ?? [],
[
'tableFilters' => array_filter([
'status' => ['value' => AlertDelivery::STATUS_FAILED],
'tenant_id' => $selectedTenant instanceof Tenant
? ['value' => (string) $selectedTenant->getKey()]
: null,
], static fn (mixed $value): bool => $value !== null),
],
),
),
'entries' => $entries,
'empty_state' => $selectedTenant instanceof Tenant
? 'No failed alert deliveries match this tenant filter right now.'
: 'No failed alert deliveries are visible right now.',
];
}
/**
* @param array<int, Tenant> $reviewTenants
* @return array<string, mixed>
*/
private function reviewFollowUpSection(
User $user,
Workspace $workspace,
array $reviewTenants,
?Tenant $selectedTenant,
?CanonicalNavigationContext $navigationContext,
): array {
$tenantIds = $selectedTenant instanceof Tenant
? [(int) $selectedTenant->getKey()]
: array_keys($reviewTenants);
$backupHealthByTenant = $this->backupHealthResolver->assessMany($tenantIds);
$recoveryEvidenceByTenant = $this->restoreSafetyResolver->dashboardRecoveryEvidenceForTenants($tenantIds, $backupHealthByTenant);
$resolved = $this->tenantTriageReviewStateResolver->resolveMany(
workspaceId: (int) $workspace->getKey(),
tenantIds: $tenantIds,
backupHealthByTenant: $backupHealthByTenant,
recoveryEvidenceByTenant: $recoveryEvidenceByTenant,
);
$latestPublishedReviews = $this->tenantReviewRegisterService
->latestPublishedQuery($user, $workspace)
->get()
->keyBy('tenant_id')
->all();
$rawEntries = [];
foreach ($tenantIds as $tenantId) {
$tenant = $reviewTenants[$tenantId] ?? null;
$rows = $resolved['rows'][$tenantId] ?? null;
if (! $tenant instanceof Tenant || ! is_array($rows)) {
continue;
}
foreach ([PortfolioArrivalContextToken::FAMILY_BACKUP_HEALTH, PortfolioArrivalContextToken::FAMILY_RECOVERY_EVIDENCE] as $family) {
$row = $rows[$family] ?? null;
if (! is_array($row) || ($row['current_concern_present'] ?? false) !== true) {
continue;
}
$derivedState = $row['derived_state'] ?? null;
if (! in_array($derivedState, [
TenantTriageReview::STATE_FOLLOW_UP_NEEDED,
TenantTriageReview::DERIVED_STATE_CHANGED_SINCE_REVIEW,
], true)) {
continue;
}
$rawEntries[] = $this->reviewEntry(
tenant: $tenant,
family: $family,
row: $row,
latestPublishedReview: $latestPublishedReviews[$tenantId] ?? null,
navigationContext: $navigationContext,
);
}
}
usort($rawEntries, function (array $left, array $right): int {
$leftRank = (int) ($left['urgency_rank'] ?? 0);
$rightRank = (int) ($right['urgency_rank'] ?? 0);
if ($leftRank !== $rightRank) {
return $leftRank <=> $rightRank;
}
return strcmp((string) ($left['headline'] ?? ''), (string) ($right['headline'] ?? ''));
});
$followUpCount = collect($rawEntries)
->where('status_label', 'Follow-up needed')
->count();
$changedCount = collect($rawEntries)
->where('status_label', 'Changed since review')
->count();
return [
'key' => 'review_follow_up',
'label' => 'Review follow-up',
'count' => count($rawEntries),
'summary' => $this->reviewSummary($followUpCount, $changedCount),
'dominant_action_label' => 'Open review follow-up',
'dominant_action_url' => $selectedTenant instanceof Tenant
? $this->appendQuery(CustomerReviewWorkspace::tenantPrefilterUrl($selectedTenant), $navigationContext?->toQuery() ?? [])
: $this->appendQuery(TenantResource::getUrl(panel: 'admin'), array_replace_recursive(
$navigationContext?->toQuery() ?? [],
[
'backup_posture' => [
TenantBackupHealthAssessment::POSTURE_ABSENT,
TenantBackupHealthAssessment::POSTURE_STALE,
TenantBackupHealthAssessment::POSTURE_DEGRADED,
],
'recovery_evidence' => [
TenantRecoveryTriagePresentation::RECOVERY_EVIDENCE_WEAKENED,
TenantRecoveryTriagePresentation::RECOVERY_EVIDENCE_UNVALIDATED,
],
'review_state' => [
TenantTriageReview::STATE_FOLLOW_UP_NEEDED,
TenantTriageReview::DERIVED_STATE_CHANGED_SINCE_REVIEW,
],
'triage_sort' => TenantRecoveryTriagePresentation::TRIAGE_SORT_WORST_FIRST,
],
)),
'entries' => array_slice($rawEntries, 0, self::PREVIEW_LIMIT),
'empty_state' => $selectedTenant instanceof Tenant
? 'No review follow-up is visible for this tenant filter right now.'
: 'No review follow-up is visible right now.',
];
}
/**
* @param array<int, Tenant> $visibleFindingTenants
*/
private function assignedFindingsQuery(User $user, array $visibleFindingTenants, ?Tenant $selectedTenant): \Illuminate\Database\Eloquent\Builder
{
$tenantIds = $selectedTenant instanceof Tenant
? [(int) $selectedTenant->getKey()]
: array_keys($visibleFindingTenants);
return Finding::query()
->with(['tenant', 'ownerUser:id,name', 'assigneeUser:id,name'])
->withSubjectDisplayName()
->whereIn('tenant_id', $tenantIds === [] ? [-1] : $tenantIds)
->where('assignee_user_id', (int) $user->getKey())
->whereIn('status', Finding::openStatusesForQuery());
}
private function orderedAssignedFindingsQuery(\Illuminate\Database\Eloquent\Builder $query): \Illuminate\Database\Eloquent\Builder
{
return $query
->orderByRaw(
'case when due_at is not null and due_at < ? then 0 when reopened_at is not null then 1 else 2 end asc',
[now()],
)
->orderByRaw('case when due_at is null then 1 else 0 end asc')
->orderBy('due_at')
->orderByDesc('id');
}
/**
* @param array<int, Tenant> $visibleFindingTenants
*/
private function intakeFindingsQuery(array $visibleFindingTenants, ?Tenant $selectedTenant): \Illuminate\Database\Eloquent\Builder
{
$tenantIds = $selectedTenant instanceof Tenant
? [(int) $selectedTenant->getKey()]
: array_keys($visibleFindingTenants);
return Finding::query()
->with(['tenant', 'ownerUser:id,name', 'assigneeUser:id,name'])
->withSubjectDisplayName()
->whereIn('tenant_id', $tenantIds === [] ? [-1] : $tenantIds)
->whereNull('assignee_user_id')
->whereIn('status', Finding::openStatusesForQuery());
}
private function orderedIntakeFindingsQuery(\Illuminate\Database\Eloquent\Builder $query): \Illuminate\Database\Eloquent\Builder
{
return $query
->orderByRaw(
"case
when due_at is not null and due_at < ? then 0
when status = ? then 1
when status = ? then 2
else 3
end asc",
[now(), Finding::STATUS_REOPENED, Finding::STATUS_NEW],
)
->orderByRaw('case when due_at is null then 1 else 0 end asc')
->orderBy('due_at')
->orderByDesc('id');
}
/**
* @param array<int, Tenant> $authorizedTenants
*/
private function terminalOperationsQuery(Workspace $workspace, array $authorizedTenants, ?Tenant $selectedTenant): \Illuminate\Database\Eloquent\Builder
{
return $this->operationsBaseQuery($workspace, $authorizedTenants, $selectedTenant)
->terminalFollowUp();
}
/**
* @param array<int, Tenant> $authorizedTenants
*/
private function staleOperationsQuery(Workspace $workspace, array $authorizedTenants, ?Tenant $selectedTenant): \Illuminate\Database\Eloquent\Builder
{
return $this->operationsBaseQuery($workspace, $authorizedTenants, $selectedTenant)
->activeStaleAttention();
}
/**
* @param array<int, Tenant> $authorizedTenants
*/
private function operationsBaseQuery(Workspace $workspace, array $authorizedTenants, ?Tenant $selectedTenant): \Illuminate\Database\Eloquent\Builder
{
$tenantIds = array_keys($authorizedTenants);
return OperationRun::query()
->with('tenant')
->where('workspace_id', (int) $workspace->getKey())
->where(function ($query) use ($selectedTenant, $tenantIds): void {
if ($selectedTenant instanceof Tenant) {
$query->where('tenant_id', (int) $selectedTenant->getKey());
return;
}
$query
->whereIn('tenant_id', $tenantIds === [] ? [-1] : $tenantIds)
->orWhereNull('tenant_id');
});
}
/**
* @param array<int, Tenant> $authorizedTenants
*/
private function alertsQuery(Workspace $workspace, array $authorizedTenants, ?Tenant $selectedTenant): \Illuminate\Database\Eloquent\Builder
{
$tenantIds = array_keys($authorizedTenants);
return AlertDelivery::query()
->with('tenant')
->where('workspace_id', (int) $workspace->getKey())
->where('status', AlertDelivery::STATUS_FAILED)
->where(function ($query) use ($selectedTenant, $tenantIds): void {
if ($selectedTenant instanceof Tenant) {
$query->where('tenant_id', (int) $selectedTenant->getKey());
return;
}
$query
->whereIn('tenant_id', $tenantIds === [] ? [-1] : $tenantIds)
->orWhereNull('tenant_id');
});
}
/**
* @return array<string, mixed>
*/
private function findingEntry(Finding $finding, string $familyKey, ?CanonicalNavigationContext $navigationContext, int $baseUrgencyRank): array
{
$sublineParts = array_values(array_filter([
$finding->owner_user_id !== null ? 'Owner: '.FindingResource::accountableOwnerDisplayFor($finding) : null,
FindingExceptionResource::relativeTimeDescription($finding->due_at) ?? FindingResource::dueAttentionLabelFor($finding),
$finding->reopened_at !== null ? 'Reopened' : null,
]));
return [
'family_key' => $familyKey,
'source_model' => Finding::class,
'source_key' => (string) $finding->getKey(),
'tenant_id' => $finding->tenant ? (int) $finding->tenant->getKey() : null,
'tenant_label' => $finding->tenant?->name,
'headline' => $finding->resolvedSubjectDisplayName() ?? 'Finding #'.$finding->getKey(),
'subline' => $sublineParts === [] ? null : implode(' • ', $sublineParts),
'urgency_rank' => $baseUrgencyRank
+ ($finding->due_at?->isPast() === true ? 0 : 1)
+ ($finding->reopened_at !== null ? 0 : 1),
'status_label' => Str::of((string) $finding->status)->replace('_', ' ')->title()->value(),
'destination_url' => $this->appendQuery(
FindingResource::getUrl('view', ['record' => $finding], panel: 'tenant', tenant: $finding->tenant),
$navigationContext?->toQuery() ?? [],
),
'back_label' => $navigationContext?->backLinkLabel ?? 'Back to governance inbox',
];
}
/**
* @return array<string, mixed>
*/
private function operationEntry(OperationRun $run, ?CanonicalNavigationContext $navigationContext): array
{
$problemClass = $run->problemClass();
return [
'family_key' => 'stale_operations',
'source_model' => OperationRun::class,
'source_key' => (string) $run->getKey(),
'tenant_id' => $run->tenant ? (int) $run->tenant->getKey() : null,
'tenant_label' => $run->tenant?->name,
'headline' => $problemClass === OperationRun::PROBLEM_CLASS_TERMINAL_FOLLOW_UP
? 'Terminal follow-up operation'
: 'Stale active operation',
'subline' => OperationRunLinks::identifier($run),
'urgency_rank' => $problemClass === OperationRun::PROBLEM_CLASS_TERMINAL_FOLLOW_UP ? 0 : 1,
'status_label' => $problemClass === OperationRun::PROBLEM_CLASS_TERMINAL_FOLLOW_UP
? 'Terminal follow-up'
: 'Stale',
'destination_url' => OperationRunLinks::tenantlessView($run, $navigationContext),
'back_label' => $navigationContext?->backLinkLabel ?? 'Back to governance inbox',
];
}
/**
* @return array<string, mixed>
*/
private function alertEntry(AlertDelivery $delivery, ?CanonicalNavigationContext $navigationContext): array
{
$payload = is_array($delivery->payload) ? $delivery->payload : [];
$headline = is_string($payload['title'] ?? null) && $payload['title'] !== ''
? (string) $payload['title']
: 'Failed alert delivery';
$sublineParts = array_values(array_filter([
is_string($delivery->last_error_message) && $delivery->last_error_message !== ''
? $delivery->last_error_message
: null,
is_string($delivery->event_type) && $delivery->event_type !== ''
? $delivery->event_type
: null,
]));
return [
'family_key' => 'alert_delivery_failures',
'source_model' => AlertDelivery::class,
'source_key' => (string) $delivery->getKey(),
'tenant_id' => $delivery->tenant ? (int) $delivery->tenant->getKey() : null,
'tenant_label' => $delivery->tenant?->name,
'headline' => $headline,
'subline' => $sublineParts === [] ? null : implode(' • ', $sublineParts),
'urgency_rank' => 0,
'status_label' => 'Failed',
'destination_url' => $this->appendQuery(
AlertDeliveryResource::getUrl('view', ['record' => $delivery], panel: 'admin'),
$navigationContext?->toQuery() ?? [],
),
'back_label' => $navigationContext?->backLinkLabel ?? 'Back to governance inbox',
];
}
/**
* @param array<string, mixed> $row
* @return array<string, mixed>
*/
private function reviewEntry(
Tenant $tenant,
string $family,
array $row,
mixed $latestPublishedReview,
?CanonicalNavigationContext $navigationContext,
): array {
$state = (string) ($row['derived_state'] ?? TenantTriageReview::DERIVED_STATE_NOT_REVIEWED);
$familyLabel = $family === PortfolioArrivalContextToken::FAMILY_BACKUP_HEALTH
? 'Backup health'
: 'Recovery evidence';
$headline = $state === TenantTriageReview::STATE_FOLLOW_UP_NEEDED
? $familyLabel.' needs review follow-up'
: $familyLabel.' changed since review';
$sublineParts = array_values(array_filter([
is_string($row['reviewed_by_user_name'] ?? null) && $row['reviewed_by_user_name'] !== ''
? 'Last review: '.$row['reviewed_by_user_name']
: null,
isset($row['reviewed_at']) && $row['reviewed_at'] !== null
? 'Reviewed '.optional($row['reviewed_at'])->toDateTimeString()
: null,
]));
$destinationUrl = $latestPublishedReview !== null
? TenantReviewResource::tenantScopedUrl('view', ['record' => $latestPublishedReview], $tenant, 'tenant')
: CustomerReviewWorkspace::tenantPrefilterUrl($tenant);
return [
'family_key' => 'review_follow_up',
'source_model' => TenantTriageReview::class,
'source_key' => (string) $tenant->getKey().':'.$family,
'tenant_id' => (int) $tenant->getKey(),
'tenant_label' => $tenant->name,
'headline' => $headline,
'subline' => $sublineParts === [] ? null : implode(' • ', $sublineParts),
'urgency_rank' => $state === TenantTriageReview::STATE_FOLLOW_UP_NEEDED ? 0 : 1,
'status_label' => $state === TenantTriageReview::STATE_FOLLOW_UP_NEEDED
? 'Follow-up needed'
: 'Changed since review',
'destination_url' => $this->appendQuery($destinationUrl, $navigationContext?->toQuery() ?? []),
'back_label' => $navigationContext?->backLinkLabel ?? 'Back to governance inbox',
];
}
private function assignedFindingsSummary(int $count, int $overdueCount): string
{
if ($count === 0) {
return 'No assigned findings are visible in the current scope.';
}
if ($overdueCount > 0) {
return sprintf(
'%d assigned finding%s remain open. %d %s overdue.',
$count,
$count === 1 ? '' : 's',
$overdueCount,
$overdueCount === 1 ? 'is' : 'are',
);
}
return sprintf(
'%d assigned finding%s remain open in the visible scope.',
$count,
$count === 1 ? '' : 's',
);
}
private function intakeFindingsSummary(int $count, int $needsTriageCount): string
{
if ($count === 0) {
return 'No intake findings are visible in the current scope.';
}
return sprintf(
'%d unassigned finding%s remain in intake. %d still need first triage.',
$count,
$count === 1 ? '' : 's',
$needsTriageCount,
);
}
private function operationsSummary(int $terminalCount, int $staleCount): string
{
if ($terminalCount + $staleCount === 0) {
return 'No stale or terminal follow-up operations are visible in the current scope.';
}
if ($terminalCount > 0 && $staleCount > 0) {
return sprintf(
'%d terminal follow-up operation%s and %d stale active run%s need monitoring attention.',
$terminalCount,
$terminalCount === 1 ? '' : 's',
$staleCount,
$staleCount === 1 ? '' : 's',
);
}
if ($terminalCount > 0) {
return sprintf(
'%d terminal follow-up operation%s need monitoring attention.',
$terminalCount,
$terminalCount === 1 ? '' : 's',
);
}
return sprintf(
'%d stale active run%s need monitoring attention.',
$staleCount,
$staleCount === 1 ? '' : 's',
);
}
private function alertsSummary(int $count): string
{
if ($count === 0) {
return 'No failed alert deliveries are visible in the current scope.';
}
return sprintf(
'%d failed alert delivery attempt%s remain visible in this workspace.',
$count,
$count === 1 ? '' : 's',
);
}
private function reviewSummary(int $followUpCount, int $changedCount): string
{
$total = $followUpCount + $changedCount;
if ($total === 0) {
return 'No review follow-up is visible in the current scope.';
}
return sprintf(
'%d review concern%s need attention. %d marked follow-up needed and %d changed since review.',
$total,
$total === 1 ? '' : 's',
$followUpCount,
$changedCount,
);
}
/**
* @param array<string, mixed> $query
*/
private function appendQuery(string $url, array $query): string
{
if ($query === []) {
return $url;
}
$separator = str_contains($url, '?') ? '&' : '?';
return $url.$separator.http_build_query($query);
}
}

View File

@ -4,10 +4,8 @@
namespace App\Support\Settings; namespace App\Support\Settings;
use App\Models\Finding;
use App\Services\Localization\LocaleResolver;
use App\Support\Ai\AiPolicyMode; use App\Support\Ai\AiPolicyMode;
use App\Services\Entitlements\WorkspaceCommercialLifecycleResolver; use App\Models\Finding;
use App\Services\Entitlements\WorkspacePlanProfileCatalog; use App\Services\Entitlements\WorkspacePlanProfileCatalog;
final class SettingsRegistry final class SettingsRegistry
@ -30,25 +28,6 @@ public function __construct()
normalizer: static fn (mixed $value): string => strtolower(trim((string) $value)), normalizer: static fn (mixed $value): string => strtolower(trim((string) $value)),
)); ));
$this->register(new SettingDefinition(
domain: LocaleResolver::SETTING_DOMAIN,
key: LocaleResolver::SETTING_DEFAULT_LOCALE,
type: 'string',
systemDefault: null,
rules: [
'nullable',
'string',
'in:'.implode(',', LocaleResolver::supportedLocales()),
],
normalizer: static function (mixed $value): ?string {
if ($value === null) {
return null;
}
return LocaleResolver::normalize($value);
},
));
$this->register(new SettingDefinition( $this->register(new SettingDefinition(
domain: 'backup', domain: 'backup',
key: 'retention_keep_last_default', key: 'retention_keep_last_default',
@ -335,44 +314,6 @@ static function (string $attribute, mixed $value, \Closure $fail): void {
return $normalized === '' ? null : $normalized; 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;
},
));
} }
/** /**

View File

@ -749,17 +749,12 @@ public static function spec195ResidualSurfaceInventory(): array
'discoveryState' => 'outside_primary_discovery', 'discoveryState' => 'outside_primary_discovery',
'closureDecision' => 'harmless_special_case', 'closureDecision' => 'harmless_special_case',
'reasonCategory' => 'read_mostly_context_detail', 'reasonCategory' => 'read_mostly_context_detail',
'explicitReason' => 'The workspace directory detail page is a read-mostly drilldown with one bounded, capability-gated commercial lifecycle mutation added by spec 251; it is still not a declaration-backed mutable system workbench.', 'explicitReason' => 'The workspace directory detail page is a read-mostly drilldown that exposes context and links, not a declaration-backed mutable system workbench.',
'evidence' => [ 'evidence' => [
[ [
'kind' => 'feature_livewire_test', 'kind' => 'feature_livewire_test',
'reference' => 'tests/Feature/System/Spec195/SystemDirectoryResidualSurfaceTest.php', 'reference' => 'tests/Feature/System/Spec195/SystemDirectoryResidualSurfaceTest.php',
'proves' => 'The workspace detail page stays capability-gated and renders contextual tenant and run links while remaining outside the primary declaration-backed table contract.', 'proves' => 'The workspace detail page stays capability-gated and renders contextual tenant and run links without mutating actions.',
],
[
'kind' => 'feature_livewire_test',
'reference' => 'tests/Feature/System/ViewWorkspaceEntitlementsTest.php',
'proves' => 'The commercial lifecycle mutation is separately capability-gated, confirmation-protected, rationale-required, and audited.',
], ],
[ [
'kind' => 'authorization_test', 'kind' => 'authorization_test',

View File

@ -8,8 +8,6 @@ final class WorkspaceResolver
{ {
public function resolve(string $value): ?Workspace public function resolve(string $value): ?Workspace
{ {
$value = $this->normalizeRouteValue($value);
$workspace = Workspace::query() $workspace = Workspace::query()
->where('slug', $value) ->where('slug', $value)
->first(); ->first();
@ -24,37 +22,4 @@ public function resolve(string $value): ?Workspace
return Workspace::query()->whereKey((int) $value)->first(); return Workspace::query()->whereKey((int) $value)->first();
} }
private function normalizeRouteValue(string $value): string
{
$value = trim($value);
if (! str_starts_with($value, '{')) {
return $value;
}
$decoded = json_decode($value, true);
if (! is_array($decoded)) {
return $value;
}
$slug = $decoded['slug'] ?? null;
if (is_string($slug) && $slug !== '') {
return $slug;
}
$id = $decoded['id'] ?? null;
if (is_int($id)) {
return (string) $id;
}
if (is_string($id) && ctype_digit($id)) {
return $id;
}
return $value;
}
} }

View File

@ -4,7 +4,6 @@
use Illuminate\Foundation\Configuration\Exceptions; use Illuminate\Foundation\Configuration\Exceptions;
use Illuminate\Foundation\Configuration\Middleware; use Illuminate\Foundation\Configuration\Middleware;
use App\Http\Middleware\ApplyResolvedLocale;
use App\Http\Middleware\SuppressDebugbarForSmokeRequests; use App\Http\Middleware\SuppressDebugbarForSmokeRequests;
use App\Http\Middleware\UseSystemSessionCookieForLivewireRequests; use App\Http\Middleware\UseSystemSessionCookieForLivewireRequests;
@ -25,12 +24,7 @@
UseSystemSessionCookieForLivewireRequests::class, UseSystemSessionCookieForLivewireRequests::class,
]); ]);
$middleware->web(append: [
ApplyResolvedLocale::class,
]);
$middleware->alias([ $middleware->alias([
'apply-resolved-locale' => ApplyResolvedLocale::class,
'ensure-correct-guard' => \App\Http\Middleware\EnsureCorrectGuard::class, 'ensure-correct-guard' => \App\Http\Middleware\EnsureCorrectGuard::class,
'ensure-platform-capability' => \App\Http\Middleware\EnsurePlatformCapability::class, 'ensure-platform-capability' => \App\Http\Middleware\EnsurePlatformCapability::class,
'ensure-workspace-member' => \App\Http\Middleware\EnsureWorkspaceMember::class, 'ensure-workspace-member' => \App\Http\Middleware\EnsureWorkspaceMember::class,

View File

@ -1,25 +0,0 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::table('users', function (Blueprint $table): void {
$table->string('preferred_locale', 8)
->nullable()
->after('last_workspace_id')
->index();
});
}
public function down(): void
{
Schema::table('users', function (Blueprint $table): void {
$table->dropColumn('preferred_locale');
});
}
};

View File

@ -1,88 +0,0 @@
<?php
declare(strict_types=1);
return [
'duplicate_warning_title' => 'Warnung',
'duplicate_warning_body_plural' => ':count Policies in diesem Tenant verwenden generische Anzeigenamen, dadurch entstehen :ambiguous_count mehrdeutige Subjekte. :app kann sie nicht sicher mit der Baseline abgleichen.',
'duplicate_warning_body_singular' => ':count Policy in diesem Tenant verwendet einen generischen Anzeigenamen, dadurch entsteht :ambiguous_count mehrdeutiges Subjekt. :app kann es nicht sicher mit der Baseline abgleichen.',
'stat_assigned_baseline' => 'Zugewiesene Baseline',
'stat_total_findings' => 'Findings gesamt',
'stat_last_compared' => 'Zuletzt verglichen',
'stat_last_compared_never' => 'Nie',
'stat_error' => 'Fehler',
'badge_snapshot' => 'Snapshot #:id',
'badge_coverage_ok' => 'Abdeckung: OK',
'badge_coverage_warnings' => 'Abdeckung: Warnungen',
'badge_fidelity' => 'Fidelity: :level',
'badge_evidence_gaps' => 'Evidence Gaps: :count',
'evidence_gaps_tooltip' => 'Wichtigste Gaps: :summary',
'evidence_gap_details_heading' => 'Evidence-Gap-Details',
'evidence_gap_details_description' => 'Durchsuchen Sie aufgezeichnete Gap-Subjekte nach Grund, Governed Subject, Subjektklasse, Ergebnis, nächster Aktion oder Subject Key, bevor Sie Rohdiagnosen verwenden.',
'evidence_gap_search_label' => 'Gap-Details suchen',
'evidence_gap_search_placeholder' => 'Nach Grund, Typ, Klasse, Ergebnis, Aktion oder Subject Key suchen',
'evidence_gap_search_help' => 'Filtert über Grund, Governed Subject, Subjektklasse, Ergebnis, nächste Aktion und Subject Key.',
'evidence_gap_bucket_help_ambiguous_match' => 'Mehrere Inventory-Datensätze passten zum gleichen Policy-Subjekt. Prüfen Sie das Mapping.',
'evidence_gap_bucket_help_policy_record_missing' => 'Der erwartete Policy-Datensatz wurde im Baseline-Snapshot nicht gefunden. Prüfen Sie, ob die Policy im Tenant noch existiert.',
'evidence_gap_bucket_help_inventory_record_missing' => 'Für diese Subjekte konnte kein Inventory-Datensatz gefunden werden. Prüfen Sie, ob der Inventory Sync aktuell ist.',
'evidence_gap_bucket_help_foundation_not_policy_backed' => 'Diese Subjekte existieren in der Foundation-Schicht, sind aber nicht durch eine verwaltete Policy abgedeckt. Prüfen Sie, ob eine Policy erstellt werden sollte.',
'evidence_gap_bucket_help_capture_failed' => 'Evidence Capture ist für diese Subjekte fehlgeschlagen. Wiederholen Sie den Vergleich oder prüfen Sie die Graph-Konnektivität.',
'evidence_gap_bucket_help_default' => 'Diese Subjekte wurden beim Vergleich markiert. Prüfen Sie die betroffenen Zeilen.',
'evidence_gap_reason' => 'Grund',
'evidence_gap_reason_affected' => ':count betroffen',
'evidence_gap_reason_recorded' => ':count aufgezeichnet',
'evidence_gap_reason_missing_detail' => ':count ohne Detail',
'evidence_gap_structural' => 'Strukturell: :count',
'evidence_gap_operational' => 'Operativ: :count',
'evidence_gap_transient' => 'Temporär: :count',
'evidence_gap_bucket_structural' => ':count strukturell',
'evidence_gap_bucket_operational' => ':count operativ',
'evidence_gap_bucket_transient' => ':count temporär',
'evidence_gap_missing_details_title' => 'Für diesen Run wurden keine Detailzeilen aufgezeichnet',
'evidence_gap_missing_details_body' => 'Evidence Gaps wurden für diesen Compare Run gezählt, aber Details auf Subjektebene wurden nicht gespeichert. Prüfen Sie Rohdiagnosen oder wiederholen Sie den Vergleich.',
'evidence_gap_missing_reason_body' => ':count betroffene Subjekte wurden für diesen Grund gezählt, aber Detailzeilen wurden nicht aufgezeichnet.',
'evidence_gap_legacy_title' => 'Legacy-Development-Gap-Payload erkannt',
'evidence_gap_legacy_body' => 'Dieser Run verwendet noch die retired breite Grundform. Erzeugen Sie den Run neu oder bereinigen Sie alte lokale Development-Payloads.',
'evidence_gap_diagnostics_heading' => 'Baseline-Compare-Evidence',
'evidence_gap_diagnostics_description' => 'Rohdiagnosen bleiben für Support und tiefere Fehlersuche nach Operator-Zusammenfassung und Detailansicht verfügbar.',
'evidence_gap_policy_type' => 'Governed Subject',
'evidence_gap_subject_class' => 'Subjektklasse',
'evidence_gap_outcome' => 'Ergebnis',
'evidence_gap_next_action' => 'Nächste Aktion',
'evidence_gap_subject_key' => 'Subject Key',
'evidence_gap_table_empty_heading' => 'Keine aufgezeichneten Gap-Zeilen passen zu dieser Ansicht',
'evidence_gap_table_empty_description' => 'Passen Sie Suche oder Filter an, um andere betroffene Subjekte zu prüfen.',
'comparing_indicator' => 'Vergleich läuft...',
'no_findings_all_clear' => 'Kein bestätigter Drift im letzten Vergleich',
'no_findings_coverage_warnings' => 'Kein Drift angezeigt, aber Coverage limitiert diesen Vergleich',
'no_findings_evidence_gaps' => 'Kein Drift angezeigt, aber Evidence Gaps müssen geprüft werden',
'no_findings_default' => 'Aktuell sind keine Drift Findings sichtbar',
'coverage_warning_title' => 'Vergleich mit Warnungen abgeschlossen',
'coverage_unproven_body' => 'Coverage Proof fehlte oder war nicht lesbar. Findings wurden aus Sicherheitsgründen unterdrückt.',
'coverage_incomplete_body' => 'Findings wurden für :count Policy :types wegen unvollständiger Coverage übersprungen.',
'coverage_uncovered_label' => 'Nicht abgedeckt: :list',
'failed_title' => 'Vergleich fehlgeschlagen',
'failed_body_default' => 'Der letzte Baseline-Vergleich ist fehlgeschlagen. Prüfen Sie die Run-Details oder wiederholen Sie ihn.',
'critical_drift_title' => 'Kritischer Drift erkannt',
'critical_drift_body' => 'Der aktuelle Tenant-Zustand weicht von Baseline :profile ab. :count High-Severity :findings erfordern sofortige Aufmerksamkeit.',
'empty_no_tenant' => 'Kein Tenant ausgewählt',
'empty_no_assignment' => 'Keine Baseline zugewiesen',
'empty_no_snapshot' => 'Kein Snapshot verfügbar',
'findings_description' => 'Die Tenant-Konfiguration weicht vom Baseline-Profil ab.',
'rbac_summary_title' => 'Intune-RBAC-Rollendefinitionen',
'rbac_summary_description' => 'Rollenzuweisungen sind in diesem Baseline-Compare-Release nicht enthalten.',
'rbac_summary_compared' => 'Verglichen',
'rbac_summary_unchanged' => 'Unverändert',
'rbac_summary_modified' => 'Geändert',
'rbac_summary_missing' => 'Fehlend',
'rbac_summary_unexpected' => 'Unerwartet',
'no_drift_title' => 'Kein Drift erkannt',
'no_drift_body' => 'Der letzte Vergleich hat keinen bestätigten Drift für das zugewiesene Baseline-Profil aufgezeichnet.',
'coverage_warnings_title' => 'Coverage-Warnungen',
'coverage_warnings_body' => 'Der letzte Vergleich wurde mit Warnungen abgeschlossen und erzeugte keine bestätigten Drift Findings. Aktualisieren Sie Evidence, bevor Sie dies als Entwarnung werten.',
'idle_title' => 'Bereit zum Vergleich',
'button_view_run' => 'Run anzeigen',
'button_view_failed_run' => 'Fehlgeschlagenen Run anzeigen',
'button_view_findings' => 'Alle Findings anzeigen',
'button_review_last_run' => 'Letzten Run prüfen',
];

View File

@ -1,31 +0,0 @@
<?php
declare(strict_types=1);
return [
'drift' => [
'rbac_role_definition' => 'Intune-RBAC-Rollendefinitions-Drift',
],
'subject_types' => [
'policy' => 'Policy',
'intuneRoleDefinition' => 'Intune-RBAC-Rollendefinition',
],
'rbac' => [
'detail_heading' => 'Intune-RBAC-Rollendefinitions-Drift',
'detail_subheading' => 'Rollenzuweisungen sind nicht enthalten. RBAC-Restore wird nicht unterstützt.',
'metadata_only' => 'Nur Metadaten geändert',
'permission_change' => 'Berechtigung geändert',
'missing' => 'Im aktuellen Tenant fehlend',
'unexpected' => 'Unerwartet im aktuellen Tenant',
'changed_fields' => 'Geänderte Felder',
'baseline' => 'Baseline',
'current' => 'Aktuell',
'absent' => 'Nicht vorhanden',
'role_source' => 'Rollenquelle',
'permission_blocks' => 'Berechtigungsblöcke',
'built_in' => 'Integriert',
'custom' => 'Benutzerdefiniert',
'assignments_excluded' => 'Rollenzuweisungen sind in diesem Baseline-Compare-Release nicht enthalten.',
'restore_unsupported' => 'RBAC-Restore wird in diesem Release nicht unterstützt.',
],
];

View File

@ -1,230 +0,0 @@
<?php
declare(strict_types=1);
return [
'locales' => [
'en' => 'Englisch',
'de' => 'Deutsch',
],
'source' => [
'explicit_override' => 'Sitzungsüberschreibung',
'user_preference' => 'persönliche Einstellung',
'workspace_default' => 'Workspace-Standard',
'workspace_override' => 'Workspace-Überschreibung',
'system_default' => 'Systemstandard',
],
'shell' => [
'language' => 'Sprache',
'current_language' => 'Aktuelle Sprache',
'language_source' => 'Quelle: :source',
'temporary_override' => 'Temporäre Überschreibung',
'switch_language' => 'Sprache wechseln',
'clear_override' => 'Geerbte Sprache verwenden',
'personal_preference' => 'Persönliche Einstellung',
'save_preference' => 'Einstellung speichern',
'inherit_workspace' => 'Workspace-Standard verwenden',
'workspace' => 'Workspace',
'choose_workspace' => 'Workspace auswählen',
'switch_workspace' => 'Workspace wechseln',
'workspace_home' => 'Workspace-Start',
'tenant_scope' => 'Tenant-Kontext',
'select_tenant' => 'Tenant auswählen',
'selected_tenant' => 'Ausgewählter Tenant',
'no_tenant_selected' => 'Kein Tenant ausgewählt',
'switch_tenant' => 'Tenant wechseln',
'clear_tenant_scope' => 'Tenant-Kontext löschen',
'context_unavailable' => 'Kontext nicht verfügbar',
'context_unavailable_workspace' => 'Der angeforderte Kontext konnte nicht wiederhergestellt werden. Die Shell zeigt stattdessen einen gültigen Workspace-Kontext.',
'context_unavailable_no_workspace' => 'Wählen Sie einen Workspace aus, um mit einem gültigen Admin-Kontext fortzufahren.',
'no_active_tenants' => 'In diesem Workspace sind keine aktiven Tenants für den Standardbetrieb verfügbar.',
'view_managed_tenants' => 'Managed Tenants anzeigen',
'workspace_wide_available' => 'Kein Tenant ausgewählt. Workspace-weite Seiten bleiben verfügbar; ein Tenant setzt nur den normalen aktiven Betriebskontext.',
'search_tenants' => 'Tenants suchen...',
'choose_workspace_first' => 'Wählen Sie zuerst einen Workspace aus.',
],
'workspace' => [
'title' => 'Workspace-Einstellungen',
'save' => 'Speichern',
'reset' => 'Zurücksetzen',
'no_manage_permission' => 'Sie haben keine Berechtigung zum Verwalten der Workspace-Einstellungen.',
'no_workspace_override' => 'Keine Workspace-Überschreibung zum Zurücksetzen vorhanden.',
'last_modified_by' => ':description - Zuletzt geändert von :user, :time.',
'section' => 'Lokalisierung',
'section_description' => 'Workspace-Standard für Benutzer ohne persönliche Spracheinstellung.',
'default_locale_label' => 'Standardsprache',
'default_locale_placeholder' => 'Nicht gesetzt (verwendet Systemstandard)',
'default_locale_helper_unset' => 'Nicht gesetzt. Effektive Sprache: :locale (:source).',
'default_locale_helper_set' => 'Effektive Sprache: :locale.',
],
'auth' => [
'microsoft_not_configured' => 'Microsoft-Anmeldung ist nicht konfiguriert.',
'sign_in_microsoft' => 'Mit Microsoft anmelden',
'tenant_admin_membership_required' => 'Tenant-Admin-Zugriff erfordert eine Tenant-Mitgliedschaft.',
],
'navigation' => [
'findings' => 'Findings',
'settings' => 'Einstellungen',
'integrations' => 'Integrationen',
'manage_workspaces' => 'Workspaces verwalten',
'operations' => 'Operationen',
'audit_log' => 'Audit-Log',
'alerts' => 'Alerts',
'governance' => 'Governance',
'monitoring' => 'Monitoring',
'dashboard' => 'Dashboard',
],
'dashboard' => [
'tenant_title' => 'Tenant-Dashboard',
'system_title' => 'System-Dashboard',
'request_support' => 'Support anfragen',
'support_request_heading' => 'Support anfragen',
'support_request_description' => 'Teilen Sie eine kurze Zusammenfassung. TenantAtlas fügt redaktionell bereinigten Kontext aus bestehenden Datensätzen hinzu.',
'submit_request' => 'Anfrage senden',
'included_context' => 'Enthaltener Kontext',
'severity' => 'Schweregrad',
'summary' => 'Zusammenfassung',
'reproduction_notes' => 'Reproduktionshinweise',
'contact_name' => 'Kontaktname',
'contact_email' => 'Kontakt-E-Mail',
'support_request_submitted' => 'Supportanfrage gesendet',
'open_support_diagnostics' => 'Supportdiagnosen öffnen',
'support_diagnostics' => 'Supportdiagnosen',
'support_diagnostics_description' => 'Redaktionell bereinigter Tenant-Kontext aus bestehenden Datensätzen.',
'close' => 'Schließen',
'time_window' => 'Zeitfenster',
'window' => 'Fenster',
'enter_break_glass' => 'Break-Glass-Modus aktivieren',
'exit_break_glass' => 'Break-Glass beenden',
'recovery_mode_enabled' => 'Wiederherstellungsmodus aktiviert',
'recovery_mode_ended' => 'Wiederherstellungsmodus beendet',
],
'review' => [
'reporting' => 'Berichte',
'customer_reviews' => 'Kundenreviews',
'customer_review_workspace' => 'Kundenreview-Workspace',
'customer_safe_review_workspace' => 'Kundensicherer Review-Workspace',
'customer_workspace_intro' => 'Prüfen Sie den zuletzt veröffentlichten kundensicheren Status für jeden berechtigten Tenant, ohne den aktuellen Workspace-Kontext zu verlassen.',
'customer_workspace_canonical_note' => 'Eine Zeile öffnet die bestehende Tenant-Review-Detailseite, damit Evidence, Review-Packs und auditfähige Nachweise auf ihren kanonischen tenantbezogenen Oberflächen bleiben.',
'reviews' => 'Reviews',
'clear_filters' => 'Filter löschen',
'tenant' => 'Tenant',
'latest_review' => 'Letztes Review',
'key_findings' => 'Wichtige Findings',
'accepted_risks' => 'Akzeptierte Risiken',
'published' => 'Veröffentlicht',
'review_pack' => 'Review-Pack',
'open_latest_review' => 'Letztes Review öffnen',
'download_review_pack' => 'Review-Pack herunterladen',
'no_entitled_tenants' => 'Keine berechtigten Tenants passen zu dieser Ansicht',
'clear_filters_description' => 'Löschen Sie die aktuellen Filter, um zum vollständigen Kundenreview-Workspace für Ihre berechtigten Tenants zurückzukehren.',
'adjust_filters_description' => 'Passen Sie die Filter an, um zum vollständigen Kundenreview-Workspace für Ihre berechtigten Tenants zurückzukehren.',
'no_published_review' => 'Kein veröffentlichtes Review',
'no_published_review_available' => 'Noch kein veröffentlichtes Review verfügbar',
'no_findings_recorded' => 'Im veröffentlichten Review sind keine Findings erfasst.',
'findings_count_summary' => ':count Findings im veröffentlichten Review zusammengefasst.',
'findings_count_with_outcomes' => ':count Findings. Terminale Ergebnisse: :outcomes.',
'no_accepted_risks_recorded' => 'Keine akzeptierten Risiken erfasst.',
'accepted_risks_need_follow_up' => ':warnings akzeptierte Risiken benötigen Governance-Nacharbeit (:total gesamt).',
'accepted_risks_governed' => ':count akzeptierte Risiken sind governed.',
'accepted_risks_on_record' => ':count akzeptierte Risiken sind erfasst.',
'unavailable' => 'Nicht verfügbar',
'available' => 'Verfügbar',
'outcome_summary' => 'Ergebniszusammenfassung',
'review' => 'Review',
'review_date' => 'Review-Datum',
'completeness' => 'Vollständigkeit',
'evidence_snapshot' => 'Evidence-Snapshot',
'current_export' => 'Aktueller Export',
'executive_posture' => 'Executive-Status',
'sections' => 'Abschnitte',
'details' => 'Details',
'export_executive_pack' => 'Executive-Pack exportieren',
'outcome' => 'Ergebnis',
'export' => 'Export',
'next_step' => 'Nächster Schritt',
'no_tenant_reviews_yet' => 'Noch keine Tenant-Reviews',
'create_first_review_description' => 'Erstellen Sie das erste Review aus einem verankerten Evidence-Snapshot, um die wiederkehrende Review-Historie für diesen Tenant zu starten.',
'create_first_review' => 'Erstes Review erstellen',
'create_review' => 'Review erstellen',
'evidence_basis' => 'Evidence-Basis',
'evidence_basis_helper' => 'Wählen Sie den verankerten Evidence-Snapshot für dieses Review.',
'unable_create_missing_context' => 'Review kann nicht erstellt werden - Kontext fehlt.',
'select_valid_evidence_snapshot' => 'Wählen Sie einen gültigen Evidence-Snapshot aus.',
'unable_create_review' => 'Review kann nicht erstellt werden',
'review_already_available' => 'Review bereits verfügbar',
'review_already_available_body' => 'Ein passendes veränderbares Review ist für diese Evidence-Basis bereits vorhanden.',
'view_review' => 'Review anzeigen',
'open_operation' => 'Operation öffnen',
'review_composing_background' => 'Das Review wird im Hintergrund zusammengestellt.',
'unable_export_missing_context' => 'Review kann nicht exportiert werden - Kontext fehlt.',
'export_already_queued_body' => 'Ein Executive-Pack-Export ist für dieses Review bereits eingereiht oder läuft.',
'executive_pack_export_unavailable' => 'Executive-Pack-Export nicht verfügbar',
'unable_export_executive_pack' => 'Executive-Pack kann nicht exportiert werden',
'executive_pack_already_available' => 'Executive-Pack bereits verfügbar',
'executive_pack_already_available_body' => 'Ein passendes Executive-Pack ist für dieses Review bereits vorhanden.',
'view_pack' => 'Pack anzeigen',
'executive_pack_generating_background' => 'Das Executive-Pack wird im Hintergrund erstellt.',
'review_explanation' => 'Review-Erklärung',
'reason_owner' => 'Reason Owner',
'platform_core' => 'Platform Core',
'platform_reason_family' => 'Platform-Reason-Familie',
'compatibility' => 'Kompatibilität',
'highlights' => 'Highlights',
'next_actions' => 'Nächste Aktionen',
'related_context' => 'Verwandter Kontext',
'publication_readiness' => 'Veröffentlichungsreife',
'ready_for_publication' => 'Dieses Review ist bereit für Veröffentlichung und Executive-Pack-Export.',
'internal_only' => 'Dieses Review ist aktuell nur für interne Nutzung geeignet.',
'needs_follow_up' => 'Dieses Review benötigt vor der Veröffentlichung noch Nacharbeit.',
'key_entries' => 'Wichtige Einträge',
'entry' => 'Eintrag',
'follow_up' => 'Follow-up',
'diagnostics' => 'Diagnosen',
'result_meaning' => 'Ergebnisbedeutung',
'result_trust' => 'Ergebnisvertrauen',
'artifact_truth' => 'Artifact Truth',
'no_action_needed' => 'Keine Aktion erforderlich',
'count' => 'Anzahl',
'guidance' => 'Orientierung',
'findings' => 'Findings',
'reports' => 'Berichte',
'operations' => 'Operationen',
'pending_verification' => 'Verifizierung ausstehend',
'verified_cleared' => 'Verifiziert bereinigt',
'terminal_outcomes' => 'Terminale Ergebnisse',
'pending' => 'Ausstehend',
'operation' => 'Operation',
'operation_description' => 'Prüfen Sie die letzte Review-Zusammenstellung oder den Aktualisierungslauf.',
'executive_pack' => 'Executive-Pack',
'view_executive_pack' => 'Executive-Pack anzeigen',
'executive_pack_description' => 'Öffnet den aktuellen Export, der zu diesem Review gehört.',
'customer_workspace' => 'Kunden-Workspace',
'open_customer_workspace' => 'Kunden-Workspace öffnen',
'customer_workspace_description' => 'Öffnet den kundensicheren Review-Workspace mit Filter auf diesen Tenant.',
'view_evidence_snapshot' => 'Evidence-Snapshot anzeigen',
'evidence_snapshot_description' => 'Zur Evidence-Basis hinter diesem Review zurückkehren.',
],
'findings' => [
'all' => 'Alle',
'needs_action' => 'Handlungsbedarf',
'overdue' => 'Überfällig',
'risk_accepted' => 'Risiko akzeptiert',
'resolved' => 'Gelöst',
'actions' => 'Aktionen',
'open_approval_queue' => 'Freigabewarteschlange öffnen',
],
'notifications' => [
'locale_override_saved' => 'Sprachüberschreibung angewendet.',
'locale_override_cleared' => 'Sprachüberschreibung gelöscht.',
'user_preference_saved' => 'Spracheinstellung gespeichert.',
'user_preference_cleared' => 'Spracheinstellung gelöscht.',
'workspace_settings_saved' => 'Workspace-Einstellungen gespeichert',
'workspace_settings_unchanged' => 'Keine Einstellungsänderungen zu speichern',
'workspace_setting_reset' => 'Workspace-Einstellung auf Standard zurückgesetzt',
'setting_already_default' => 'Einstellung verwendet bereits den Standard',
],
'validation' => [
'unsupported_locale' => 'Wählen Sie eine unterstützte Sprache.',
],
];

View File

@ -1,230 +0,0 @@
<?php
declare(strict_types=1);
return [
'locales' => [
'en' => 'English',
'de' => 'German',
],
'source' => [
'explicit_override' => 'session override',
'user_preference' => 'personal preference',
'workspace_default' => 'workspace default',
'workspace_override' => 'workspace override',
'system_default' => 'system default',
],
'shell' => [
'language' => 'Language',
'current_language' => 'Current language',
'language_source' => 'Source: :source',
'temporary_override' => 'Temporary override',
'switch_language' => 'Switch language',
'clear_override' => 'Use inherited language',
'personal_preference' => 'Personal preference',
'save_preference' => 'Save preference',
'inherit_workspace' => 'Use workspace default',
'workspace' => 'Workspace',
'choose_workspace' => 'Choose workspace',
'switch_workspace' => 'Switch workspace',
'workspace_home' => 'Workspace Home',
'tenant_scope' => 'Tenant scope',
'select_tenant' => 'Select tenant',
'selected_tenant' => 'Selected tenant',
'no_tenant_selected' => 'No tenant selected',
'switch_tenant' => 'Switch tenant',
'clear_tenant_scope' => 'Clear tenant scope',
'context_unavailable' => 'Context unavailable',
'context_unavailable_workspace' => 'The requested scope could not be restored. The shell is showing a valid workspace state instead.',
'context_unavailable_no_workspace' => 'Choose a workspace to continue with a valid admin context.',
'no_active_tenants' => 'No active tenants are available for the standard operating context in this workspace.',
'view_managed_tenants' => 'View managed tenants',
'workspace_wide_available' => 'No tenant selected. Workspace-wide pages remain available, and choosing a tenant only sets the normal active operating context.',
'search_tenants' => 'Search tenants...',
'choose_workspace_first' => 'Choose a workspace first.',
],
'workspace' => [
'title' => 'Workspace settings',
'save' => 'Save',
'reset' => 'Reset',
'no_manage_permission' => 'You do not have permission to manage workspace settings.',
'no_workspace_override' => 'No workspace override to reset.',
'last_modified_by' => ':description - Last modified by :user, :time.',
'section' => 'Localization settings',
'section_description' => 'Workspace default used by users without a personal language preference.',
'default_locale_label' => 'Default language',
'default_locale_placeholder' => 'Unset (uses system default)',
'default_locale_helper_unset' => 'Unset. Effective language: :locale (:source).',
'default_locale_helper_set' => 'Effective language: :locale.',
],
'auth' => [
'microsoft_not_configured' => 'Microsoft sign-in is not configured.',
'sign_in_microsoft' => 'Sign in with Microsoft',
'tenant_admin_membership_required' => 'Tenant Admin access requires a tenant membership.',
],
'navigation' => [
'findings' => 'Findings',
'settings' => 'Settings',
'integrations' => 'Integrations',
'manage_workspaces' => 'Manage workspaces',
'operations' => 'Operations',
'audit_log' => 'Audit Log',
'alerts' => 'Alerts',
'governance' => 'Governance',
'monitoring' => 'Monitoring',
'dashboard' => 'Dashboard',
],
'dashboard' => [
'tenant_title' => 'Tenant dashboard',
'system_title' => 'System dashboard',
'request_support' => 'Request support',
'support_request_heading' => 'Request support',
'support_request_description' => 'Share a concise summary and TenantAtlas will attach redacted context from existing records.',
'submit_request' => 'Submit request',
'included_context' => 'Included context',
'severity' => 'Severity',
'summary' => 'Summary',
'reproduction_notes' => 'Reproduction notes',
'contact_name' => 'Contact name',
'contact_email' => 'Contact email',
'support_request_submitted' => 'Support request submitted',
'open_support_diagnostics' => 'Open support diagnostics',
'support_diagnostics' => 'Support diagnostics',
'support_diagnostics_description' => 'Redacted tenant context from existing records.',
'close' => 'Close',
'time_window' => 'Time window',
'window' => 'Window',
'enter_break_glass' => 'Enter break-glass mode',
'exit_break_glass' => 'Exit break-glass',
'recovery_mode_enabled' => 'Recovery mode enabled',
'recovery_mode_ended' => 'Recovery mode ended',
],
'review' => [
'reporting' => 'Reporting',
'customer_reviews' => 'Customer reviews',
'customer_review_workspace' => 'Customer Review Workspace',
'customer_safe_review_workspace' => 'Customer-safe review workspace',
'customer_workspace_intro' => 'Review the latest published customer-safe posture for each entitled tenant without leaving the current workspace context.',
'customer_workspace_canonical_note' => '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.',
'reviews' => 'Reviews',
'clear_filters' => 'Clear filters',
'tenant' => 'Tenant',
'latest_review' => 'Latest review',
'key_findings' => 'Key findings',
'accepted_risks' => 'Accepted risks',
'published' => 'Published',
'review_pack' => 'Review pack',
'open_latest_review' => 'Open latest review',
'download_review_pack' => 'Download review pack',
'no_entitled_tenants' => 'No entitled tenants match this view',
'clear_filters_description' => 'Clear the current filters to return to the full customer review workspace for your entitled tenants.',
'adjust_filters_description' => 'Adjust filters to return to the full customer review workspace for your entitled tenants.',
'no_published_review' => 'No published review',
'no_published_review_available' => 'No published review available yet',
'no_findings_recorded' => 'No findings recorded in the published review.',
'findings_count_summary' => ':count findings summarized in the published review.',
'findings_count_with_outcomes' => ':count findings. Terminal outcomes: :outcomes.',
'no_accepted_risks_recorded' => 'No accepted risks recorded.',
'accepted_risks_need_follow_up' => ':warnings accepted risks need governance follow-up (:total total).',
'accepted_risks_governed' => ':count accepted risks are governed.',
'accepted_risks_on_record' => ':count accepted risks are on record.',
'unavailable' => 'Unavailable',
'available' => 'Available',
'outcome_summary' => 'Outcome summary',
'review' => 'Review',
'review_date' => 'Review date',
'completeness' => 'Completeness',
'evidence_snapshot' => 'Evidence snapshot',
'current_export' => 'Current export',
'executive_posture' => 'Executive posture',
'sections' => 'Sections',
'details' => 'Details',
'export_executive_pack' => 'Export executive pack',
'outcome' => 'Outcome',
'export' => 'Export',
'next_step' => 'Next step',
'no_tenant_reviews_yet' => 'No tenant reviews yet',
'create_first_review_description' => 'Create the first review from an anchored evidence snapshot to start the recurring review history for this tenant.',
'create_first_review' => 'Create first review',
'create_review' => 'Create review',
'evidence_basis' => 'Evidence basis',
'evidence_basis_helper' => 'Choose the anchored evidence snapshot for this review.',
'unable_create_missing_context' => 'Unable to create review - missing context.',
'select_valid_evidence_snapshot' => 'Select a valid evidence snapshot.',
'unable_create_review' => 'Unable to create review',
'review_already_available' => 'Review already available',
'review_already_available_body' => 'A matching mutable review already exists for this evidence basis.',
'view_review' => 'View review',
'open_operation' => 'Open operation',
'review_composing_background' => 'The review is being composed in the background.',
'unable_export_missing_context' => 'Unable to export review - missing context.',
'export_already_queued_body' => 'An executive pack export is already queued or running for this review.',
'executive_pack_export_unavailable' => 'Executive pack export unavailable',
'unable_export_executive_pack' => 'Unable to export executive pack',
'executive_pack_already_available' => 'Executive pack already available',
'executive_pack_already_available_body' => 'A matching executive pack already exists for this review.',
'view_pack' => 'View pack',
'executive_pack_generating_background' => 'The executive pack is being generated in the background.',
'review_explanation' => 'Review explanation',
'reason_owner' => 'Reason owner',
'platform_core' => 'Platform core',
'platform_reason_family' => 'Platform reason family',
'compatibility' => 'Compatibility',
'highlights' => 'Highlights',
'next_actions' => 'Next actions',
'related_context' => 'Related context',
'publication_readiness' => 'Publication readiness',
'ready_for_publication' => 'This review is ready for publication and executive-pack export.',
'internal_only' => 'This review is currently safe for internal use only.',
'needs_follow_up' => 'This review still needs follow-up before publication.',
'key_entries' => 'Key entries',
'entry' => 'Entry',
'follow_up' => 'Follow-up',
'diagnostics' => 'Diagnostics',
'result_meaning' => 'Result meaning',
'result_trust' => 'Result trust',
'artifact_truth' => 'Artifact truth',
'no_action_needed' => 'No action needed',
'count' => 'Count',
'guidance' => 'Guidance',
'findings' => 'Findings',
'reports' => 'Reports',
'operations' => 'Operations',
'pending_verification' => 'Pending verification',
'verified_cleared' => 'Verified cleared',
'terminal_outcomes' => 'Terminal outcomes',
'pending' => 'Pending',
'operation' => 'Operation',
'operation_description' => 'Inspect the latest review composition or refresh run.',
'executive_pack' => 'Executive pack',
'view_executive_pack' => 'View executive pack',
'executive_pack_description' => 'Open the current export that belongs to this review.',
'customer_workspace' => 'Customer workspace',
'open_customer_workspace' => 'Open customer workspace',
'customer_workspace_description' => 'Open the customer-safe review workspace prefiltered to this tenant.',
'view_evidence_snapshot' => 'View evidence snapshot',
'evidence_snapshot_description' => 'Return to the evidence basis behind this review.',
],
'findings' => [
'all' => 'All',
'needs_action' => 'Needs action',
'overdue' => 'Overdue',
'risk_accepted' => 'Risk accepted',
'resolved' => 'Resolved',
'actions' => 'Actions',
'open_approval_queue' => 'Open approval queue',
],
'notifications' => [
'locale_override_saved' => 'Language override applied.',
'locale_override_cleared' => 'Language override cleared.',
'user_preference_saved' => 'Language preference saved.',
'user_preference_cleared' => 'Language preference cleared.',
'workspace_settings_saved' => 'Workspace settings saved',
'workspace_settings_unchanged' => 'No settings changes to save',
'workspace_setting_reset' => 'Workspace setting reset to default',
'setting_already_default' => 'Setting already uses default',
],
'validation' => [
'unsupported_locale' => 'Choose a supported language.',
],
];

View File

@ -37,7 +37,7 @@
$compressedOutcome['primaryLabel'] ?? null, $compressedOutcome['primaryLabel'] ?? null,
$state['primaryLabel'] ?? null, $state['primaryLabel'] ?? null,
$operatorExplanation['headline'] ?? null, $operatorExplanation['headline'] ?? null,
__('localization.review.artifact_truth'), 'Artifact truth',
]); ]);
$primaryReason = $firstArtifactTruthText([ $primaryReason = $firstArtifactTruthText([
$compressedOutcome['primaryReason'] ?? null, $compressedOutcome['primaryReason'] ?? null,
@ -49,7 +49,7 @@
$compressedOutcome['nextActionText'] ?? null, $compressedOutcome['nextActionText'] ?? null,
data_get($operatorExplanation, 'nextAction.text'), data_get($operatorExplanation, 'nextAction.text'),
$state['nextActionLabel'] ?? null, $state['nextActionLabel'] ?? null,
__('localization.review.no_action_needed'), 'No action needed',
]); ]);
$diagnosticsSummary = $firstArtifactTruthText([ $diagnosticsSummary = $firstArtifactTruthText([
$compressedOutcome['diagnosticsSummary'] ?? null, $compressedOutcome['diagnosticsSummary'] ?? null,
@ -81,7 +81,7 @@
if ($evaluationSpec && $evaluationSpec->label !== 'Unknown') { if ($evaluationSpec && $evaluationSpec->label !== 'Unknown') {
$summaryFacts->push([ $summaryFacts->push([
'label' => __('localization.review.result_meaning'), 'label' => 'Result meaning',
'value' => $evaluationSpec->label, 'value' => $evaluationSpec->label,
'badge' => BadgeCatalog::summaryData($evaluationSpec), 'badge' => BadgeCatalog::summaryData($evaluationSpec),
]); ]);
@ -89,7 +89,7 @@
if ($trustSpec && $trustSpec->label !== 'Unknown') { if ($trustSpec && $trustSpec->label !== 'Unknown') {
$summaryFacts->push([ $summaryFacts->push([
'label' => __('localization.review.result_trust'), 'label' => 'Result trust',
'value' => $trustSpec->label, 'value' => $trustSpec->label,
'badge' => BadgeCatalog::summaryData($trustSpec), 'badge' => BadgeCatalog::summaryData($trustSpec),
]); ]);
@ -133,7 +133,7 @@
<div class="rounded-xl border border-gray-200 bg-white p-4 shadow-sm dark:border-gray-800 dark:bg-gray-900"> <div class="rounded-xl border border-gray-200 bg-white p-4 shadow-sm dark:border-gray-800 dark:bg-gray-900">
<div class="text-xs font-semibold uppercase tracking-[0.16em] text-gray-500 dark:text-gray-400"> <div class="text-xs font-semibold uppercase tracking-[0.16em] text-gray-500 dark:text-gray-400">
{{ __('localization.review.diagnostics') }} Diagnostics
</div> </div>
<div class="mt-3 space-y-2"> <div class="mt-3 space-y-2">
@ -164,7 +164,7 @@
<div class="rounded-md border border-gray-100 bg-gray-50 px-3 py-2 dark:border-gray-800 dark:bg-gray-950/60"> <div class="rounded-md border border-gray-100 bg-gray-50 px-3 py-2 dark:border-gray-800 dark:bg-gray-950/60">
<div class="text-[11px] font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400"> <div class="text-[11px] font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">
{{ $count['label'] ?? __('localization.review.count') }} {{ $count['label'] ?? 'Count' }}
</div> </div>
<div class="mt-1 text-lg font-semibold text-gray-900 dark:text-gray-100"> <div class="mt-1 text-lg font-semibold text-gray-900 dark:text-gray-100">
{{ (int) ($count['value'] ?? 0) }} {{ (int) ($count['value'] ?? 0) }}
@ -211,7 +211,7 @@
<dl class="grid gap-3 sm:grid-cols-2 xl:grid-cols-4"> <dl class="grid gap-3 sm:grid-cols-2 xl:grid-cols-4">
<div class="rounded-md border border-gray-100 bg-gray-50 px-3 py-2 dark:border-gray-800 dark:bg-gray-950/60"> <div class="rounded-md border border-gray-100 bg-gray-50 px-3 py-2 dark:border-gray-800 dark:bg-gray-950/60">
<dt class="text-[11px] font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">{{ __('localization.review.next_step') }}</dt> <dt class="text-[11px] font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">Next step</dt>
<dd class="mt-1 text-sm text-gray-900 dark:text-gray-100"> <dd class="mt-1 text-sm text-gray-900 dark:text-gray-100">
{{ $nextActionText }} {{ $nextActionText }}
</dd> </dd>
@ -237,7 +237,7 @@
@if ($nextSteps !== []) @if ($nextSteps !== [])
<div class="space-y-2"> <div class="space-y-2">
<div class="text-xs font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">{{ __('localization.review.guidance') }}</div> <div class="text-xs font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">Guidance</div>
<ul class="space-y-1 text-sm text-gray-700 dark:text-gray-300"> <ul class="space-y-1 text-sm text-gray-700 dark:text-gray-300">
@foreach ($nextSteps as $step) @foreach ($nextSteps as $step)
@continue(! is_string($step) || trim($step) === '') @continue(! is_string($step) || trim($step) === '')

View File

@ -42,14 +42,14 @@
@if ($entries !== []) @if ($entries !== [])
<div class="space-y-2"> <div class="space-y-2">
<div class="text-xs font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">{{ __('localization.review.key_entries') }}</div> <div class="text-xs font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">Key entries</div>
<div class="space-y-2"> <div class="space-y-2">
@foreach ($entries as $entry) @foreach ($entries as $entry)
@continue(! is_array($entry)) @continue(! is_array($entry))
<div class="rounded-md border border-gray-100 bg-gray-50 px-3 py-3 text-sm dark:border-gray-800 dark:bg-gray-950/60"> <div class="rounded-md border border-gray-100 bg-gray-50 px-3 py-3 text-sm dark:border-gray-800 dark:bg-gray-950/60">
<div class="font-medium text-gray-900 dark:text-gray-100"> <div class="font-medium text-gray-900 dark:text-gray-100">
{{ $entry['title'] ?? $entry['displayName'] ?? $entry['type'] ?? __('localization.review.entry') }} {{ $entry['title'] ?? $entry['displayName'] ?? $entry['type'] ?? 'Entry' }}
</div> </div>
@php @php
@ -82,7 +82,7 @@
@if ($nextActions !== []) @if ($nextActions !== [])
<div class="space-y-2"> <div class="space-y-2">
<div class="text-xs font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">{{ __('localization.review.follow_up') }}</div> <div class="text-xs font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">Follow-up</div>
<ul class="space-y-1 text-sm text-gray-700 dark:text-gray-300"> <ul class="space-y-1 text-sm text-gray-700 dark:text-gray-300">
@foreach ($nextActions as $action) @foreach ($nextActions as $action)
@continue(! is_string($action) || trim($action) === '') @continue(! is_string($action) || trim($action) === '')

View File

@ -25,7 +25,7 @@
@if ($operatorExplanation !== []) @if ($operatorExplanation !== [])
<div class="rounded-lg border border-gray-200 bg-white px-4 py-3 shadow-sm dark:border-gray-800 dark:bg-gray-900/70"> <div class="rounded-lg border border-gray-200 bg-white px-4 py-3 shadow-sm dark:border-gray-800 dark:bg-gray-900/70">
<div class="text-sm font-semibold text-gray-950 dark:text-white"> <div class="text-sm font-semibold text-gray-950 dark:text-white">
{{ $operatorExplanation['headline'] ?? __('localization.review.review_explanation') }} {{ $operatorExplanation['headline'] ?? 'Review explanation' }}
</div> </div>
@if (filled($operatorExplanation['reliabilityStatement'] ?? null)) @if (filled($operatorExplanation['reliabilityStatement'] ?? null))
@ -45,13 +45,13 @@
@if ($reasonSemantics !== []) @if ($reasonSemantics !== [])
<dl class="grid grid-cols-1 gap-3 md:grid-cols-2"> <dl class="grid grid-cols-1 gap-3 md:grid-cols-2">
<div class="rounded-md border border-gray-100 bg-gray-50 px-3 py-2 dark:border-gray-800 dark:bg-gray-950/60"> <div class="rounded-md border border-gray-100 bg-gray-50 px-3 py-2 dark:border-gray-800 dark:bg-gray-950/60">
<dt class="text-[11px] font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">{{ __('localization.review.reason_owner') }}</dt> <dt class="text-[11px] font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">Reason owner</dt>
<dd class="mt-1 text-sm text-gray-900 dark:text-gray-100">{{ $reasonSemantics['owner_label'] ?? __('localization.review.platform_core') }}</dd> <dd class="mt-1 text-sm text-gray-900 dark:text-gray-100">{{ $reasonSemantics['owner_label'] ?? 'Platform core' }}</dd>
</div> </div>
<div class="rounded-md border border-gray-100 bg-gray-50 px-3 py-2 dark:border-gray-800 dark:bg-gray-950/60"> <div class="rounded-md border border-gray-100 bg-gray-50 px-3 py-2 dark:border-gray-800 dark:bg-gray-950/60">
<dt class="text-[11px] font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">{{ __('localization.review.platform_reason_family') }}</dt> <dt class="text-[11px] font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">Platform reason family</dt>
<dd class="mt-1 text-sm text-gray-900 dark:text-gray-100">{{ $reasonSemantics['family_label'] ?? __('localization.review.compatibility') }}</dd> <dd class="mt-1 text-sm text-gray-900 dark:text-gray-100">{{ $reasonSemantics['family_label'] ?? 'Compatibility' }}</dd>
</div> </div>
</dl> </dl>
@endif @endif
@ -74,7 +74,7 @@
@if ($highlights !== []) @if ($highlights !== [])
<div class="space-y-2"> <div class="space-y-2">
<div class="text-xs font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">{{ __('localization.review.highlights') }}</div> <div class="text-xs font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">Highlights</div>
<ul class="space-y-1 text-sm text-gray-700 dark:text-gray-300"> <ul class="space-y-1 text-sm text-gray-700 dark:text-gray-300">
@foreach ($highlights as $highlight) @foreach ($highlights as $highlight)
@continue(! is_string($highlight) || trim($highlight) === '') @continue(! is_string($highlight) || trim($highlight) === '')
@ -87,7 +87,7 @@
@if ($nextActions !== []) @if ($nextActions !== [])
<div class="space-y-2"> <div class="space-y-2">
<div class="text-xs font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">{{ __('localization.review.next_actions') }}</div> <div class="text-xs font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">Next actions</div>
<ul class="space-y-1 text-sm text-gray-700 dark:text-gray-300"> <ul class="space-y-1 text-sm text-gray-700 dark:text-gray-300">
@foreach ($nextActions as $action) @foreach ($nextActions as $action)
@continue(! is_string($action) || trim($action) === '') @continue(! is_string($action) || trim($action) === '')
@ -100,7 +100,7 @@
@if ($contextLinks !== []) @if ($contextLinks !== [])
<div class="space-y-2"> <div class="space-y-2">
<div class="text-xs font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">{{ __('localization.review.related_context') }}</div> <div class="text-xs font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">Related context</div>
<div class="grid gap-3 md:grid-cols-3"> <div class="grid gap-3 md:grid-cols-3">
@foreach ($contextLinks as $link) @foreach ($contextLinks as $link)
@php @php
@ -130,11 +130,11 @@
@endif @endif
<div class="space-y-2"> <div class="space-y-2">
<div class="text-xs font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">{{ __('localization.review.publication_readiness') }}</div> <div class="text-xs font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">Publication readiness</div>
@if ($publishBlockers === [] && $decisionDirection === 'publishable') @if ($publishBlockers === [] && $decisionDirection === 'publishable')
<div class="rounded-md border border-emerald-100 bg-emerald-50 px-3 py-2 text-sm text-emerald-800 dark:border-emerald-900/40 dark:bg-emerald-950/30 dark:text-emerald-200"> <div class="rounded-md border border-emerald-100 bg-emerald-50 px-3 py-2 text-sm text-emerald-800 dark:border-emerald-900/40 dark:bg-emerald-950/30 dark:text-emerald-200">
{{ __('localization.review.ready_for_publication') }} This review is ready for publication and executive-pack export.
</div> </div>
@elseif ($publishBlockers !== []) @elseif ($publishBlockers !== [])
<ul class="space-y-1 text-sm text-amber-800 dark:text-amber-200"> <ul class="space-y-1 text-sm text-amber-800 dark:text-amber-200">
@ -146,7 +146,7 @@
</ul> </ul>
@elseif ($decisionDirection === 'internal_only') @elseif ($decisionDirection === 'internal_only')
<div class="rounded-md border border-amber-100 bg-amber-50 px-3 py-2 text-sm text-amber-800 dark:border-amber-900/40 dark:bg-amber-950/30 dark:text-amber-200"> <div class="rounded-md border border-amber-100 bg-amber-50 px-3 py-2 text-sm text-amber-800 dark:border-amber-900/40 dark:bg-amber-950/30 dark:text-amber-200">
<div>{{ __('localization.review.internal_only') }}</div> <div>This review is currently safe for internal use only.</div>
@if ($publicationNextAction !== null) @if ($publicationNextAction !== null)
<div class="mt-1">{{ $publicationNextAction }}</div> <div class="mt-1">{{ $publicationNextAction }}</div>
@ -154,7 +154,7 @@
</div> </div>
@else @else
<div class="rounded-md border border-amber-100 bg-amber-50 px-3 py-2 text-sm text-amber-800 dark:border-amber-900/40 dark:bg-amber-950/30 dark:text-amber-200"> <div class="rounded-md border border-amber-100 bg-amber-50 px-3 py-2 text-sm text-amber-800 dark:border-amber-900/40 dark:bg-amber-950/30 dark:text-amber-200">
{{ $publicationNextAction ?? $publicationReason ?? __('localization.review.needs_follow_up') }} {{ $publicationNextAction ?? $publicationReason ?? 'This review still needs follow-up before publication.' }}
</div> </div>
@endif @endif
</div> </div>

View File

@ -14,7 +14,7 @@
@if (! $isConfigured) @if (! $isConfigured)
<div class="rounded-md bg-amber-50 p-4 text-sm text-amber-900 dark:bg-amber-950/30 dark:text-amber-200"> <div class="rounded-md bg-amber-50 p-4 text-sm text-amber-900 dark:bg-amber-950/30 dark:text-amber-200">
{{ __('localization.auth.microsoft_not_configured') }} Microsoft sign-in is not configured.
</div> </div>
@endif @endif
@ -25,11 +25,11 @@
:disabled="! $isConfigured" :disabled="! $isConfigured"
color="primary" color="primary"
> >
{{ __('localization.auth.sign_in_microsoft') }} Sign in with Microsoft
</x-filament::button> </x-filament::button>
<div class="text-center text-sm text-gray-500 dark:text-gray-400"> <div class="text-center text-sm text-gray-500 dark:text-gray-400">
{{ __('localization.auth.tenant_admin_membership_required') }} Tenant Admin access requires a tenant membership.
</div> </div>
</div> </div>
</div> </div>

View File

@ -1,164 +0,0 @@
<x-filament-panels::page>
@php
$scope = $this->appliedScope();
$sections = $this->sections();
$emptyState = $this->calmEmptyState();
@endphp
<x-filament::section>
<div class="flex flex-col gap-3">
<div class="inline-flex w-fit items-center gap-2 rounded-full border border-primary-200 bg-primary-50 px-3 py-1 text-xs font-medium text-primary-700 dark:border-primary-700/60 dark:bg-primary-950/40 dark:text-primary-300">
<x-filament::icon icon="heroicon-o-inbox-stack" class="h-3.5 w-3.5" />
Governance inbox
</div>
<div class="space-y-1">
<h1 class="text-2xl font-semibold tracking-tight text-gray-950 dark:text-white">
Governance inbox
</h1>
<p class="max-w-3xl text-sm leading-6 text-gray-600 dark:text-gray-300">
This workspace decision surface routes you into the existing findings, operations, alerts, and review surfaces without introducing a second workflow state.
</p>
</div>
<div class="flex flex-wrap gap-2 text-sm text-gray-600 dark:text-gray-300">
@if (filled($scope['workspace_label'] ?? null))
<span class="inline-flex items-center rounded-full bg-gray-100 px-3 py-1 text-xs font-medium text-gray-700 dark:bg-gray-800 dark:text-gray-200">
Workspace: {{ $scope['workspace_label'] }}
</span>
@endif
<span class="inline-flex items-center rounded-full bg-gray-100 px-3 py-1 text-xs font-medium text-gray-700 dark:bg-gray-800 dark:text-gray-200">
Scope: {{ $scope['family_label'] ?? 'All attention' }}
</span>
<span class="inline-flex items-center rounded-full bg-gray-100 px-3 py-1 text-xs font-medium text-gray-700 dark:bg-gray-800 dark:text-gray-200">
Visible items: {{ $scope['total_count'] ?? 0 }}
</span>
@if (filled($scope['tenant_label'] ?? null))
<span class="inline-flex items-center rounded-full bg-warning-50 px-3 py-1 text-xs font-medium text-warning-700 dark:bg-warning-500/10 dark:text-warning-300">
Tenant: {{ $scope['tenant_label'] }}
</span>
@endif
</div>
<div class="flex flex-wrap gap-2">
<a
href="{{ $this->pageUrl(['family' => null]) }}"
class="inline-flex items-center gap-2 rounded-full border px-3 py-1.5 text-sm font-medium transition {{ $this->family === null ? 'border-primary-300 bg-primary-50 text-primary-700 dark:border-primary-700/60 dark:bg-primary-950/40 dark:text-primary-300' : 'border-gray-200 text-gray-700 hover:border-gray-300 dark:border-gray-700 dark:text-gray-200 dark:hover:border-gray-600' }}"
>
All attention
<span class="rounded-full bg-black/5 px-2 py-0.5 text-xs dark:bg-white/10">{{ $scope['total_count'] ?? 0 }}</span>
</a>
@foreach ($this->availableFamilies() as $family)
<a
href="{{ $this->pageUrl(['family' => $family['key']]) }}"
class="inline-flex items-center gap-2 rounded-full border px-3 py-1.5 text-sm font-medium transition {{ $this->isActiveFamily($family['key']) ? 'border-primary-300 bg-primary-50 text-primary-700 dark:border-primary-700/60 dark:bg-primary-950/40 dark:text-primary-300' : 'border-gray-200 text-gray-700 hover:border-gray-300 dark:border-gray-700 dark:text-gray-200 dark:hover:border-gray-600' }}"
>
{{ $family['label'] }}
<span class="rounded-full bg-black/5 px-2 py-0.5 text-xs dark:bg-white/10">{{ $family['count'] }}</span>
</a>
@endforeach
</div>
@if ($this->hasTenantPrefilter())
<div class="flex flex-wrap items-center gap-3 text-sm text-gray-600 dark:text-gray-300">
<span>The inbox is currently filtered to one tenant.</span>
<a href="{{ $this->pageUrl(['tenant' => null]) }}" class="font-medium text-primary-600 hover:text-primary-500 dark:text-primary-400 dark:hover:text-primary-300">
Clear tenant filter
</a>
</div>
@endif
</div>
</x-filament::section>
@if ($sections === [])
<x-filament::section>
<div class="flex flex-col gap-4 rounded-2xl border border-dashed border-gray-300 bg-gray-50/60 p-6 dark:border-gray-700 dark:bg-gray-900/40">
<div class="space-y-1">
<h2 class="text-base font-semibold text-gray-950 dark:text-white">{{ $emptyState['title'] }}</h2>
<p class="max-w-2xl text-sm leading-6 text-gray-600 dark:text-gray-300">{{ $emptyState['body'] }}</p>
</div>
@if (filled($emptyState['action_label'] ?? null) && filled($emptyState['action_url'] ?? null))
<div>
<x-filament::button tag="a" color="gray" href="{{ $emptyState['action_url'] }}">
{{ $emptyState['action_label'] }}
</x-filament::button>
</div>
@endif
</div>
</x-filament::section>
@else
@foreach ($sections as $section)
<x-filament::section>
<div class="flex flex-col gap-4">
<div class="flex flex-col gap-3 lg:flex-row lg:items-start lg:justify-between">
<div class="space-y-2">
<div class="flex flex-wrap items-center gap-2">
<h2 class="text-base font-semibold text-gray-950 dark:text-white">{{ $section['label'] }}</h2>
<span class="inline-flex items-center rounded-full bg-gray-100 px-2.5 py-1 text-xs font-medium text-gray-700 dark:bg-gray-800 dark:text-gray-200">
{{ $section['count'] }}
</span>
</div>
<p class="max-w-3xl text-sm leading-6 text-gray-600 dark:text-gray-300">{{ $section['summary'] }}</p>
</div>
<div>
<x-filament::button tag="a" color="gray" href="{{ $section['dominant_action_url'] }}">
{{ $section['dominant_action_label'] }}
</x-filament::button>
</div>
</div>
@if ($section['count'] === 0)
<div class="rounded-2xl border border-dashed border-gray-300 bg-gray-50/60 p-5 text-sm leading-6 text-gray-600 dark:border-gray-700 dark:bg-gray-900/40 dark:text-gray-300">
{{ $section['empty_state'] }}
</div>
@else
<ul class="grid gap-3">
@foreach ($section['entries'] as $entry)
<li class="rounded-2xl border border-gray-200 bg-white p-4 shadow-sm dark:border-gray-800 dark:bg-gray-900/60">
<div class="flex flex-col gap-3 lg:flex-row lg:items-start lg:justify-between">
<div class="space-y-1.5">
@if (filled($entry['tenant_label'] ?? null))
<div class="text-xs font-medium uppercase tracking-[0.16em] text-gray-500 dark:text-gray-400">
{{ $entry['tenant_label'] }}
</div>
@endif
<div class="flex flex-wrap items-center gap-2">
<a href="{{ $entry['destination_url'] }}" class="text-sm font-semibold text-gray-950 hover:text-primary-600 dark:text-white dark:hover:text-primary-300">
{{ $entry['headline'] }}
</a>
<span class="inline-flex items-center rounded-full bg-gray-100 px-2.5 py-1 text-xs font-medium text-gray-700 dark:bg-gray-800 dark:text-gray-200">
{{ $entry['status_label'] }}
</span>
</div>
@if (filled($entry['subline'] ?? null))
<p class="text-sm leading-6 text-gray-600 dark:text-gray-300">{{ $entry['subline'] }}</p>
@endif
</div>
<div>
<x-filament::button tag="a" color="gray" size="sm" href="{{ $entry['destination_url'] }}">
Open source
</x-filament::button>
</div>
</div>
</li>
@endforeach
</ul>
@endif
</div>
</x-filament::section>
@endforeach
@endif
</x-filament-panels::page>

View File

@ -1,19 +0,0 @@
<x-filament-panels::page>
<x-filament::section>
<div class="flex flex-col gap-3">
<div class="text-lg font-semibold text-gray-900 dark:text-gray-100">
{{ __('localization.review.customer_safe_review_workspace') }}
</div>
<div class="text-sm text-gray-600 dark:text-gray-300">
{{ __('localization.review.customer_workspace_intro') }}
</div>
<div class="text-sm text-gray-600 dark:text-gray-300">
{{ __('localization.review.customer_workspace_canonical_note') }}
</div>
</div>
</x-filament::section>
{{ $this->table }}
</x-filament-panels::page>

View File

@ -31,8 +31,8 @@
@endphp @endphp
@php @php
$tenantLabel = $currentTenantName ?? __('localization.shell.no_tenant_selected'); $tenantLabel = $currentTenantName ?? 'No tenant selected';
$workspaceLabel = $workspace?->name ?? __('localization.shell.choose_workspace'); $workspaceLabel = $workspace?->name ?? 'Choose workspace';
$hasActiveTenant = $currentTenantName !== null; $hasActiveTenant = $currentTenantName !== null;
$managedTenantsUrl = $workspace $managedTenantsUrl = $workspace
? route('admin.workspace.managed-tenants.index', ['workspace' => $workspace]) ? route('admin.workspace.managed-tenants.index', ['workspace' => $workspace])
@ -40,8 +40,7 @@
$workspaceUrl = $workspace $workspaceUrl = $workspace
? route('admin.home') ? route('admin.home')
: ChooseWorkspace::getUrl(panel: 'admin'); : ChooseWorkspace::getUrl(panel: 'admin');
$tenantTriggerLabel = $workspace ? $tenantLabel : __('localization.shell.choose_workspace'); $tenantTriggerLabel = $workspace ? $tenantLabel : 'Choose workspace';
$localePlane = Filament::getCurrentPanel()?->getId() === 'tenant' ? 'tenant' : 'admin';
@endphp @endphp
<div class="inline-flex items-center gap-0 rounded-lg border border-gray-200 bg-white text-sm dark:border-white/10 dark:bg-white/5"> <div class="inline-flex items-center gap-0 rounded-lg border border-gray-200 bg-white text-sm dark:border-white/10 dark:bg-white/5">
@ -64,7 +63,7 @@ class="inline-flex items-center gap-1.5 rounded-l-lg px-2.5 py-1.5 font-medium t
<x-slot name="trigger"> <x-slot name="trigger">
<button <button
type="button" type="button"
aria-label="{{ $workspace ? __('localization.shell.tenant_scope') : __('localization.shell.select_tenant') }}" aria-label="{{ $workspace ? 'Tenant scope' : 'Select tenant' }}"
class="inline-flex items-center gap-1.5 rounded-r-lg px-2 py-1.5 transition hover:bg-gray-50 dark:hover:bg-white/10" class="inline-flex items-center gap-1.5 rounded-r-lg px-2 py-1.5 transition hover:bg-gray-50 dark:hover:bg-white/10"
> >
<span class="{{ $workspace && $hasActiveTenant ? 'font-medium text-primary-600 dark:text-primary-400' : 'text-gray-500 dark:text-gray-400' }}"> <span class="{{ $workspace && $hasActiveTenant ? 'font-medium text-primary-600 dark:text-primary-400' : 'text-gray-500 dark:text-gray-400' }}">
@ -79,12 +78,12 @@ class="inline-flex items-center gap-1.5 rounded-r-lg px-2 py-1.5 transition hove
<div class="space-y-3 px-3 py-2" x-data="{ query: '' }"> <div class="space-y-3 px-3 py-2" x-data="{ query: '' }">
@if ($resolvedContext->showsRecoveryNotice()) @if ($resolvedContext->showsRecoveryNotice())
<div class="rounded-lg border border-amber-200 bg-amber-50 px-3 py-2 text-xs text-amber-800 dark:border-amber-500/30 dark:bg-amber-500/10 dark:text-amber-200"> <div class="rounded-lg border border-amber-200 bg-amber-50 px-3 py-2 text-xs text-amber-800 dark:border-amber-500/30 dark:bg-amber-500/10 dark:text-amber-200">
<div class="font-semibold">{{ __('localization.shell.context_unavailable') }}</div> <div class="font-semibold">Context unavailable</div>
@if ($workspace) @if ($workspace)
<div>{{ __('localization.shell.context_unavailable_workspace') }}</div> <div>The requested scope could not be restored. The shell is showing a valid workspace state instead.</div>
@else @else
<div>{{ __('localization.shell.context_unavailable_no_workspace') }}</div> <div>Choose a workspace to continue with a valid admin context.</div>
@endif @endif
</div> </div>
@endif @endif
@ -92,7 +91,7 @@ class="inline-flex items-center gap-1.5 rounded-r-lg px-2 py-1.5 transition hove
{{-- Workspace section --}} {{-- Workspace section --}}
<div class="space-y-1"> <div class="space-y-1">
<div class="text-xs font-semibold uppercase tracking-wider text-gray-400 dark:text-gray-500"> <div class="text-xs font-semibold uppercase tracking-wider text-gray-400 dark:text-gray-500">
{{ __('localization.shell.workspace') }} Workspace
</div> </div>
<div class="flex items-center justify-between rounded-lg bg-gray-50 px-3 py-2 dark:bg-white/5"> <div class="flex items-center justify-between rounded-lg bg-gray-50 px-3 py-2 dark:bg-white/5">
@ -105,7 +104,7 @@ class="inline-flex items-center gap-1.5 rounded-r-lg px-2 py-1.5 transition hove
href="{{ ChooseWorkspace::getUrl(panel: 'admin').'?choose=1' }}" href="{{ ChooseWorkspace::getUrl(panel: 'admin').'?choose=1' }}"
class="text-xs font-medium text-primary-600 hover:text-primary-500 dark:text-primary-400 dark:hover:text-primary-300" class="text-xs font-medium text-primary-600 hover:text-primary-500 dark:text-primary-400 dark:hover:text-primary-300"
> >
{{ __('localization.shell.switch_workspace') }} Switch workspace
</a> </a>
</div> </div>
@ -114,7 +113,7 @@ class="text-xs font-medium text-primary-600 hover:text-primary-500 dark:text-pri
class="flex items-center gap-2 rounded-lg px-3 py-2 text-sm text-gray-700 transition hover:bg-gray-50 dark:text-gray-300 dark:hover:bg-white/5" class="flex items-center gap-2 rounded-lg px-3 py-2 text-sm text-gray-700 transition hover:bg-gray-50 dark:text-gray-300 dark:hover:bg-white/5"
> >
<x-filament::icon icon="heroicon-o-home" class="h-4 w-4 text-gray-400 dark:text-gray-500" /> <x-filament::icon icon="heroicon-o-home" class="h-4 w-4 text-gray-400 dark:text-gray-500" />
{{ __('localization.shell.workspace_home') }} Workspace Home
</a> </a>
</div> </div>
@ -125,7 +124,7 @@ class="flex items-center gap-2 rounded-lg px-3 py-2 text-sm text-gray-700 transi
<div class="space-y-2"> <div class="space-y-2">
<div class="flex items-center justify-between"> <div class="flex items-center justify-between">
<div class="text-xs font-semibold uppercase tracking-wider text-gray-400 dark:text-gray-500"> <div class="text-xs font-semibold uppercase tracking-wider text-gray-400 dark:text-gray-500">
{{ __('localization.shell.selected_tenant') }} Selected tenant
</div> </div>
</div> </div>
@ -138,7 +137,7 @@ class="flex items-center gap-2 rounded-lg px-3 py-2 text-sm text-gray-700 transi
href="{{ ChooseTenant::getUrl(panel: 'admin') }}" href="{{ ChooseTenant::getUrl(panel: 'admin') }}"
class="ml-auto text-xs font-medium text-primary-600 hover:text-primary-500 dark:text-primary-400 dark:hover:text-primary-300" class="ml-auto text-xs font-medium text-primary-600 hover:text-primary-500 dark:text-primary-400 dark:hover:text-primary-300"
> >
{{ __('localization.shell.switch_tenant') }} Switch tenant
</a> </a>
</div> </div>
@ -147,7 +146,7 @@ class="ml-auto text-xs font-medium text-primary-600 hover:text-primary-500 dark:
@csrf @csrf
<button type="submit" class="w-full rounded-lg px-3 py-1.5 text-left text-xs font-medium text-gray-500 transition hover:bg-gray-50 hover:text-gray-700 dark:text-gray-400 dark:hover:bg-white/5 dark:hover:text-gray-200"> <button type="submit" class="w-full rounded-lg px-3 py-1.5 text-left text-xs font-medium text-gray-500 transition hover:bg-gray-50 hover:text-gray-700 dark:text-gray-400 dark:hover:bg-white/5 dark:hover:text-gray-200">
{{ __('localization.shell.clear_tenant_scope') }} Clear tenant scope
</button> </button>
</form> </form>
@endif @endif
@ -155,23 +154,23 @@ class="ml-auto text-xs font-medium text-primary-600 hover:text-primary-500 dark:
@else @else
@if ($tenants->isEmpty()) @if ($tenants->isEmpty())
<div class="space-y-2 rounded-lg border border-dashed border-gray-300 px-3 py-3 text-center text-xs text-gray-500 dark:border-gray-600 dark:text-gray-400"> <div class="space-y-2 rounded-lg border border-dashed border-gray-300 px-3 py-3 text-center text-xs text-gray-500 dark:border-gray-600 dark:text-gray-400">
<div>{{ __('localization.shell.no_active_tenants') }}</div> <div>No active tenants are available for the standard operating context in this workspace.</div>
<a href="{{ $managedTenantsUrl }}" class="inline-flex items-center gap-1 text-primary-600 hover:text-primary-500 dark:text-primary-400 dark:hover:text-primary-300"> <a href="{{ $managedTenantsUrl }}" class="inline-flex items-center gap-1 text-primary-600 hover:text-primary-500 dark:text-primary-400 dark:hover:text-primary-300">
<x-filament::icon icon="heroicon-o-arrow-top-right-on-square" class="h-3.5 w-3.5" /> <x-filament::icon icon="heroicon-o-arrow-top-right-on-square" class="h-3.5 w-3.5" />
{{ __('localization.shell.view_managed_tenants') }} View managed tenants
</a> </a>
</div> </div>
@else @else
@if (! $hasActiveTenant) @if (! $hasActiveTenant)
<div class="rounded-lg bg-gray-50 px-3 py-2 text-xs text-gray-600 dark:bg-white/5 dark:text-gray-400"> <div class="rounded-lg bg-gray-50 px-3 py-2 text-xs text-gray-600 dark:bg-white/5 dark:text-gray-400">
{{ __('localization.shell.workspace_wide_available') }} No tenant selected. Workspace-wide pages remain available, and choosing a tenant only sets the normal active operating context.
</div> </div>
@endif @endif
<input <input
type="text" type="text"
class="fi-input fi-text-input w-full" class="fi-input fi-text-input w-full"
placeholder="{{ __('localization.shell.search_tenants') }}" placeholder="Search tenants…"
x-model="query" x-model="query"
/> />
@ -208,7 +207,7 @@ class="flex w-full items-center gap-2 px-3 py-2 text-left text-sm transition {{
@csrf @csrf
<button type="submit" class="w-full rounded-lg px-3 py-1.5 text-left text-xs font-medium text-gray-500 transition hover:bg-gray-50 hover:text-gray-700 dark:text-gray-400 dark:hover:bg-white/5 dark:hover:text-gray-200"> <button type="submit" class="w-full rounded-lg px-3 py-1.5 text-left text-xs font-medium text-gray-500 transition hover:bg-gray-50 hover:text-gray-700 dark:text-gray-400 dark:hover:bg-white/5 dark:hover:text-gray-200">
{{ __('localization.shell.clear_tenant_scope') }} Clear tenant scope
</button> </button>
</form> </form>
@endif @endif
@ -217,12 +216,10 @@ class="flex w-full items-center gap-2 px-3 py-2 text-left text-sm transition {{
</div> </div>
@else @else
<div class="rounded-lg border border-dashed border-gray-300 px-3 py-3 text-center text-xs text-gray-500 dark:border-gray-600 dark:text-gray-400"> <div class="rounded-lg border border-dashed border-gray-300 px-3 py-3 text-center text-xs text-gray-500 dark:border-gray-600 dark:text-gray-400">
{{ __('localization.shell.choose_workspace_first') }} Choose a workspace first.
</div> </div>
@endif @endif
</div> </div>
</x-filament::dropdown.list> </x-filament::dropdown.list>
</x-filament::dropdown> </x-filament::dropdown>
@include('filament.partials.locale-switcher', ['plane' => $localePlane, 'showPreference' => true, 'embedded' => true])
</div> </div>

View File

@ -1,110 +0,0 @@
@php
use App\Models\User;
use App\Services\Localization\LocaleResolver;
$plane = $plane ?? 'admin';
$showPreference = (bool) ($showPreference ?? true);
$embedded = (bool) ($embedded ?? false);
/** @var LocaleResolver $localeResolver */
$localeResolver = app(LocaleResolver::class);
$localeContext = request()->attributes->get(LocaleResolver::REQUEST_ATTRIBUTE);
$localeContext = is_array($localeContext) ? $localeContext : $localeResolver->resolve(request(), $plane);
$localeOptions = LocaleResolver::localeOptions();
$currentLocale = (string) ($localeContext['locale'] ?? 'en');
$source = (string) ($localeContext['source'] ?? LocaleResolver::SOURCE_SYSTEM_DEFAULT);
$sourceLabel = __('localization.source.'.$source);
$user = auth()->user();
$preferredLocale = $user instanceof User ? $user->preferred_locale : null;
@endphp
<div class="{{ $embedded ? 'border-l border-gray-200 dark:border-white/10' : 'inline-flex rounded-lg border border-gray-200 bg-white text-sm dark:border-white/10 dark:bg-white/5' }}">
<x-filament::dropdown placement="bottom-end" teleport width="sm">
<x-slot name="trigger">
<button
type="button"
aria-label="{{ __('localization.shell.language') }}"
class="inline-flex items-center gap-1.5 rounded-r-lg px-2 py-1.5 text-sm transition hover:bg-gray-50 dark:hover:bg-white/10"
>
<x-filament::icon icon="heroicon-o-language" class="h-4 w-4 text-gray-400 dark:text-gray-500" />
<span class="font-medium text-gray-700 dark:text-gray-200">{{ strtoupper($currentLocale) }}</span>
<x-filament::icon icon="heroicon-m-chevron-down" class="h-3.5 w-3.5 text-gray-400 dark:text-gray-500" />
</button>
</x-slot>
<x-filament::dropdown.list>
<div class="space-y-3 px-3 py-2">
<div class="space-y-1">
<div class="text-xs font-semibold uppercase tracking-wider text-gray-400 dark:text-gray-500">
{{ __('localization.shell.current_language') }}
</div>
<div class="rounded-lg bg-gray-50 px-3 py-2 dark:bg-white/5">
<div class="text-sm font-medium text-gray-950 dark:text-white">
{{ $localeOptions[$currentLocale] ?? strtoupper($currentLocale) }}
</div>
<div class="text-xs text-gray-500 dark:text-gray-400">
{{ __('localization.shell.language_source', ['source' => $sourceLabel]) }}
</div>
</div>
</div>
<div class="border-t border-gray-200 dark:border-white/10"></div>
<form method="POST" action="{{ route('localization.override.update') }}" class="space-y-2">
@csrf
<label class="text-xs font-semibold uppercase tracking-wider text-gray-400 dark:text-gray-500" for="tenantpilot-locale-override-{{ $plane }}">
{{ __('localization.shell.temporary_override') }}
</label>
<x-filament::input.wrapper class="w-full">
<x-filament::input.select
id="tenantpilot-locale-override-{{ $plane }}"
name="locale"
>
@foreach ($localeOptions as $locale => $label)
<option value="{{ $locale }}" @selected($currentLocale === $locale)>{{ $label }}</option>
@endforeach
</x-filament::input.select>
</x-filament::input.wrapper>
<button type="submit" class="w-full rounded-lg bg-primary-600 px-3 py-1.5 text-sm font-medium text-white transition hover:bg-primary-500">
{{ __('localization.shell.switch_language') }}
</button>
</form>
@if ($source === LocaleResolver::SOURCE_EXPLICIT_OVERRIDE)
<form method="POST" action="{{ route('localization.override.clear') }}">
@csrf
@method('DELETE')
<button type="submit" class="w-full rounded-lg px-3 py-1.5 text-left text-xs font-medium text-gray-500 transition hover:bg-gray-50 hover:text-gray-700 dark:text-gray-400 dark:hover:bg-white/5 dark:hover:text-gray-200">
{{ __('localization.shell.clear_override') }}
</button>
</form>
@endif
@if ($showPreference && $user instanceof User)
<div class="border-t border-gray-200 dark:border-white/10"></div>
<form method="POST" action="{{ route('localization.preference.update') }}" class="space-y-2">
@csrf
<label class="text-xs font-semibold uppercase tracking-wider text-gray-400 dark:text-gray-500" for="tenantpilot-locale-preference-{{ $plane }}">
{{ __('localization.shell.personal_preference') }}
</label>
<x-filament::input.wrapper class="w-full">
<x-filament::input.select
id="tenantpilot-locale-preference-{{ $plane }}"
name="preferred_locale"
>
<option value="" @selected($preferredLocale === null)>{{ __('localization.shell.inherit_workspace') }}</option>
@foreach ($localeOptions as $locale => $label)
<option value="{{ $locale }}" @selected($preferredLocale === $locale)>{{ $label }}</option>
@endforeach
</x-filament::input.select>
</x-filament::input.wrapper>
<button type="submit" class="w-full rounded-lg px-3 py-1.5 text-sm font-medium text-primary-600 transition hover:bg-primary-50 hover:text-primary-500 dark:text-primary-400 dark:hover:bg-primary-500/10 dark:hover:text-primary-300">
{{ __('localization.shell.save_preference') }}
</button>
</form>
@endif
</div>
</x-filament::dropdown.list>
</x-filament::dropdown>
</div>

View File

@ -1,18 +1,9 @@
@php @php
use App\Support\Badges\BadgeCatalog;
use App\Support\Badges\BadgeDomain;
/** @var \App\Models\Workspace $workspace */ /** @var \App\Models\Workspace $workspace */
$workspace = $this->workspace; $workspace = $this->workspace;
$customerHealthDecision = $this->customerHealthDecision(); $customerHealthDecision = $this->customerHealthDecision();
$tenants = $this->workspaceTenants(); $tenants = $this->workspaceTenants();
$runs = $this->recentRuns(); $runs = $this->recentRuns();
$commercialLifecycle = $this->workspaceCommercialLifecycleSummary();
$commercialBadge = BadgeCatalog::spec(BadgeDomain::CommercialLifecycleState, $commercialLifecycle['state'] ?? null);
$commercialActionDecisions = is_array($commercialLifecycle['action_decisions'] ?? null) ? $commercialLifecycle['action_decisions'] : [];
$activationLifecycleDecision = $commercialActionDecisions['managed_tenant_activation'] ?? null;
$reviewPackLifecycleDecision = $commercialActionDecisions['review_pack_start'] ?? null;
$readOnlyLifecycleDecision = $commercialActionDecisions['generated_pack_read'] ?? null;
$workspaceEntitlementSummary = $this->workspaceEntitlementSummary(); $workspaceEntitlementSummary = $this->workspaceEntitlementSummary();
$planProfile = $workspaceEntitlementSummary['plan_profile'] ?? null; $planProfile = $workspaceEntitlementSummary['plan_profile'] ?? null;
$entitlementDecisions = $workspaceEntitlementSummary['decisions'] ?? []; $entitlementDecisions = $workspaceEntitlementSummary['decisions'] ?? [];
@ -49,63 +40,6 @@
@include('filament.system.pages.directory.partials.customer-health-decision-card', ['decision' => $customerHealthDecision]) @include('filament.system.pages.directory.partials.customer-health-decision-card', ['decision' => $customerHealthDecision])
@endif @endif
<x-filament::section>
<x-slot name="heading">
Commercial lifecycle
</x-slot>
<div class="grid grid-cols-1 gap-4 sm:grid-cols-2">
<div class="rounded-lg bg-gray-50 px-4 py-3 dark:bg-white/5">
<p class="text-xs font-semibold uppercase tracking-wider text-gray-500 dark:text-gray-400">Current state</p>
<div class="mt-2 flex items-center gap-2">
<x-filament::badge :color="$commercialBadge->color" :icon="$commercialBadge->icon">
{{ $commercialBadge->label }}
</x-filament::badge>
<span class="text-sm text-gray-500 dark:text-gray-400">{{ $commercialLifecycle['source_label'] ?? 'default active paid' }}</span>
</div>
<p class="mt-2 text-sm text-gray-500 dark:text-gray-400">{{ $commercialLifecycle['description'] ?? 'Commercial lifecycle state controls expansion and review-pack starts.' }}</p>
</div>
<div class="rounded-lg bg-gray-50 px-4 py-3 dark:bg-white/5">
<p class="text-xs font-semibold uppercase tracking-wider text-gray-500 dark:text-gray-400">Lifecycle rationale</p>
<p class="mt-1 text-base font-semibold text-gray-950 dark:text-white">{{ $commercialLifecycle['rationale'] ?? 'No explicit rationale recorded.' }}</p>
<p class="mt-1 text-sm text-gray-500 dark:text-gray-400">
{{ $commercialLifecycle['last_changed_by'] ?? 'System default' }}
@if (($commercialLifecycle['last_changed_at'] ?? null) instanceof \Carbon\CarbonInterface)
· {{ $commercialLifecycle['last_changed_at']->diffForHumans() }}
@endif
</p>
</div>
</div>
<div class="mt-4 space-y-3">
@foreach ([
'Managed tenant activation' => $activationLifecycleDecision,
'Review-pack starts' => $reviewPackLifecycleDecision,
'Read-only history and downloads' => $readOnlyLifecycleDecision,
] as $label => $decision)
@if (is_array($decision))
<div class="rounded-lg border border-gray-200 px-4 py-3 dark:border-white/10">
<div class="flex items-start justify-between gap-4">
<div>
<p class="text-sm font-semibold text-gray-950 dark:text-white">{{ $label }}</p>
<p class="mt-1 text-sm text-gray-500 dark:text-gray-400">{{ $decision['message'] ?? 'No lifecycle decision message available.' }}</p>
</div>
<x-filament::badge :color="match ($decision['outcome'] ?? null) {
'block' => 'danger',
'warn' => 'warning',
'allow_read_only' => 'info',
default => 'success',
}">
{{ str_replace('_', ' ', (string) ($decision['outcome'] ?? 'allow')) }}
</x-filament::badge>
</div>
</div>
@endif
@endforeach
</div>
</x-filament::section>
@if (is_array($planProfile) && is_array($managedTenantDecision) && is_array($reviewPackDecision)) @if (is_array($planProfile) && is_array($managedTenantDecision) && is_array($reviewPackDecision))
<x-filament::section> <x-filament::section>
<x-slot name="heading"> <x-slot name="heading">

View File

@ -11,8 +11,6 @@
/** @var bool $canManage */ /** @var bool $canManage */
/** @var bool $generationBlocked */ /** @var bool $generationBlocked */
/** @var ?string $generationBlockReason */ /** @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 */
@ -34,12 +32,6 @@
</div> </div>
@endif @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">
@ -223,18 +215,5 @@
@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>

View File

@ -4,7 +4,6 @@
use App\Http\Controllers\AdminConsentCallbackController; use App\Http\Controllers\AdminConsentCallbackController;
use App\Http\Controllers\Auth\EntraController; use App\Http\Controllers\Auth\EntraController;
use App\Http\Controllers\ClearTenantContextController; use App\Http\Controllers\ClearTenantContextController;
use App\Http\Controllers\LocalizationController;
use App\Http\Controllers\OpenFindingExceptionsQueueController; use App\Http\Controllers\OpenFindingExceptionsQueueController;
use App\Http\Controllers\RbacDelegatedAuthController; use App\Http\Controllers\RbacDelegatedAuthController;
use App\Http\Controllers\ReviewPackDownloadController; use App\Http\Controllers\ReviewPackDownloadController;
@ -68,21 +67,6 @@
->middleware('throttle:entra-callback') ->middleware('throttle:entra-callback')
->name('auth.entra.callback'); ->name('auth.entra.callback');
Route::middleware(['web'])->group(function (): void {
Route::get('/localization/context', [LocalizationController::class, 'context'])
->name('localization.context');
Route::post('/localization/override', [LocalizationController::class, 'updateOverride'])
->name('localization.override.update');
Route::delete('/localization/override', [LocalizationController::class, 'clearOverride'])
->name('localization.override.clear');
});
Route::middleware(['web', 'auth', 'ensure-correct-guard:web'])
->post('/users/me/locale-preference', [LocalizationController::class, 'updateUserPreference'])
->name('localization.preference.update');
$makeSmokeCookie = static fn () => cookie()->make( $makeSmokeCookie = static fn () => cookie()->make(
SuppressDebugbarForSmokeRequests::COOKIE_NAME, SuppressDebugbarForSmokeRequests::COOKIE_NAME,
SuppressDebugbarForSmokeRequests::COOKIE_VALUE, SuppressDebugbarForSmokeRequests::COOKIE_VALUE,

View File

@ -1,100 +0,0 @@
<?php
declare(strict_types=1);
use App\Filament\Resources\TenantReviewResource;
use App\Models\ReviewPack;
use App\Models\Tenant;
use App\Support\TenantReviewStatus;
use App\Support\Workspaces\WorkspaceContext;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Storage;
uses(RefreshDatabase::class);
pest()->browser()->timeout(20_000);
beforeEach(function (): void {
Storage::fake('exports');
});
it('smokes the customer review workspace handoff from tenant review detail', function (): void {
$tenantPublished = Tenant::factory()->create(['name' => 'Published Tenant']);
[$user, $tenantPublished] = createUserWithTenant(
tenant: $tenantPublished,
role: 'owner',
workspaceRole: 'manager',
);
$tenantWithoutPublished = Tenant::factory()->create([
'workspace_id' => (int) $tenantPublished->workspace_id,
'name' => 'No Published Tenant',
]);
createUserWithTenant(
tenant: $tenantWithoutPublished,
user: $user,
role: 'owner',
workspaceRole: 'manager',
);
$publishedSnapshot = seedTenantReviewEvidence($tenantPublished);
$noPublishedSnapshot = seedTenantReviewEvidence($tenantWithoutPublished);
$publishedReview = composeTenantReviewForTest($tenantPublished, $user, $publishedSnapshot);
$publishedReview->forceFill([
'status' => TenantReviewStatus::Published->value,
'published_at' => now(),
'published_by_user_id' => (int) $user->getKey(),
])->save();
$internalOnlyReview = composeTenantReviewForTest($tenantWithoutPublished, $user, $noPublishedSnapshot);
$internalOnlyReview->forceFill([
'status' => TenantReviewStatus::Ready->value,
'published_at' => null,
'published_by_user_id' => null,
])->save();
Storage::disk('exports')->put('review-packs/customer-review-workspace-smoke.zip', 'PK-test');
ReviewPack::factory()->ready()->create([
'tenant_id' => (int) $tenantPublished->getKey(),
'workspace_id' => (int) $tenantPublished->workspace_id,
'tenant_review_id' => (int) $publishedReview->getKey(),
'evidence_snapshot_id' => (int) $publishedSnapshot->getKey(),
'initiated_by_user_id' => (int) $user->getKey(),
'file_path' => 'review-packs/customer-review-workspace-smoke.zip',
'file_disk' => 'exports',
]);
$this->actingAs($user)->withSession([
WorkspaceContext::SESSION_KEY => (int) $tenantPublished->workspace_id,
WorkspaceContext::LAST_TENANT_IDS_SESSION_KEY => [
(string) $tenantPublished->workspace_id => (int) $tenantPublished->getKey(),
],
]);
visit(TenantReviewResource::tenantScopedUrl('view', ['record' => $publishedReview], $tenantPublished))
->waitForText('Related context')
->assertSee('Open customer workspace')
->assertNoJavaScriptErrors()
->assertNoConsoleLogs()
->click('Open customer workspace')
->waitForText('Customer-safe review workspace')
->assertSee('Clear filters')
->assertSee('Open latest review')
->assertDontSee('Publish review')
->assertDontSee('Refresh review')
->click('Clear filters')
->waitForText('No published review available yet')
->assertSee('No published review available yet')
->click('Open latest review')
->waitForText('Outcome summary')
->assertDontSee('Publish review')
->assertDontSee('Refresh review')
->assertDontSee('Create next review')
->assertDontSee('Export executive pack')
->assertDontSee('Archive review')
->assertNoJavaScriptErrors()
->assertNoConsoleLogs();
});

View File

@ -10,14 +10,10 @@
use App\Models\EvidenceSnapshotItem; use App\Models\EvidenceSnapshotItem;
use App\Models\Finding; use App\Models\Finding;
use App\Models\OperationRun; use App\Models\OperationRun;
use App\Models\PlatformUser;
use App\Models\ReviewPack; use App\Models\ReviewPack;
use App\Models\StoredReport; use App\Models\StoredReport;
use App\Models\Tenant; use App\Models\Tenant;
use App\Services\Entitlements\WorkspaceCommercialLifecycleResolver;
use App\Services\Settings\SettingsWriter;
use App\Support\Auth\Capabilities; use App\Support\Auth\Capabilities;
use App\Support\Auth\PlatformCapabilities;
use App\Support\Evidence\EvidenceCompletenessState; use App\Support\Evidence\EvidenceCompletenessState;
use App\Support\Evidence\EvidenceSnapshotStatus; use App\Support\Evidence\EvidenceSnapshotStatus;
use App\Support\Ui\GovernanceActions\GovernanceActionCatalog; use App\Support\Ui\GovernanceActions\GovernanceActionCatalog;
@ -72,23 +68,6 @@ function evidenceSnapshotHeaderActions(Testable $component): array
return $instance->getCachedHeaderActions(); return $instance->getCachedHeaderActions();
} }
function suspendEvidenceSnapshotWorkspace(Tenant $tenant): void
{
app(SettingsWriter::class)->updateWorkspaceCommercialLifecycle(
actor: PlatformUser::factory()->create([
'capabilities' => [
PlatformCapabilities::ACCESS_SYSTEM_PANEL,
PlatformCapabilities::DIRECTORY_VIEW,
PlatformCapabilities::COMMERCIAL_LIFECYCLE_MANAGE,
],
'is_active' => true,
]),
workspace: $tenant->workspace,
state: WorkspaceCommercialLifecycleResolver::STATE_SUSPENDED_READ_ONLY,
reason: 'Evidence read-only preservation test',
);
}
it('renders the evidence list page for an authorized user', function (): void { it('renders the evidence list page for an authorized user', function (): void {
$tenant = Tenant::factory()->create(); $tenant = Tenant::factory()->create();
[$user, $tenant] = createUserWithTenant(tenant: $tenant, role: 'owner'); [$user, $tenant] = createUserWithTenant(tenant: $tenant, role: 'owner');
@ -228,36 +207,6 @@ function suspendEvidenceSnapshotWorkspace(Tenant $tenant): void
->toContain('operation_run', 'review_pack'); ->toContain('operation_run', 'review_pack');
}); });
it('keeps evidence snapshot detail accessible for readonly members while suspended read-only', function (): void {
$tenant = Tenant::factory()->create();
[$user, $tenant] = createUserWithTenant(tenant: $tenant, role: 'readonly');
$snapshot = EvidenceSnapshot::query()->create([
'tenant_id' => (int) $tenant->getKey(),
'workspace_id' => (int) $tenant->workspace_id,
'status' => EvidenceSnapshotStatus::Active->value,
'completeness_state' => EvidenceCompletenessState::Complete->value,
'summary' => ['finding_count' => 2],
'generated_at' => now(),
]);
suspendEvidenceSnapshotWorkspace($tenant);
$this->actingAs($user)
->get(EvidenceSnapshotResource::getUrl('view', ['record' => $snapshot], tenant: $tenant, panel: 'tenant'))
->assertOk();
$tenant->makeCurrent();
Filament::setTenant($tenant, true);
Livewire::actingAs($user)
->test(ViewEvidenceSnapshot::class, ['record' => $snapshot->getKey()])
->assertActionVisible('refresh_evidence')
->assertActionDisabled('refresh_evidence')
->assertActionVisible('expire_snapshot')
->assertActionDisabled('expire_snapshot');
});
it('shows artifact truth and next-step guidance for degraded evidence snapshots', function (): void { it('shows artifact truth and next-step guidance for degraded evidence snapshots', function (): void {
$tenant = Tenant::factory()->create(); $tenant = Tenant::factory()->create();
[$user, $tenant] = createUserWithTenant(tenant: $tenant, role: 'owner'); [$user, $tenant] = createUserWithTenant(tenant: $tenant, role: 'owner');

View File

@ -1,16 +0,0 @@
<?php
declare(strict_types=1);
use App\Filament\Resources\FindingResource;
use Illuminate\Support\Facades\App;
it('resolves first-wave governance labels from the active locale', function (): void {
App::setLocale('de');
expect(__('localization.dashboard.tenant_title'))->toBe('Tenant-Dashboard')
->and(FindingResource::getNavigationGroup())->toBe('Governance')
->and(__('localization.findings.needs_action'))->toBe('Handlungsbedarf')
->and(__('baseline-compare.stat_total_findings'))->toBe('Findings gesamt')
->and(__('findings.rbac.detail_heading'))->toBe('Intune-RBAC-Rollendefinitions-Drift');
});

View File

@ -1,99 +0,0 @@
<?php
declare(strict_types=1);
use App\Filament\Pages\Governance\GovernanceInbox;
use App\Models\Tenant;
use App\Models\User;
use App\Models\Workspace;
use App\Models\WorkspaceMembership;
use App\Services\Auth\WorkspaceCapabilityResolver;
use App\Support\Workspaces\WorkspaceContext;
use function Pest\Laravel\mock;
it('redirects governance inbox visits without workspace context into the existing workspace chooser flow', function (): void {
$user = User::factory()->create();
$workspaceA = Workspace::factory()->create();
$workspaceB = Workspace::factory()->create();
WorkspaceMembership::factory()->create([
'workspace_id' => (int) $workspaceA->getKey(),
'user_id' => (int) $user->getKey(),
'role' => 'owner',
]);
WorkspaceMembership::factory()->create([
'workspace_id' => (int) $workspaceB->getKey(),
'user_id' => (int) $user->getKey(),
'role' => 'owner',
]);
$this->actingAs($user)
->get(GovernanceInbox::getUrl(panel: 'admin'))
->assertRedirect('/admin/choose-workspace');
});
it('returns 404 for users outside the active workspace on the governance inbox route', function (): void {
$user = User::factory()->create();
$workspace = Workspace::factory()->create();
WorkspaceMembership::factory()->create([
'workspace_id' => (int) Workspace::factory()->create()->getKey(),
'user_id' => (int) $user->getKey(),
'role' => 'owner',
]);
$this->actingAs($user)
->withSession([WorkspaceContext::SESSION_KEY => (int) $workspace->getKey()])
->get(GovernanceInbox::getUrl(panel: 'admin'))
->assertNotFound();
});
it('returns 403 for workspace members with no qualifying family visibility anywhere', function (): void {
$user = User::factory()->create();
$workspace = Workspace::factory()->create();
WorkspaceMembership::factory()->create([
'workspace_id' => (int) $workspace->getKey(),
'user_id' => (int) $user->getKey(),
'role' => 'owner',
]);
mock(WorkspaceCapabilityResolver::class, function ($mock): void {
$mock->shouldReceive('isMember')->andReturnTrue();
$mock->shouldReceive('can')->andReturnFalse();
});
$this->actingAs($user)
->withSession([WorkspaceContext::SESSION_KEY => (int) $workspace->getKey()])
->get(GovernanceInbox::getUrl(panel: 'admin'))
->assertForbidden();
});
it('allows readonly tenant members to open the governance inbox through operations-family visibility', function (): void {
$tenant = Tenant::factory()->create(['status' => 'active']);
[$user, $tenant] = createUserWithTenant($tenant, role: 'readonly', workspaceRole: 'readonly');
$this->actingAs($user)
->withSession([WorkspaceContext::SESSION_KEY => (int) $tenant->workspace_id])
->get(GovernanceInbox::getUrl(panel: 'admin'))
->assertOk()
->assertSee('Governance inbox');
});
it('returns 404 for explicit tenant filters outside the actor scope', function (): void {
$visibleTenant = Tenant::factory()->create(['status' => 'active']);
[$user, $visibleTenant] = createUserWithTenant($visibleTenant, role: 'readonly', workspaceRole: 'readonly');
$hiddenTenant = Tenant::factory()->create([
'status' => 'active',
'workspace_id' => (int) $visibleTenant->workspace_id,
]);
$this->actingAs($user)
->withSession([WorkspaceContext::SESSION_KEY => (int) $visibleTenant->workspace_id])
->get(GovernanceInbox::getUrl(panel: 'admin').'?tenant_id='.(string) $hiddenTenant->getKey())
->assertNotFound();
});

View File

@ -1,64 +0,0 @@
<?php
declare(strict_types=1);
use App\Filament\Pages\Findings\MyFindingsInbox;
use App\Filament\Pages\Governance\GovernanceInbox;
use App\Models\Finding;
use App\Models\OperationRun;
use App\Models\Tenant;
use App\Support\Navigation\CanonicalNavigationContext;
use App\Support\OperationRunLinks;
use App\Support\OperationRunOutcome;
use App\Support\OperationRunStatus;
use App\Support\Workspaces\WorkspaceContext;
use Filament\Facades\Filament;
it('embeds canonical governance inbox navigation context into source links', function (): void {
$tenant = Tenant::factory()->create([
'status' => 'active',
'name' => 'Alpha Tenant',
'external_id' => 'alpha-tenant',
]);
[$user, $tenant] = createUserWithTenant($tenant, role: 'owner', workspaceRole: 'owner');
$finding = Finding::factory()
->for($tenant)
->assignedTo((int) $user->getKey())
->create();
$run = OperationRun::factory()
->forTenant($tenant)
->create([
'status' => OperationRunStatus::Completed->value,
'outcome' => OperationRunOutcome::Failed->value,
'completed_at' => now()->subMinute(),
]);
$context = new CanonicalNavigationContext(
sourceSurface: 'governance.inbox',
canonicalRouteName: GovernanceInbox::getRouteName(Filament::getPanel('admin')),
backLinkLabel: 'Back to governance inbox',
backLinkUrl: GovernanceInbox::getUrl(panel: 'admin'),
);
$response = $this->actingAs($user)
->withSession([WorkspaceContext::SESSION_KEY => (int) $tenant->workspace_id])
->get(GovernanceInbox::getUrl(panel: 'admin'));
$response->assertOk();
$expectedMyFindingsUrl = htmlspecialchars(
MyFindingsInbox::getUrl(panel: 'admin').'?'.http_build_query($context->toQuery()),
ENT_QUOTES,
);
$expectedOperationUrl = htmlspecialchars(
OperationRunLinks::tenantlessView($run, $context),
ENT_QUOTES,
);
$response->assertSee($expectedMyFindingsUrl, false)
->assertSee($expectedOperationUrl, false)
->assertSee((string) $finding->getKey())
->assertSee('nav%5Bback_label%5D=Back+to+governance+inbox', false);
});

View File

@ -1,143 +0,0 @@
<?php
declare(strict_types=1);
use App\Filament\Pages\Governance\GovernanceInbox;
use App\Models\AlertDelivery;
use App\Models\Finding;
use App\Models\OperationRun;
use App\Models\Tenant;
use App\Models\TenantTriageReview;
use App\Support\BackupHealth\TenantBackupHealthResolver;
use App\Support\OperationRunOutcome;
use App\Support\OperationRunStatus;
use App\Support\PortfolioTriage\PortfolioArrivalContextToken;
use App\Support\PortfolioTriage\TenantTriageReviewFingerprint;
use App\Support\Workspaces\WorkspaceContext;
it('renders visible governance attention sections on the governance inbox page', function (): void {
$alphaTenant = Tenant::factory()->create([
'status' => 'active',
'name' => 'Alpha Tenant',
'external_id' => 'alpha-tenant',
]);
[$user, $alphaTenant] = createUserWithTenant($alphaTenant, role: 'owner', workspaceRole: 'owner');
$bravoTenant = Tenant::factory()->create([
'status' => 'active',
'workspace_id' => (int) $alphaTenant->workspace_id,
'name' => 'Bravo Tenant',
'external_id' => 'bravo-tenant',
]);
$user->tenants()->syncWithoutDetaching([
(int) $bravoTenant->getKey() => ['role' => 'owner'],
]);
Finding::factory()
->for($alphaTenant)
->assignedTo((int) $user->getKey())
->ownedBy((int) $user->getKey())
->overdueByHours()
->create();
Finding::factory()
->for($bravoTenant)
->reopened()
->create();
OperationRun::factory()
->forTenant($alphaTenant)
->create([
'status' => OperationRunStatus::Completed->value,
'outcome' => OperationRunOutcome::Failed->value,
'completed_at' => now()->subMinute(),
]);
AlertDelivery::factory()->create([
'tenant_id' => null,
'workspace_id' => (int) $alphaTenant->workspace_id,
'status' => AlertDelivery::STATUS_FAILED,
'payload' => [
'title' => 'Delivery failed',
'body' => 'A notification destination failed.',
],
]);
$backupHealthResolver = app(TenantBackupHealthResolver::class);
$fingerprints = app(TenantTriageReviewFingerprint::class);
$alphaBackupFingerprint = $fingerprints->forBackupHealth($backupHealthResolver->assess($alphaTenant));
expect($alphaBackupFingerprint)->not->toBeNull();
TenantTriageReview::factory()
->for($alphaTenant)
->followUpNeeded()
->create([
'workspace_id' => (int) $alphaTenant->workspace_id,
'reviewed_by_user_id' => (int) $user->getKey(),
'concern_family' => PortfolioArrivalContextToken::FAMILY_BACKUP_HEALTH,
'review_fingerprint' => $alphaBackupFingerprint['fingerprint'],
'review_snapshot' => $alphaBackupFingerprint['snapshot'],
]);
$this->actingAs($user)
->withSession([WorkspaceContext::SESSION_KEY => (int) $alphaTenant->workspace_id])
->get(GovernanceInbox::getUrl(panel: 'admin'))
->assertOk()
->assertSee('Assigned findings')
->assertSee('Findings intake')
->assertSee('Operations follow-up')
->assertSee('Alert delivery failures')
->assertSee('Review follow-up')
->assertSee('Open my findings')
->assertSee('Open terminal follow-up')
->assertSee('Open alert deliveries')
->assertSee('Open review follow-up');
});
it('renders honest empty states for tenant and family filtering on the governance inbox page', function (): void {
$alphaTenant = Tenant::factory()->create([
'status' => 'active',
'name' => 'Alpha Tenant',
'external_id' => 'alpha-tenant',
]);
[$user, $alphaTenant] = createUserWithTenant($alphaTenant, role: 'owner', workspaceRole: 'owner');
$bravoTenant = Tenant::factory()->create([
'status' => 'active',
'workspace_id' => (int) $alphaTenant->workspace_id,
'name' => 'Bravo Tenant',
'external_id' => 'bravo-tenant',
]);
$user->tenants()->syncWithoutDetaching([
(int) $bravoTenant->getKey() => ['role' => 'owner'],
]);
Finding::factory()
->for($bravoTenant)
->assignedTo((int) $user->getKey())
->create();
AlertDelivery::factory()->create([
'tenant_id' => null,
'workspace_id' => (int) $alphaTenant->workspace_id,
'status' => AlertDelivery::STATUS_FAILED,
]);
$this->actingAs($user)
->withSession([WorkspaceContext::SESSION_KEY => (int) $alphaTenant->workspace_id])
->get(GovernanceInbox::getUrl(panel: 'admin').'?tenant_id='.(string) $alphaTenant->getKey())
->assertOk()
->assertSee('This tenant filter is hiding other visible attention')
->assertSee('Clear tenant filter');
$this->actingAs($user)
->withSession([WorkspaceContext::SESSION_KEY => (int) $alphaTenant->workspace_id])
->get(GovernanceInbox::getUrl(panel: 'admin').'?tenant_id='.(string) $alphaTenant->getKey().'&family=alert_delivery_failures')
->assertOk()
->assertSee('Alert delivery failures')
->assertSee('No failed alert deliveries match this tenant filter right now.')
->assertDontSee('Open my findings');
});

View File

@ -1,43 +0,0 @@
<?php
declare(strict_types=1);
use App\Services\Localization\LocaleResolver;
use App\Support\Workspaces\WorkspaceContext;
it('renders the admin auth surface in the explicit locale override', function (): void {
$this->withSession([LocaleResolver::SESSION_OVERRIDE_KEY => 'de'])
->get('/admin/login')
->assertSuccessful()
->assertSee('Mit Microsoft anmelden')
->assertSee('Tenant-Admin-Zugriff erfordert eine Tenant-Mitgliedschaft');
});
it('keeps system plane resolution independent from user and workspace preferences', function (): void {
[$workspace, $user] = localizationWorkspaceMember();
$user->forceFill(['preferred_locale' => 'de'])->save();
session()->forget(LocaleResolver::SESSION_OVERRIDE_KEY);
$this->actingAs($user)
->withSession([
WorkspaceContext::SESSION_KEY => (int) $workspace->getKey(),
LocaleResolver::SESSION_OVERRIDE_KEY => null,
])
->getJson('/localization/context?plane=system')
->assertSuccessful()
->assertJsonPath('locale', 'en')
->assertJsonPath('source', LocaleResolver::SOURCE_SYSTEM_DEFAULT)
->assertJsonPath('user_preference_locale', null)
->assertJsonPath('workspace_default_locale', null);
$this->actingAs($user)
->withSession([
WorkspaceContext::SESSION_KEY => (int) $workspace->getKey(),
LocaleResolver::SESSION_OVERRIDE_KEY => 'de',
])
->getJson('/localization/context?plane=system')
->assertSuccessful()
->assertJsonPath('locale', 'de')
->assertJsonPath('source', LocaleResolver::SOURCE_EXPLICIT_OVERRIDE);
});

View File

@ -1,87 +0,0 @@
<?php
declare(strict_types=1);
use App\Services\Localization\LocaleResolver;
use App\Services\Settings\SettingsWriter;
use App\Support\Workspaces\WorkspaceContext;
it('allows users to save and clear a personal locale preference over workspace default', function (): void {
[$workspace, $user] = localizationWorkspaceMember();
app(SettingsWriter::class)->updateWorkspaceSetting(
actor: $user,
workspace: $workspace,
domain: LocaleResolver::SETTING_DOMAIN,
key: LocaleResolver::SETTING_DEFAULT_LOCALE,
value: 'de',
);
$this->actingAs($user)
->withSession([WorkspaceContext::SESSION_KEY => (int) $workspace->getKey()])
->getJson('/localization/context')
->assertSuccessful()
->assertJsonPath('locale', 'de')
->assertJsonPath('source', LocaleResolver::SOURCE_WORKSPACE_DEFAULT);
$this->actingAs($user)
->withSession([WorkspaceContext::SESSION_KEY => (int) $workspace->getKey()])
->post(route('localization.preference.update'), ['preferred_locale' => 'en'])
->assertRedirect();
expect($user->refresh()->preferred_locale)->toBe('en');
$this->actingAs($user)
->withSession([WorkspaceContext::SESSION_KEY => (int) $workspace->getKey()])
->getJson('/localization/context')
->assertSuccessful()
->assertJsonPath('locale', 'en')
->assertJsonPath('source', LocaleResolver::SOURCE_USER_PREFERENCE);
$this->actingAs($user)
->withSession([WorkspaceContext::SESSION_KEY => (int) $workspace->getKey()])
->post(route('localization.preference.update'), ['preferred_locale' => ''])
->assertRedirect();
expect($user->refresh()->preferred_locale)->toBeNull();
$this->actingAs($user)
->withSession([WorkspaceContext::SESSION_KEY => (int) $workspace->getKey()])
->getJson('/localization/context')
->assertSuccessful()
->assertJsonPath('locale', 'de')
->assertJsonPath('source', LocaleResolver::SOURCE_WORKSPACE_DEFAULT);
});
it('allows temporary overrides to win until cleared', function (): void {
[$workspace, $user] = localizationWorkspaceMember();
$user->forceFill(['preferred_locale' => 'en'])->save();
$this->actingAs($user)
->withSession([WorkspaceContext::SESSION_KEY => (int) $workspace->getKey()])
->post(route('localization.override.update'), ['locale' => 'de'])
->assertRedirect();
expect(session(LocaleResolver::SESSION_OVERRIDE_KEY))->toBe('de');
$this->actingAs($user)
->withSession([
WorkspaceContext::SESSION_KEY => (int) $workspace->getKey(),
LocaleResolver::SESSION_OVERRIDE_KEY => 'de',
])
->getJson('/localization/context')
->assertSuccessful()
->assertJsonPath('locale', 'de')
->assertJsonPath('source', LocaleResolver::SOURCE_EXPLICIT_OVERRIDE);
$this->actingAs($user)
->withSession([
WorkspaceContext::SESSION_KEY => (int) $workspace->getKey(),
LocaleResolver::SESSION_OVERRIDE_KEY => 'de',
])
->delete(route('localization.override.clear'))
->assertRedirect();
expect(session(LocaleResolver::SESSION_OVERRIDE_KEY))->toBeNull();
});

View File

@ -1,47 +0,0 @@
<?php
declare(strict_types=1);
use App\Services\Localization\LocaleResolver;
use App\Services\Settings\SettingsWriter;
use App\Support\Workspaces\WorkspaceContext;
it('formats locale preference feedback in the resolved locale', function (): void {
[$workspace, $user] = localizationWorkspaceMember();
$this->actingAs($user)
->withSession([
WorkspaceContext::SESSION_KEY => (int) $workspace->getKey(),
LocaleResolver::SESSION_OVERRIDE_KEY => 'de',
])
->post(route('localization.preference.update'), ['preferred_locale' => 'de'])
->assertRedirect()
->assertSessionHas('status', 'Spracheinstellung gespeichert.');
});
it('formats override feedback in the newly effective locale', function (): void {
[$workspace, $user] = localizationWorkspaceMember();
app(SettingsWriter::class)->updateWorkspaceSetting(
actor: $user,
workspace: $workspace,
domain: LocaleResolver::SETTING_DOMAIN,
key: LocaleResolver::SETTING_DEFAULT_LOCALE,
value: 'de',
);
$this->actingAs($user)
->withSession([WorkspaceContext::SESSION_KEY => (int) $workspace->getKey()])
->post(route('localization.override.update'), ['locale' => 'de'])
->assertRedirect()
->assertSessionHas('status', 'Sprachüberschreibung angewendet.');
$this->actingAs($user)
->withSession([
WorkspaceContext::SESSION_KEY => (int) $workspace->getKey(),
LocaleResolver::SESSION_OVERRIDE_KEY => 'en',
])
->delete(route('localization.override.clear'))
->assertRedirect()
->assertSessionHas('status', 'Sprachüberschreibung gelöscht.');
});

View File

@ -1,31 +0,0 @@
<?php
declare(strict_types=1);
use App\Models\AuditLog;
use App\Services\Localization\LocaleResolver;
use App\Services\Settings\SettingsWriter;
use App\Support\Audit\AuditActionId;
use Illuminate\Support\Facades\App;
it('keeps audit action identifiers and machine values invariant while UI locale is German', function (): void {
[$workspace, $user] = localizationWorkspaceMember();
App::setLocale('de');
app(SettingsWriter::class)->updateWorkspaceSetting(
actor: $user,
workspace: $workspace,
domain: LocaleResolver::SETTING_DOMAIN,
key: LocaleResolver::SETTING_DEFAULT_LOCALE,
value: 'de',
);
$audit = AuditLog::query()->latest('id')->first();
expect($audit)->not->toBeNull()
->and($audit->action)->toBe(AuditActionId::WorkspaceSettingUpdated->value)
->and(data_get($audit->metadata, 'domain'))->toBe(LocaleResolver::SETTING_DOMAIN)
->and(data_get($audit->metadata, 'key'))->toBe(LocaleResolver::SETTING_DEFAULT_LOCALE)
->and(data_get($audit->metadata, 'after_value'))->toBe('de');
});

View File

@ -1,23 +0,0 @@
<?php
declare(strict_types=1);
use Illuminate\Support\Facades\App;
use Illuminate\Support\Facades\Lang;
it('falls back to English for missing German translation lines', function (): void {
Lang::addLines(['localization.fallback_probe' => 'English fallback probe'], 'en');
App::setFallbackLocale('en');
App::setLocale('de');
expect(__('localization.fallback_probe'))->toBe('English fallback probe');
});
it('does not expose raw translation keys for supported first-wave catalogs', function (): void {
App::setLocale('de');
expect(__('localization.auth.sign_in_microsoft'))->not->toBe('localization.auth.sign_in_microsoft')
->and(__('baseline-compare.button_view_findings'))->not->toBe('baseline-compare.button_view_findings')
->and(__('findings.rbac.restore_unsupported'))->not->toBe('findings.rbac.restore_unsupported');
});

View File

@ -1,50 +0,0 @@
<?php
declare(strict_types=1);
use App\Filament\Pages\Settings\WorkspaceSettings;
use App\Models\WorkspaceSetting;
use App\Services\Localization\LocaleResolver;
use App\Services\Settings\SettingsResolver;
use Livewire\Livewire;
it('persists workspace default locale through the existing workspace settings page', function (): void {
[$workspace, $user] = localizationWorkspaceMember();
$this->actingAs($user)
->get(WorkspaceSettings::getUrl(panel: 'admin'))
->assertSuccessful();
Livewire::actingAs($user)
->test(WorkspaceSettings::class)
->assertSet('data.localization_default_locale', null)
->set('data.localization_default_locale', 'de')
->callAction('save')
->assertHasNoErrors()
->assertSet('data.localization_default_locale', 'de');
expect(app(SettingsResolver::class)->resolveValue($workspace, LocaleResolver::SETTING_DOMAIN, LocaleResolver::SETTING_DEFAULT_LOCALE))
->toBe('de');
expect(WorkspaceSetting::query()
->where('workspace_id', (int) $workspace->getKey())
->where('domain', LocaleResolver::SETTING_DOMAIN)
->where('key', LocaleResolver::SETTING_DEFAULT_LOCALE)
->exists())->toBeTrue();
});
it('keeps workspace default locale authorization aligned to settings capabilities', function (): void {
[$workspace, $user] = localizationWorkspaceMember('readonly');
$this->actingAs($user)
->get(WorkspaceSettings::getUrl(panel: 'admin'))
->assertSuccessful();
Livewire::actingAs($user)
->test(WorkspaceSettings::class)
->assertSet('data.localization_default_locale', null)
->assertActionVisible('save')
->assertActionDisabled('save')
->call('save')
->assertStatus(403);
});

View File

@ -5,16 +5,13 @@
use App\Filament\Pages\Workspaces\ManagedTenantOnboardingWizard; use App\Filament\Pages\Workspaces\ManagedTenantOnboardingWizard;
use App\Models\AuditLog; use App\Models\AuditLog;
use App\Models\OperationRun; use App\Models\OperationRun;
use App\Models\PlatformUser;
use App\Models\ProviderConnection; use App\Models\ProviderConnection;
use App\Models\Tenant; use App\Models\Tenant;
use App\Models\TenantOnboardingSession; use App\Models\TenantOnboardingSession;
use App\Models\User; use App\Models\User;
use App\Models\Workspace; use App\Models\Workspace;
use App\Services\Entitlements\WorkspaceCommercialLifecycleResolver;
use App\Services\Entitlements\WorkspaceEntitlementResolver; use App\Services\Entitlements\WorkspaceEntitlementResolver;
use App\Services\Settings\SettingsWriter; use App\Services\Settings\SettingsWriter;
use App\Support\Auth\PlatformCapabilities;
use App\Support\OperationRunOutcome; use App\Support\OperationRunOutcome;
use App\Support\OperationRunStatus; use App\Support\OperationRunStatus;
use App\Support\Workspaces\WorkspaceContext; use App\Support\Workspaces\WorkspaceContext;
@ -24,12 +21,7 @@
/** /**
* @return array{workspace: Workspace, user: User, tenant: Tenant, draft: TenantOnboardingSession, component: \Livewire\Features\SupportTesting\Testable} * @return array{workspace: Workspace, user: User, tenant: Tenant, draft: TenantOnboardingSession, component: \Livewire\Features\SupportTesting\Testable}
*/ */
function readyOnboardingEntitlementContext( function readyOnboardingEntitlementContext(int $activeTenantCount = 0, ?int $limitOverride = null, ?string $overrideReason = null): array
int $activeTenantCount = 0,
?int $limitOverride = null,
?string $overrideReason = null,
?string $commercialState = null,
): array
{ {
Queue::fake(); Queue::fake();
@ -118,22 +110,6 @@ function readyOnboardingEntitlementContext(
} }
} }
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()); session()->put(WorkspaceContext::SESSION_KEY, (int) $workspace->getKey());
$component = Livewire::actingAs($user)->test(ManagedTenantOnboardingWizard::class, [ $component = Livewire::actingAs($user)->test(ManagedTenantOnboardingWizard::class, [
@ -212,65 +188,3 @@ function readyOnboardingEntitlementContext(
expect($context['tenant']->status)->toBe(Tenant::STATUS_ACTIVE); 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();
});

View File

@ -27,10 +27,3 @@
->toContain(".:/var/www/repo:ro") ->toContain(".:/var/www/repo:ro")
->toContain('TENANTATLAS_REPO_ROOT: /var/www/repo'); ->toContain('TENANTATLAS_REPO_ROOT: /var/www/repo');
}); });
it('keeps the local queue service in code-reloading listen mode', function (): void {
$compose = file_get_contents(repo_path('docker-compose.yml'));
expect($compose)->toContain('command: php artisan queue:listen --tries=3 --timeout=300 --sleep=3')
->not->toContain('command: php artisan queue:work --tries=3 --timeout=300 --sleep=3 --max-jobs=1000');
});

View File

@ -3,13 +3,7 @@
declare(strict_types=1); declare(strict_types=1);
use App\Models\ReviewPack; use App\Models\ReviewPack;
use App\Models\AuditLog;
use App\Models\PlatformUser;
use App\Services\Entitlements\WorkspaceCommercialLifecycleResolver;
use App\Services\ReviewPackService; use App\Services\ReviewPackService;
use App\Services\Settings\SettingsWriter;
use App\Support\Audit\AuditActionId;
use App\Support\Auth\PlatformCapabilities;
use App\Support\ReviewPackStatus; use App\Support\ReviewPackStatus;
use Illuminate\Foundation\Testing\RefreshDatabase; use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Storage; use Illuminate\Support\Facades\Storage;
@ -42,56 +36,12 @@ function createReadyPackWithFile(?array $packOverrides = []): array
return [$user, $tenant, $pack]; return [$user, $tenant, $pack];
} }
function suspendReadyPackWorkspaceForDownloadTest(ReviewPack $pack): void
{
app(SettingsWriter::class)->updateWorkspaceCommercialLifecycle(
actor: PlatformUser::factory()->create([
'capabilities' => [
PlatformCapabilities::ACCESS_SYSTEM_PANEL,
PlatformCapabilities::DIRECTORY_VIEW,
PlatformCapabilities::COMMERCIAL_LIFECYCLE_MANAGE,
],
'is_active' => true,
]),
workspace: $pack->workspace,
state: WorkspaceCommercialLifecycleResolver::STATE_SUSPENDED_READ_ONLY,
reason: 'Download preservation test',
);
}
// ─── Happy Path: Signed URL → 200 ─────────────────────────── // ─── Happy Path: Signed URL → 200 ───────────────────────────
it('downloads a ready pack via signed URL with correct headers', function (): void { it('downloads a ready pack via signed URL with correct headers', function (): void {
[$user, $tenant, $pack] = createReadyPackWithFile(); [$user, $tenant, $pack] = createReadyPackWithFile();
$signedUrl = app(ReviewPackService::class)->generateDownloadUrl($pack, [ $signedUrl = app(ReviewPackService::class)->generateDownloadUrl($pack);
'source_surface' => 'customer_review_workspace',
]);
$response = $this->actingAs($user)->get($signedUrl);
$response->assertOk();
$response->assertHeader('X-Review-Pack-SHA256', $pack->sha256);
$response->assertDownload();
$audit = AuditLog::query()
->where('action', AuditActionId::ReviewPackDownloaded->value)
->latest('id')
->first();
expect($audit)->not->toBeNull()
->and($audit?->resource_type)->toBe('review_pack')
->and(data_get($audit?->metadata, 'review_pack_id'))->toBe((int) $pack->getKey())
->and(data_get($audit?->metadata, 'source_surface'))->toBe('customer_review_workspace');
});
it('keeps ready pack downloads available while the workspace is suspended read-only', function (): void {
[$user, $tenant, $pack] = createReadyPackWithFile();
suspendReadyPackWorkspaceForDownloadTest($pack);
$signedUrl = app(ReviewPackService::class)->generateDownloadUrl($pack, [
'source_surface' => 'suspended_read_only_check',
]);
$response = $this->actingAs($user)->get($signedUrl); $response = $this->actingAs($user)->get($signedUrl);

View File

@ -8,20 +8,16 @@
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\Models\User; use App\Models\User;
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\Services\Settings\SettingsWriter;
use App\Support\Auth\PlatformCapabilities;
use App\Support\Evidence\EvidenceSnapshotStatus; use App\Support\Evidence\EvidenceSnapshotStatus;
use App\Support\OperationRunType; use App\Support\OperationRunType;
use App\Support\Workspaces\WorkspaceContext; use App\Support\Workspaces\WorkspaceContext;
use Illuminate\Support\Facades\Notification;
use Illuminate\Support\Facades\Storage; use Illuminate\Support\Facades\Storage;
use Livewire\Livewire; use Livewire\Livewire;
@ -112,23 +108,6 @@ function disableReviewPackGenerationForWorkspace(Tenant $tenant, User $user, str
); );
} }
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 { 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'); [$user, $tenant] = createUserWithTenant(role: 'owner');
seedEntitlementReviewPackSnapshot($tenant); seedEntitlementReviewPackSnapshot($tenant);
@ -209,86 +188,3 @@ function setReviewPackCommercialLifecycleState(Tenant $tenant, string $state, st
->assertOk() ->assertOk()
->assertSee('Download'); ->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();
});

View File

@ -3,23 +3,18 @@
declare(strict_types=1); declare(strict_types=1);
use App\Exceptions\ReviewPackEvidenceResolutionException; use App\Exceptions\ReviewPackEvidenceResolutionException;
use App\Exceptions\Entitlements\WorkspaceEntitlementBlockedException;
use App\Filament\Widgets\Tenant\TenantReviewPackCard; use App\Filament\Widgets\Tenant\TenantReviewPackCard;
use App\Jobs\GenerateReviewPackJob; use App\Jobs\GenerateReviewPackJob;
use App\Models\EvidenceSnapshot; use App\Models\EvidenceSnapshot;
use App\Models\Finding; use App\Models\Finding;
use App\Models\OperationRun; use App\Models\OperationRun;
use App\Models\PlatformUser;
use App\Models\ReviewPack; use App\Models\ReviewPack;
use App\Models\StoredReport; use App\Models\StoredReport;
use App\Models\Tenant; use App\Models\Tenant;
use App\Notifications\OperationRunCompleted; use App\Notifications\OperationRunCompleted;
use App\Notifications\OperationRunQueued; use App\Notifications\OperationRunQueued;
use App\Services\Entitlements\WorkspaceCommercialLifecycleResolver;
use App\Services\Evidence\EvidenceSnapshotService; use App\Services\Evidence\EvidenceSnapshotService;
use App\Services\ReviewPackService; use App\Services\ReviewPackService;
use App\Services\Settings\SettingsWriter;
use App\Support\Auth\PlatformCapabilities;
use App\Support\Evidence\EvidenceSnapshotStatus; use App\Support\Evidence\EvidenceSnapshotStatus;
use App\Support\OperationRunOutcome; use App\Support\OperationRunOutcome;
use App\Support\OperationRunStatus; use App\Support\OperationRunStatus;
@ -162,23 +157,6 @@ function createEvidenceSnapshotForReviewPack(Tenant $tenant): EvidenceSnapshot
return $snapshot->load('items'); return $snapshot->load('items');
} }
function suspendReviewPackGenerationWorkspaceForGenerationTest(Tenant $tenant): void
{
app(SettingsWriter::class)->updateWorkspaceCommercialLifecycle(
actor: PlatformUser::factory()->create([
'capabilities' => [
PlatformCapabilities::ACCESS_SYSTEM_PANEL,
PlatformCapabilities::DIRECTORY_VIEW,
PlatformCapabilities::COMMERCIAL_LIFECYCLE_MANAGE,
],
'is_active' => true,
]),
workspace: $tenant->workspace,
state: WorkspaceCommercialLifecycleResolver::STATE_SUSPENDED_READ_ONLY,
reason: 'Generation notification boundary test',
);
}
// ─── Happy Path ────────────────────────────────────────────── // ─── Happy Path ──────────────────────────────────────────────
it('generates a review pack end-to-end (happy path)', function (): void { it('generates a review pack end-to-end (happy path)', function (): void {
@ -232,22 +210,6 @@ function suspendReviewPackGenerationWorkspaceForGenerationTest(Tenant $tenant):
Notification::assertSentTo($user, OperationRunCompleted::class); Notification::assertSentTo($user, OperationRunCompleted::class);
}); });
it('does not send queued or terminal run notifications when suspended read-only blocks generation', function (): void {
[$user, $tenant] = createUserWithTenant();
seedTenantWithData($tenant);
createEvidenceSnapshotForReviewPack($tenant);
suspendReviewPackGenerationWorkspaceForGenerationTest($tenant);
Notification::fake();
expect(fn (): ReviewPack => app(ReviewPackService::class)->generate($tenant, $user))
->toThrow(WorkspaceEntitlementBlockedException::class, 'suspended / read-only');
Notification::assertNotSentTo($user, OperationRunQueued::class);
Notification::assertNotSentTo($user, OperationRunCompleted::class);
});
// ─── Failure Path ────────────────────────────────────────────── // ─── Failure Path ──────────────────────────────────────────────
it('marks pack as failed when generation throws an exception', function (): void { it('marks pack as failed when generation throws an exception', function (): void {

View File

@ -77,9 +77,11 @@ function getReviewPackRbacEmptyStateAction(Testable $component, string $name): ?
'file_disk' => 'exports', 'file_disk' => 'exports',
]); ]);
// Note: download route uses signed middleware, not tenant-scoped RBAC.
// Any user with a valid signature can download. This is by design.
$signedUrl = app(ReviewPackService::class)->generateDownloadUrl($pack); $signedUrl = app(ReviewPackService::class)->generateDownloadUrl($pack);
$this->actingAs($user)->get($signedUrl)->assertNotFound(); $this->actingAs($user)->get($signedUrl)->assertOk();
}); });
// ─── REVIEW_PACK_VIEW Member ──────────────────────────────── // ─── REVIEW_PACK_VIEW Member ────────────────────────────────

View File

@ -1,66 +0,0 @@
<?php
declare(strict_types=1);
use App\Filament\Pages\Reviews\CustomerReviewWorkspace;
use App\Models\Tenant;
use App\Models\User;
use App\Models\Workspace;
use App\Models\WorkspaceMembership;
use App\Support\Workspaces\WorkspaceContext;
use Illuminate\Foundation\Testing\RefreshDatabase;
uses(RefreshDatabase::class);
it('returns 404 for users outside the active workspace on the customer review workspace', function (): void {
$workspace = Workspace::factory()->create();
$user = User::factory()->create();
$this->actingAs($user)
->withSession([WorkspaceContext::SESSION_KEY => (int) $workspace->getKey()])
->get(CustomerReviewWorkspace::getUrl(panel: 'admin'))
->assertNotFound();
});
it('returns 404 for workspace members that have no tenant review visibility in the active workspace', function (): void {
$workspace = Workspace::factory()->create();
$user = User::factory()->create();
WorkspaceMembership::factory()->create([
'workspace_id' => (int) $workspace->getKey(),
'user_id' => (int) $user->getKey(),
'role' => 'owner',
]);
$this->actingAs($user)
->withSession([WorkspaceContext::SESSION_KEY => (int) $workspace->getKey()])
->get(CustomerReviewWorkspace::getUrl(panel: 'admin'))
->assertNotFound();
});
it('allows entitled workspace members to access the customer review workspace', function (): void {
$tenant = Tenant::factory()->create();
[$user, $tenant] = createUserWithTenant(tenant: $tenant, role: 'readonly');
$this->actingAs($user)
->withSession([WorkspaceContext::SESSION_KEY => (int) $tenant->workspace_id])
->get(CustomerReviewWorkspace::getUrl(panel: 'admin'))
->assertOk();
});
it('returns 404 for explicit out-of-scope tenant targeting on the customer review workspace', function (): void {
$tenantAllowed = Tenant::factory()->create(['name' => 'Allowed Tenant']);
[$user, $tenantAllowed] = createUserWithTenant(tenant: $tenantAllowed, role: 'readonly');
$tenantDenied = Tenant::factory()->create([
'workspace_id' => (int) $tenantAllowed->workspace_id,
'name' => 'Denied Tenant',
]);
$otherOwner = User::factory()->create();
createUserWithTenant(tenant: $tenantDenied, user: $otherOwner, role: 'owner');
$this->actingAs($user)
->withSession([WorkspaceContext::SESSION_KEY => (int) $tenantAllowed->workspace_id])
->get(CustomerReviewWorkspace::getUrl(panel: 'admin').'?tenant='.(string) $tenantDenied->getKey())
->assertNotFound();
});

View File

@ -1,160 +0,0 @@
<?php
declare(strict_types=1);
use App\Filament\Pages\Reviews\CustomerReviewWorkspace;
use App\Filament\Resources\EvidenceSnapshotResource;
use App\Filament\Resources\ReviewPackResource;
use App\Filament\Resources\TenantReviewResource\Pages\ViewTenantReview;
use App\Filament\Resources\TenantReviewResource;
use App\Filament\Widgets\Tenant\TenantReviewPackCard;
use App\Models\AuditLog;
use App\Models\EvidenceSnapshot;
use App\Models\ReviewPack;
use App\Models\Tenant;
use App\Models\TenantReview;
use App\Support\Audit\AuditActionId;
use App\Support\Evidence\EvidenceSnapshotStatus;
use App\Support\TenantReviewStatus;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Storage;
use Livewire\Livewire;
uses(RefreshDatabase::class);
beforeEach(function (): void {
Storage::fake('exports');
});
it('renders a customer workspace link from tenant review detail context', function (): void {
$tenant = Tenant::factory()->create();
[$user, $tenant] = createUserWithTenant(tenant: $tenant, role: 'owner');
$snapshot = seedTenantReviewEvidence($tenant);
$review = composeTenantReviewForTest($tenant, $user, $snapshot);
$review->forceFill([
'status' => TenantReviewStatus::Published->value,
'published_at' => now(),
'published_by_user_id' => (int) $user->getKey(),
])->save();
$this->actingAs($user)
->get(TenantReviewResource::tenantScopedUrl('view', ['record' => $review], $tenant))
->assertOk()
->assertSee(CustomerReviewWorkspace::tenantPrefilterUrl($tenant), false);
});
it('adds a customer workspace entry to evidence snapshot related context', function (): void {
$tenant = Tenant::factory()->create();
$snapshot = EvidenceSnapshot::query()->create([
'tenant_id' => (int) $tenant->getKey(),
'workspace_id' => (int) $tenant->workspace_id,
'status' => EvidenceSnapshotStatus::Active->value,
'summary' => [],
'generated_at' => now(),
]);
$entry = collect(EvidenceSnapshotResource::relatedContextEntries($snapshot))
->firstWhere('key', 'customer_review_workspace');
expect($entry)->not->toBeNull()
->and($entry['targetUrl'] ?? null)->toBe(CustomerReviewWorkspace::tenantPrefilterUrl($tenant));
});
it('renders a customer workspace link from review pack detail context', function (): void {
$tenant = Tenant::factory()->create();
[$user, $tenant] = createUserWithTenant(tenant: $tenant, role: 'owner');
$snapshot = seedTenantReviewEvidence($tenant);
$review = composeTenantReviewForTest($tenant, $user, $snapshot);
$review->forceFill([
'status' => TenantReviewStatus::Published->value,
'published_at' => now(),
'published_by_user_id' => (int) $user->getKey(),
])->save();
Storage::disk('exports')->put('review-packs/customer-workspace-link.zip', 'PK-test');
$pack = ReviewPack::factory()->ready()->create([
'tenant_id' => (int) $tenant->getKey(),
'workspace_id' => (int) $tenant->workspace_id,
'tenant_review_id' => (int) $review->getKey(),
'evidence_snapshot_id' => (int) $snapshot->getKey(),
'initiated_by_user_id' => (int) $user->getKey(),
'file_path' => 'review-packs/customer-workspace-link.zip',
'file_disk' => 'exports',
]);
$this->actingAs($user)
->get(ReviewPackResource::getUrl('view', ['record' => $pack], tenant: $tenant, panel: 'tenant'))
->assertOk()
->assertSee(CustomerReviewWorkspace::tenantPrefilterUrl($tenant), false);
});
it('renders a customer workspace launch button on the tenant review pack widget', function (): void {
$tenant = Tenant::factory()->create();
[$user, $tenant] = createUserWithTenant(tenant: $tenant, role: 'owner');
$snapshot = seedTenantReviewEvidence($tenant);
$review = composeTenantReviewForTest($tenant, $user, $snapshot);
$review->forceFill([
'status' => TenantReviewStatus::Published->value,
'published_at' => now(),
'published_by_user_id' => (int) $user->getKey(),
])->save();
Storage::disk('exports')->put('review-packs/widget-customer-workspace.zip', 'PK-test');
ReviewPack::factory()->ready()->create([
'tenant_id' => (int) $tenant->getKey(),
'workspace_id' => (int) $tenant->workspace_id,
'tenant_review_id' => (int) $review->getKey(),
'evidence_snapshot_id' => (int) $snapshot->getKey(),
'initiated_by_user_id' => (int) $user->getKey(),
'file_path' => 'review-packs/widget-customer-workspace.zip',
'file_disk' => 'exports',
]);
setTenantPanelContext($tenant);
Livewire::actingAs($user)
->test(TenantReviewPackCard::class, ['record' => $tenant])
->assertSee('Customer workspace')
->assertSee(CustomerReviewWorkspace::tenantPrefilterUrl($tenant), false);
});
it('keeps the linked tenant review detail read-only for a readonly-capable actor', function (): void {
$tenant = Tenant::factory()->create();
[$user, $tenant] = createUserWithTenant(tenant: $tenant, role: 'readonly');
$snapshot = seedTenantReviewEvidence($tenant);
$review = composeTenantReviewForTest($tenant, $user, $snapshot);
$review->forceFill([
'status' => TenantReviewStatus::Published->value,
'published_at' => now(),
'published_by_user_id' => (int) $user->getKey(),
])->save();
setTenantPanelContext($tenant);
Livewire::withQueryParams([CustomerReviewWorkspace::DETAIL_CONTEXT_QUERY_KEY => 1])
->actingAs($user)
->test(ViewTenantReview::class, ['record' => $review->getKey()])
->assertSee('Outcome summary')
->assertActionDoesNotExist('publish_review')
->assertActionDoesNotExist('refresh_review')
->assertActionDoesNotExist('create_next_review')
->assertActionDoesNotExist('export_executive_pack')
->assertActionHidden('archive_review');
$audit = AuditLog::query()
->where('action', AuditActionId::TenantReviewOpened->value)
->latest('id')
->first();
expect($audit)->not->toBeNull()
->and($audit?->resource_type)->toBe('tenant_review')
->and(data_get($audit?->metadata, 'review_id'))->toBe((int) $review->getKey())
->and(data_get($audit?->metadata, 'source_surface'))->toBe('customer_review_workspace');
});

View File

@ -1,156 +0,0 @@
<?php
declare(strict_types=1);
use App\Filament\Pages\Reviews\CustomerReviewWorkspace;
use App\Models\PlatformUser;
use App\Models\ReviewPack;
use App\Models\Tenant;
use App\Services\Entitlements\WorkspaceCommercialLifecycleResolver;
use App\Services\Settings\SettingsWriter;
use App\Support\Auth\PlatformCapabilities;
use App\Support\TenantReviewStatus;
use App\Support\Workspaces\WorkspaceContext;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Livewire\Livewire;
uses(RefreshDatabase::class);
function suspendCustomerReviewWorkspacePackAccessWorkspace(Tenant $tenant): void
{
app(SettingsWriter::class)->updateWorkspaceCommercialLifecycle(
actor: PlatformUser::factory()->create([
'capabilities' => [
PlatformCapabilities::ACCESS_SYSTEM_PANEL,
PlatformCapabilities::DIRECTORY_VIEW,
PlatformCapabilities::COMMERCIAL_LIFECYCLE_MANAGE,
],
'is_active' => true,
]),
workspace: $tenant->workspace,
state: WorkspaceCommercialLifecycleResolver::STATE_SUSPENDED_READ_ONLY,
reason: 'Customer review workspace suspended read-only test',
);
}
it('shows the ready review-pack action for the latest published review', function (): void {
$tenant = Tenant::factory()->create();
[$user, $tenant] = createUserWithTenant(tenant: $tenant, role: 'readonly');
$snapshot = seedTenantReviewEvidence($tenant);
$review = composeTenantReviewForTest($tenant, $user, $snapshot);
$review->forceFill([
'status' => TenantReviewStatus::Published->value,
'published_at' => now(),
'published_by_user_id' => (int) $user->getKey(),
])->save();
$pack = ReviewPack::factory()->ready()->create([
'tenant_id' => (int) $tenant->getKey(),
'workspace_id' => (int) $tenant->workspace_id,
'tenant_review_id' => (int) $review->getKey(),
'evidence_snapshot_id' => (int) $snapshot->getKey(),
'initiated_by_user_id' => (int) $user->getKey(),
'expires_at' => now()->addDay(),
]);
$review->forceFill([
'current_export_review_pack_id' => (int) $pack->getKey(),
])->save();
$this->actingAs($user);
setAdminPanelContext();
session()->put(WorkspaceContext::SESSION_KEY, (int) $tenant->workspace_id);
Livewire::actingAs($user)
->test(CustomerReviewWorkspace::class)
->assertTableActionVisible('open_latest_review', $tenant)
->assertTableActionVisible('download_review_pack', $tenant)
->assertSee('Available');
});
it('keeps customer review workspace and pack actions visible while suspended read-only', function (): void {
$tenant = Tenant::factory()->create();
[$user, $tenant] = createUserWithTenant(tenant: $tenant, role: 'readonly');
$snapshot = seedTenantReviewEvidence($tenant);
$review = composeTenantReviewForTest($tenant, $user, $snapshot);
$review->forceFill([
'status' => TenantReviewStatus::Published->value,
'published_at' => now(),
'published_by_user_id' => (int) $user->getKey(),
])->save();
$pack = ReviewPack::factory()->ready()->create([
'tenant_id' => (int) $tenant->getKey(),
'workspace_id' => (int) $tenant->workspace_id,
'tenant_review_id' => (int) $review->getKey(),
'evidence_snapshot_id' => (int) $snapshot->getKey(),
'initiated_by_user_id' => (int) $user->getKey(),
'expires_at' => now()->addDay(),
]);
$review->forceFill([
'current_export_review_pack_id' => (int) $pack->getKey(),
])->save();
suspendCustomerReviewWorkspacePackAccessWorkspace($tenant);
$this->actingAs($user);
setAdminPanelContext();
session()->put(WorkspaceContext::SESSION_KEY, (int) $tenant->workspace_id);
Livewire::actingAs($user)
->test(CustomerReviewWorkspace::class)
->assertTableActionVisible('open_latest_review', $tenant)
->assertTableActionVisible('download_review_pack', $tenant)
->assertSee('Available');
});
it('shows an unavailable pack state and hides the download action when no current review pack exists', function (): void {
$tenant = Tenant::factory()->create();
[$user, $tenant] = createUserWithTenant(tenant: $tenant, role: 'readonly');
$snapshot = seedTenantReviewEvidence($tenant);
$review = composeTenantReviewForTest($tenant, $user, $snapshot);
$review->forceFill([
'status' => TenantReviewStatus::Published->value,
'published_at' => now(),
'published_by_user_id' => (int) $user->getKey(),
'current_export_review_pack_id' => null,
])->save();
$this->actingAs($user);
setAdminPanelContext();
session()->put(WorkspaceContext::SESSION_KEY, (int) $tenant->workspace_id);
Livewire::actingAs($user)
->test(CustomerReviewWorkspace::class)
->assertTableActionVisible('open_latest_review', $tenant)
->assertTableActionHidden('download_review_pack', $tenant)
->assertSee('Unavailable');
});
it('hides review and pack actions for tenants without a published review', function (): void {
$tenant = Tenant::factory()->create();
[$user, $tenant] = createUserWithTenant(tenant: $tenant, role: 'readonly');
$snapshot = seedTenantReviewEvidence($tenant);
$review = composeTenantReviewForTest($tenant, $user, $snapshot);
$review->forceFill([
'status' => TenantReviewStatus::Ready->value,
'published_at' => null,
'published_by_user_id' => null,
'current_export_review_pack_id' => null,
])->save();
$this->actingAs($user);
setAdminPanelContext();
session()->put(WorkspaceContext::SESSION_KEY, (int) $tenant->workspace_id);
Livewire::actingAs($user)
->test(CustomerReviewWorkspace::class)
->assertTableActionHidden('open_latest_review', $tenant)
->assertTableActionHidden('download_review_pack', $tenant)
->assertSee('No published review available yet');
});

View File

@ -1,222 +0,0 @@
<?php
declare(strict_types=1);
use App\Filament\Pages\Reviews\CustomerReviewWorkspace;
use App\Filament\Resources\TenantReviewResource;
use App\Models\Tenant;
use App\Models\User;
use App\Support\TenantReviewStatus;
use App\Support\Workspaces\WorkspaceContext;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Livewire\Livewire;
uses(RefreshDatabase::class);
it('lists only the latest published review per entitled tenant on the customer review workspace', function (): void {
$tenantA = Tenant::factory()->create(['name' => 'Alpha Tenant']);
[$user, $tenantA] = createUserWithTenant(tenant: $tenantA, role: 'readonly');
$tenantB = Tenant::factory()->create([
'workspace_id' => (int) $tenantA->workspace_id,
'name' => 'Beta Tenant',
]);
createUserWithTenant(tenant: $tenantB, user: $user, role: 'readonly');
$tenantDenied = Tenant::factory()->create([
'workspace_id' => (int) $tenantA->workspace_id,
'name' => 'Denied Tenant',
]);
$otherOwner = User::factory()->create();
createUserWithTenant(tenant: $tenantDenied, user: $otherOwner, role: 'owner');
$tenantASnapshot = seedTenantReviewEvidence($tenantA);
$tenantBSnapshot = seedTenantReviewEvidence($tenantB);
$tenantDeniedSnapshot = seedTenantReviewEvidence($tenantDenied);
$olderPublishedReview = composeTenantReviewForTest($tenantA, $user, $tenantASnapshot);
$olderPublishedReview->forceFill([
'status' => TenantReviewStatus::Published->value,
'generated_at' => now()->subDays(3),
'published_at' => now()->subDays(3),
'published_by_user_id' => (int) $user->getKey(),
])->save();
$newerInternalReview = $olderPublishedReview->replicate();
$newerInternalReview->forceFill([
'tenant_id' => (int) $tenantA->getKey(),
'workspace_id' => (int) $tenantA->workspace_id,
'evidence_snapshot_id' => (int) $tenantASnapshot->getKey(),
'status' => TenantReviewStatus::Ready->value,
'generated_at' => now()->subDay(),
'published_at' => null,
'published_by_user_id' => null,
])->save();
$latestPublishedReview = $olderPublishedReview->replicate();
$latestPublishedReview->forceFill([
'tenant_id' => (int) $tenantA->getKey(),
'workspace_id' => (int) $tenantA->workspace_id,
'evidence_snapshot_id' => (int) $tenantASnapshot->getKey(),
'status' => TenantReviewStatus::Published->value,
'generated_at' => now(),
'published_at' => now(),
'published_by_user_id' => (int) $user->getKey(),
])->save();
$betaPublishedReview = composeTenantReviewForTest($tenantB, $user, $tenantBSnapshot);
$betaPublishedReview->forceFill([
'status' => TenantReviewStatus::Published->value,
'generated_at' => now()->subHours(2),
'published_at' => now()->subHours(2),
'published_by_user_id' => (int) $user->getKey(),
])->save();
$deniedPublishedReview = composeTenantReviewForTest($tenantDenied, $otherOwner, $tenantDeniedSnapshot);
$deniedPublishedReview->forceFill([
'status' => TenantReviewStatus::Published->value,
'generated_at' => now()->subHours(3),
'published_at' => now()->subHours(3),
'published_by_user_id' => (int) $otherOwner->getKey(),
])->save();
$this->actingAs($user);
setAdminPanelContext();
session()->put(WorkspaceContext::SESSION_KEY, (int) $tenantA->workspace_id);
Livewire::actingAs($user)
->test(CustomerReviewWorkspace::class)
->assertCanSeeTableRecords([$tenantA->fresh(), $tenantB->fresh()])
->assertCanNotSeeTableRecords([$tenantDenied->fresh()])
->assertSee(TenantReviewResource::tenantScopedUrl('view', ['record' => $latestPublishedReview->fresh()], $tenantA), false)
->assertSee(TenantReviewResource::tenantScopedUrl('view', ['record' => $betaPublishedReview->fresh()], $tenantB), false)
->assertDontSee(TenantReviewResource::tenantScopedUrl('view', ['record' => $olderPublishedReview->fresh()], $tenantA), false)
->assertDontSee(TenantReviewResource::tenantScopedUrl('view', ['record' => $newerInternalReview->fresh()], $tenantA), false)
->assertDontSee('Publish review')
->assertDontSee('Refresh review')
->assertDontSee('Create next review')
->assertDontSee('Regenerate')
->assertDontSee('Expire snapshot')
->assertDontSee(TenantReviewResource::tenantScopedUrl('view', ['record' => $deniedPublishedReview->fresh()], $tenantDenied), false);
});
it('shows entitled tenants without a published review as calm absence rows', function (): void {
$tenantPublished = Tenant::factory()->create(['name' => 'Published Tenant']);
[$user, $tenantPublished] = createUserWithTenant(tenant: $tenantPublished, role: 'readonly');
$tenantWithoutPublished = Tenant::factory()->create([
'workspace_id' => (int) $tenantPublished->workspace_id,
'name' => 'No Published Tenant',
]);
createUserWithTenant(tenant: $tenantWithoutPublished, user: $user, role: 'readonly');
$publishedSnapshot = seedTenantReviewEvidence($tenantPublished);
$noPublishedSnapshot = seedTenantReviewEvidence($tenantWithoutPublished);
$publishedReview = composeTenantReviewForTest($tenantPublished, $user, $publishedSnapshot);
$publishedReview->forceFill([
'status' => TenantReviewStatus::Published->value,
'published_at' => now()->subHour(),
'published_by_user_id' => (int) $user->getKey(),
])->save();
$internalOnlyReview = composeTenantReviewForTest($tenantWithoutPublished, $user, $noPublishedSnapshot);
$internalOnlyReview->forceFill([
'status' => TenantReviewStatus::Ready->value,
'published_at' => null,
'published_by_user_id' => null,
])->save();
$this->actingAs($user);
setAdminPanelContext();
session()->put(WorkspaceContext::SESSION_KEY, (int) $tenantPublished->workspace_id);
Livewire::actingAs($user)
->test(CustomerReviewWorkspace::class)
->assertCanSeeTableRecords([$tenantPublished->fresh(), $tenantWithoutPublished->fresh()])
->assertSee('No published review')
->assertSee('No published review available yet')
->assertDontSee(TenantReviewResource::tenantScopedUrl('view', ['record' => $internalOnlyReview->fresh()], $tenantWithoutPublished), false)
->assertSee(TenantReviewResource::tenantScopedUrl('view', ['record' => $publishedReview->fresh()], $tenantPublished), false);
});
it('defaults the customer review workspace to the remembered tenant when tenant context is available', function (): void {
$tenantA = Tenant::factory()->create(['name' => 'Alpha Tenant']);
[$user, $tenantA] = createUserWithTenant(tenant: $tenantA, role: 'readonly');
$tenantB = Tenant::factory()->create([
'workspace_id' => (int) $tenantA->workspace_id,
'name' => 'Beta Tenant',
]);
createUserWithTenant(tenant: $tenantB, user: $user, role: 'readonly');
$snapshotA = seedTenantReviewEvidence($tenantA);
$snapshotB = seedTenantReviewEvidence($tenantB);
$reviewA = composeTenantReviewForTest($tenantA, $user, $snapshotA);
$reviewA->forceFill([
'status' => TenantReviewStatus::Published->value,
'published_at' => now()->subDay(),
'published_by_user_id' => (int) $user->getKey(),
])->save();
$reviewB = composeTenantReviewForTest($tenantB, $user, $snapshotB);
$reviewB->forceFill([
'status' => TenantReviewStatus::Published->value,
'published_at' => now(),
'published_by_user_id' => (int) $user->getKey(),
])->save();
$this->actingAs($user);
setAdminPanelContext();
session()->put(WorkspaceContext::SESSION_KEY, (int) $tenantA->workspace_id);
session()->put(WorkspaceContext::LAST_TENANT_IDS_SESSION_KEY, [
(string) $tenantA->workspace_id => (int) $tenantB->getKey(),
]);
Livewire::actingAs($user)
->test(CustomerReviewWorkspace::class)
->assertSet('tableFilters.tenant_id.value', (string) $tenantB->getKey())
->filterTable('tenant_id', (string) $tenantB->getKey())
->assertCanSeeTableRecords([$tenantB->fresh()])
->assertCanNotSeeTableRecords([$tenantA->fresh()]);
});
it('prefilters the customer review workspace from an explicit tenant query parameter and accepts external tenant identifiers', function (): void {
$tenantA = Tenant::factory()->create(['name' => 'Alpha Tenant']);
[$user, $tenantA] = createUserWithTenant(tenant: $tenantA, role: 'readonly');
$tenantB = Tenant::factory()->create([
'workspace_id' => (int) $tenantA->workspace_id,
'name' => 'Beta Tenant',
]);
createUserWithTenant(tenant: $tenantB, user: $user, role: 'readonly');
$snapshotA = seedTenantReviewEvidence($tenantA);
$snapshotB = seedTenantReviewEvidence($tenantB);
$reviewA = composeTenantReviewForTest($tenantA, $user, $snapshotA);
$reviewA->forceFill([
'status' => TenantReviewStatus::Published->value,
'published_at' => now(),
'published_by_user_id' => (int) $user->getKey(),
])->save();
$reviewB = composeTenantReviewForTest($tenantB, $user, $snapshotB);
$reviewB->forceFill([
'status' => TenantReviewStatus::Published->value,
'published_at' => now()->subDay(),
'published_by_user_id' => (int) $user->getKey(),
])->save();
$this->actingAs($user);
setAdminPanelContext();
session()->put(WorkspaceContext::SESSION_KEY, (int) $tenantA->workspace_id);
Livewire::withQueryParams(['tenant' => (string) $tenantA->external_id])
->test(CustomerReviewWorkspace::class)
->assertSet('tableFilters.tenant_id.value', (string) $tenantA->getKey())
->filterTable('tenant_id', (string) $tenantA->getKey())
->assertCanSeeTableRecords([$tenantA->fresh()])
->assertCanNotSeeTableRecords([$tenantB->fresh()]);
});

View File

@ -5,9 +5,7 @@
use App\Models\OperationRun; use App\Models\OperationRun;
use App\Models\PlatformUser; use App\Models\PlatformUser;
use App\Models\User; use App\Models\User;
use App\Models\Workspace;
use App\Support\Auth\PlatformCapabilities; use App\Support\Auth\PlatformCapabilities;
use App\Support\System\SystemDirectoryLinks;
use App\Support\System\SystemOperationRunLinks; use App\Support\System\SystemOperationRunLinks;
use Illuminate\Foundation\Testing\RefreshDatabase; use Illuminate\Foundation\Testing\RefreshDatabase;
@ -121,38 +119,3 @@
->get('/system/ops/runbooks') ->get('/system/ops/runbooks')
->assertSuccessful(); ->assertSuccessful();
}); });
it('keeps system workspace detail route semantics separate from commercial business-state blocks', function (): void {
$workspace = Workspace::factory()->create();
$this->actingAs(User::factory()->create())
->get(SystemDirectoryLinks::workspaceDetail($workspace))
->assertNotFound();
auth()->guard('web')->logout();
$platformWithoutDirectoryView = PlatformUser::factory()->create([
'capabilities' => [
PlatformCapabilities::ACCESS_SYSTEM_PANEL,
],
'is_active' => true,
]);
$this->actingAs($platformWithoutDirectoryView, 'platform')
->get(SystemDirectoryLinks::workspaceDetail($workspace))
->assertForbidden();
$directoryViewer = PlatformUser::factory()->create([
'capabilities' => [
PlatformCapabilities::ACCESS_SYSTEM_PANEL,
PlatformCapabilities::DIRECTORY_VIEW,
],
'is_active' => true,
]);
$this->actingAs($directoryViewer, 'platform')
->get(SystemDirectoryLinks::workspaceDetail($workspace))
->assertSuccessful()
->assertSee('Commercial lifecycle')
->assertDontSee('Change commercial state');
});

View File

@ -3,25 +3,13 @@
declare(strict_types=1); declare(strict_types=1);
use App\Filament\System\Pages\Directory\ViewWorkspace; use App\Filament\System\Pages\Directory\ViewWorkspace;
use App\Models\AuditLog;
use App\Models\PlatformUser; use App\Models\PlatformUser;
use App\Models\Tenant; use App\Models\Tenant;
use App\Models\User; use App\Models\User;
use App\Models\Workspace; use App\Models\Workspace;
use App\Models\WorkspaceMembership; use App\Models\WorkspaceMembership;
use App\Models\WorkspaceSetting;
use App\Services\Entitlements\WorkspaceCommercialLifecycleResolver;
use App\Services\Settings\SettingsWriter; use App\Services\Settings\SettingsWriter;
use App\Support\Audit\AuditActionId;
use App\Support\Auth\PlatformCapabilities; 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 { it('renders the read-only workspace entitlement summary on the system workspace detail page', function (): void {
$workspace = Workspace::factory()->create(['name' => 'Acme Workspace']); $workspace = Workspace::factory()->create(['name' => 'Acme Workspace']);
@ -91,102 +79,5 @@
->assertSee('Pilot workspace') ->assertSee('Pilot workspace')
->assertSee('Escalation only') ->assertSee('Escalation only')
->assertSee('workspace override') ->assertSee('workspace override')
->assertSee('Commercial lifecycle')
->assertSee('Active paid')
->assertSee('default active paid')
->assertDontSee('Save'); ->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']);
});

View File

@ -164,25 +164,6 @@ function something()
// .. // ..
} }
/**
* @return array{0: Workspace, 1: User}
*/
function localizationWorkspaceMember(string $role = 'manager'): array
{
$workspace = Workspace::factory()->create();
$user = User::factory()->create();
WorkspaceMembership::factory()->create([
'workspace_id' => (int) $workspace->getKey(),
'user_id' => (int) $user->getKey(),
'role' => $role,
]);
session()->put(WorkspaceContext::SESSION_KEY, (int) $workspace->getKey());
return [$workspace, $user];
}
function repo_root(): string function repo_root(): string
{ {
$configuredRoot = env('TENANTATLAS_REPO_ROOT'); $configuredRoot = env('TENANTATLAS_REPO_ROOT');

View File

@ -1,20 +0,0 @@
<?php
declare(strict_types=1);
use App\Services\Entitlements\WorkspaceCommercialLifecycleResolver;
use App\Support\Badges\BadgeCatalog;
use App\Support\Badges\BadgeDomain;
it('maps commercial lifecycle states through the shared badge catalog', function (string $state, string $label, string $color): void {
$spec = BadgeCatalog::spec(BadgeDomain::CommercialLifecycleState, $state);
expect($spec->label)->toBe($label)
->and($spec->color)->toBe($color)
->and($spec->icon)->not->toBeNull();
})->with([
'trial' => [WorkspaceCommercialLifecycleResolver::STATE_TRIAL, 'Trial', 'info'],
'grace' => [WorkspaceCommercialLifecycleResolver::STATE_GRACE, 'Grace', 'warning'],
'active paid' => [WorkspaceCommercialLifecycleResolver::STATE_ACTIVE_PAID, 'Active paid', 'success'],
'suspended read-only' => [WorkspaceCommercialLifecycleResolver::STATE_SUSPENDED_READ_ONLY, 'Suspended / read-only', 'danger'],
]);

View File

@ -1,199 +0,0 @@
<?php
declare(strict_types=1);
use App\Models\PlatformUser;
use App\Models\Tenant;
use App\Models\User;
use App\Models\Workspace;
use App\Models\WorkspaceMembership;
use App\Services\Entitlements\WorkspaceCommercialLifecycleResolver;
use App\Services\Entitlements\WorkspaceEntitlementResolver;
use App\Services\Settings\SettingsWriter;
use App\Support\Auth\PlatformCapabilities;
use Illuminate\Foundation\Testing\RefreshDatabase;
uses(RefreshDatabase::class);
/**
* @return array{0: Workspace, 1: User}
*/
function commercialLifecycleWorkspaceManager(): array
{
$workspace = Workspace::factory()->create();
$user = User::factory()->create();
WorkspaceMembership::factory()->create([
'workspace_id' => (int) $workspace->getKey(),
'user_id' => (int) $user->getKey(),
'role' => 'manager',
]);
return [$workspace, $user];
}
function commercialLifecyclePlatformOperator(): PlatformUser
{
return PlatformUser::factory()->create([
'capabilities' => [
PlatformCapabilities::ACCESS_SYSTEM_PANEL,
PlatformCapabilities::DIRECTORY_VIEW,
PlatformCapabilities::COMMERCIAL_LIFECYCLE_MANAGE,
],
'is_active' => true,
]);
}
function setCommercialLifecycleState(Workspace $workspace, string $state, string $reason = 'Unit test commercial state change'): void
{
app(SettingsWriter::class)->updateWorkspaceCommercialLifecycle(
actor: commercialLifecyclePlatformOperator(),
workspace: $workspace,
state: $state,
reason: $reason,
);
}
it('falls back to active paid when no explicit commercial lifecycle setting exists', function (): void {
[$workspace] = commercialLifecycleWorkspaceManager();
$summary = app(WorkspaceCommercialLifecycleResolver::class)->summary($workspace);
expect($summary)
->toMatchArray([
'state' => WorkspaceCommercialLifecycleResolver::STATE_ACTIVE_PAID,
'state_label' => 'Active paid',
'source' => WorkspaceCommercialLifecycleResolver::SOURCE_DEFAULT_ACTIVE_PAID,
'source_label' => 'default active paid',
'rationale' => null,
])
->and($summary['last_changed_at'])->toBeNull()
->and($summary['last_changed_by'])->toBeNull()
->and($summary['action_decisions'][WorkspaceCommercialLifecycleResolver::ACTION_MANAGED_TENANT_ACTIVATION]['outcome'])
->toBe(WorkspaceCommercialLifecycleResolver::OUTCOME_ALLOW)
->and($summary['action_decisions'][WorkspaceCommercialLifecycleResolver::ACTION_REVIEW_PACK_START]['outcome'])
->toBe(WorkspaceCommercialLifecycleResolver::OUTCOME_ALLOW);
});
it('resolves explicit stored commercial lifecycle states with source rationale and platform attribution', function (string $state, string $expectedLabel): void {
[$workspace] = commercialLifecycleWorkspaceManager();
$operator = commercialLifecyclePlatformOperator();
app(SettingsWriter::class)->updateWorkspaceCommercialLifecycle(
actor: $operator,
workspace: $workspace,
state: $state,
reason: 'Support approved commercial lifecycle transition',
);
$summary = app(WorkspaceCommercialLifecycleResolver::class)->summary($workspace);
expect($summary)
->toMatchArray([
'state' => $state,
'state_label' => $expectedLabel,
'source' => WorkspaceCommercialLifecycleResolver::SOURCE_WORKSPACE_SETTING,
'source_label' => 'workspace setting',
'rationale' => 'Support approved commercial lifecycle transition',
'last_changed_by' => $operator->name,
])
->and($summary['last_changed_at'])->not->toBeNull();
})->with([
'trial' => [WorkspaceCommercialLifecycleResolver::STATE_TRIAL, 'Trial'],
'grace' => [WorkspaceCommercialLifecycleResolver::STATE_GRACE, 'Grace'],
'active paid' => [WorkspaceCommercialLifecycleResolver::STATE_ACTIVE_PAID, 'Active paid'],
'suspended read-only' => [WorkspaceCommercialLifecycleResolver::STATE_SUSPENDED_READ_ONLY, 'Suspended / read-only'],
]);
it('blocks activation but warns review pack starts during grace', function (): void {
[$workspace] = commercialLifecycleWorkspaceManager();
setCommercialLifecycleState($workspace, WorkspaceCommercialLifecycleResolver::STATE_GRACE, 'Payment collection pending');
$resolver = app(WorkspaceCommercialLifecycleResolver::class);
$activation = $resolver->actionDecision($workspace, WorkspaceCommercialLifecycleResolver::ACTION_MANAGED_TENANT_ACTIVATION);
$reviewPackStart = $resolver->actionDecision($workspace, WorkspaceCommercialLifecycleResolver::ACTION_REVIEW_PACK_START);
expect($activation)
->toMatchArray([
'outcome' => WorkspaceCommercialLifecycleResolver::OUTCOME_BLOCK,
'is_blocked' => true,
'reason_family' => WorkspaceCommercialLifecycleResolver::REASON_FAMILY_COMMERCIAL_LIFECYCLE,
'state' => WorkspaceCommercialLifecycleResolver::STATE_GRACE,
])
->and($activation['block_reason'])->toContain('grace')
->and($reviewPackStart)
->toMatchArray([
'outcome' => WorkspaceCommercialLifecycleResolver::OUTCOME_WARN,
'is_blocked' => false,
'is_warning' => true,
'reason_family' => WorkspaceCommercialLifecycleResolver::REASON_FAMILY_COMMERCIAL_LIFECYCLE,
'state' => WorkspaceCommercialLifecycleResolver::STATE_GRACE,
])
->and($reviewPackStart['warning_reason'])->toContain('grace');
});
it('blocks new starts but allows read-only history during suspended read-only', function (): void {
[$workspace] = commercialLifecycleWorkspaceManager();
setCommercialLifecycleState($workspace, WorkspaceCommercialLifecycleResolver::STATE_SUSPENDED_READ_ONLY, 'Commercial suspension');
$resolver = app(WorkspaceCommercialLifecycleResolver::class);
expect($resolver->actionDecision($workspace, WorkspaceCommercialLifecycleResolver::ACTION_MANAGED_TENANT_ACTIVATION))
->toMatchArray([
'outcome' => WorkspaceCommercialLifecycleResolver::OUTCOME_BLOCK,
'is_blocked' => true,
'reason_family' => WorkspaceCommercialLifecycleResolver::REASON_FAMILY_COMMERCIAL_LIFECYCLE,
])
->and($resolver->actionDecision($workspace, WorkspaceCommercialLifecycleResolver::ACTION_REVIEW_PACK_START))
->toMatchArray([
'outcome' => WorkspaceCommercialLifecycleResolver::OUTCOME_BLOCK,
'is_blocked' => true,
'reason_family' => WorkspaceCommercialLifecycleResolver::REASON_FAMILY_COMMERCIAL_LIFECYCLE,
])
->and($resolver->actionDecision($workspace, WorkspaceCommercialLifecycleResolver::ACTION_EVIDENCE_READ))
->toMatchArray([
'outcome' => WorkspaceCommercialLifecycleResolver::OUTCOME_ALLOW_READ_ONLY,
'is_blocked' => false,
'reason_family' => WorkspaceCommercialLifecycleResolver::REASON_FAMILY_COMMERCIAL_LIFECYCLE,
]);
});
it('preserves entitlement substrate blocks ahead of lifecycle outcomes', function (): void {
[$workspace, $manager] = commercialLifecycleWorkspaceManager();
setCommercialLifecycleState($workspace, WorkspaceCommercialLifecycleResolver::STATE_GRACE, 'Grace should not bypass substrate');
Tenant::factory()->create([
'workspace_id' => (int) $workspace->getKey(),
'status' => Tenant::STATUS_ACTIVE,
]);
app(SettingsWriter::class)->updateWorkspaceSetting(
actor: $manager,
workspace: $workspace,
domain: WorkspaceEntitlementResolver::SETTING_DOMAIN,
key: WorkspaceEntitlementResolver::SETTING_MANAGED_TENANT_LIMIT_OVERRIDE_VALUE,
value: 1,
);
app(SettingsWriter::class)->updateWorkspaceSetting(
actor: $manager,
workspace: $workspace,
domain: WorkspaceEntitlementResolver::SETTING_DOMAIN,
key: WorkspaceEntitlementResolver::SETTING_REVIEW_PACK_GENERATION_OVERRIDE_VALUE,
value: false,
);
$resolver = app(WorkspaceCommercialLifecycleResolver::class);
expect($resolver->actionDecision($workspace, WorkspaceCommercialLifecycleResolver::ACTION_MANAGED_TENANT_ACTIVATION))
->toMatchArray([
'outcome' => WorkspaceCommercialLifecycleResolver::OUTCOME_BLOCK,
'reason_family' => WorkspaceCommercialLifecycleResolver::REASON_FAMILY_ENTITLEMENT_SUBSTRATE,
])
->and($resolver->actionDecision($workspace, WorkspaceCommercialLifecycleResolver::ACTION_REVIEW_PACK_START))
->toMatchArray([
'outcome' => WorkspaceCommercialLifecycleResolver::OUTCOME_BLOCK,
'reason_family' => WorkspaceCommercialLifecycleResolver::REASON_FAMILY_ENTITLEMENT_SUBSTRATE,
'is_warning' => false,
]);
});

View File

@ -1,83 +0,0 @@
<?php
declare(strict_types=1);
use App\Services\Localization\LocaleResolver;
function unitLocaleResolver(): LocaleResolver
{
return app(LocaleResolver::class);
}
it('resolves admin locale precedence from explicit override through system default', function (): void {
$resolver = unitLocaleResolver();
expect($resolver->resolveFromSources('de', 'en', 'en', 'en'))
->toMatchArray([
'locale' => 'de',
'source' => LocaleResolver::SOURCE_EXPLICIT_OVERRIDE,
'machine_artifacts_invariant' => true,
]);
expect($resolver->resolveFromSources(null, 'de', 'en', 'en'))
->toMatchArray([
'locale' => 'de',
'source' => LocaleResolver::SOURCE_USER_PREFERENCE,
]);
expect($resolver->resolveFromSources(null, null, 'de', 'en'))
->toMatchArray([
'locale' => 'de',
'source' => LocaleResolver::SOURCE_WORKSPACE_DEFAULT,
]);
expect($resolver->resolveFromSources(null, null, null, 'de'))
->toMatchArray([
'locale' => 'de',
'source' => LocaleResolver::SOURCE_SYSTEM_DEFAULT,
]);
});
it('falls through unsupported locale sources safely', function (): void {
$resolver = unitLocaleResolver();
$context = $resolver->resolveFromSources('fr', 'es', 'de', 'en');
expect($context)
->toMatchArray([
'locale' => 'de',
'source' => LocaleResolver::SOURCE_WORKSPACE_DEFAULT,
'fallback_locale' => 'en',
])
->and($context['user_preference_locale'])->toBeNull();
});
it('keeps system panel resolution to explicit override or system default only', function (): void {
$resolver = unitLocaleResolver();
expect($resolver->resolveFromSources(
explicitOverride: null,
userPreference: 'de',
workspaceDefault: 'de',
systemDefault: 'en',
includeUserPreference: false,
includeWorkspaceDefault: false,
))->toMatchArray([
'locale' => 'en',
'source' => LocaleResolver::SOURCE_SYSTEM_DEFAULT,
'user_preference_locale' => null,
'workspace_default_locale' => null,
]);
expect($resolver->resolveFromSources(
explicitOverride: 'de',
userPreference: 'en',
workspaceDefault: 'en',
systemDefault: 'en',
includeUserPreference: false,
includeWorkspaceDefault: false,
))->toMatchArray([
'locale' => 'de',
'source' => LocaleResolver::SOURCE_EXPLICIT_OVERRIDE,
]);
});

View File

@ -1,197 +0,0 @@
<?php
declare(strict_types=1);
use App\Models\AlertDelivery;
use App\Models\Finding;
use App\Models\OperationRun;
use App\Models\Tenant;
use App\Models\TenantTriageReview;
use App\Models\User;
use App\Models\Workspace;
use App\Support\Auth\Capabilities;
use App\Support\BackupHealth\TenantBackupHealthResolver;
use App\Support\GovernanceInbox\GovernanceInboxSectionBuilder;
use App\Support\Navigation\CanonicalNavigationContext;
use App\Support\OperationRunOutcome;
use App\Support\OperationRunStatus;
use App\Support\PortfolioTriage\PortfolioArrivalContextToken;
use App\Support\PortfolioTriage\TenantTriageReviewFingerprint;
use Illuminate\Foundation\Testing\RefreshDatabase;
uses(RefreshDatabase::class);
it('builds visible governance inbox sections in canonical order with source links', function (): void {
$workspace = Workspace::factory()->create();
$user = User::factory()->create();
$alphaTenant = Tenant::factory()->create([
'workspace_id' => (int) $workspace->getKey(),
'name' => 'Alpha Tenant',
'external_id' => 'alpha-tenant',
]);
$bravoTenant = Tenant::factory()->create([
'workspace_id' => (int) $workspace->getKey(),
'name' => 'Bravo Tenant',
'external_id' => 'bravo-tenant',
]);
Finding::factory()
->for($alphaTenant)
->assignedTo((int) $user->getKey())
->ownedBy((int) $user->getKey())
->overdueByHours()
->create([
'status' => Finding::STATUS_IN_PROGRESS,
'subject_external_id' => 'assigned-finding',
]);
Finding::factory()
->for($bravoTenant)
->reopened()
->create([
'subject_external_id' => 'intake-finding',
]);
OperationRun::factory()
->forTenant($alphaTenant)
->create([
'status' => OperationRunStatus::Completed->value,
'outcome' => OperationRunOutcome::Failed->value,
'completed_at' => now()->subMinute(),
]);
OperationRun::factory()
->forTenant($bravoTenant)
->create([
'status' => OperationRunStatus::Queued->value,
'outcome' => OperationRunOutcome::Pending->value,
'created_at' => now()->subMinutes(6),
]);
AlertDelivery::factory()->create([
'tenant_id' => null,
'workspace_id' => (int) $workspace->getKey(),
'status' => AlertDelivery::STATUS_FAILED,
'event_type' => 'alerts.failed_delivery',
'payload' => [
'title' => 'Delivery failed',
'body' => 'Alert delivery could not be completed.',
],
]);
$backupHealthResolver = app(TenantBackupHealthResolver::class);
$fingerprints = app(TenantTriageReviewFingerprint::class);
$alphaBackupFingerprint = $fingerprints->forBackupHealth($backupHealthResolver->assess($alphaTenant));
$bravoBackupFingerprint = $fingerprints->forBackupHealth($backupHealthResolver->assess($bravoTenant));
expect($alphaBackupFingerprint)->not->toBeNull()
->and($bravoBackupFingerprint)->not->toBeNull();
TenantTriageReview::factory()
->for($alphaTenant)
->followUpNeeded()
->create([
'workspace_id' => (int) $workspace->getKey(),
'reviewed_by_user_id' => (int) $user->getKey(),
'concern_family' => PortfolioArrivalContextToken::FAMILY_BACKUP_HEALTH,
'review_fingerprint' => $alphaBackupFingerprint['fingerprint'],
'review_snapshot' => $alphaBackupFingerprint['snapshot'],
'reviewed_at' => now()->subDay(),
]);
TenantTriageReview::factory()
->for($bravoTenant)
->reviewed()
->create([
'workspace_id' => (int) $workspace->getKey(),
'reviewed_by_user_id' => (int) $user->getKey(),
'concern_family' => PortfolioArrivalContextToken::FAMILY_BACKUP_HEALTH,
'review_fingerprint' => hash('sha256', 'stale-review-fingerprint'),
'review_snapshot' => $bravoBackupFingerprint['snapshot'],
'reviewed_at' => now()->subDays(2),
]);
$context = new CanonicalNavigationContext(
sourceSurface: 'governance.inbox',
canonicalRouteName: 'filament.admin.pages.governance.inbox',
backLinkLabel: 'Back to governance inbox',
backLinkUrl: '/admin/governance/inbox',
);
$payload = app(GovernanceInboxSectionBuilder::class)->build(
user: $user,
workspace: $workspace,
authorizedTenants: [$alphaTenant, $bravoTenant],
visibleFindingTenants: [$alphaTenant, $bravoTenant],
reviewTenants: [$alphaTenant, $bravoTenant],
canViewAlerts: true,
navigationContext: $context,
);
expect(collect($payload['sections'])->pluck('key')->all())
->toBe([
'assigned_findings',
'intake_findings',
'stale_operations',
'alert_delivery_failures',
'review_follow_up',
])
->and($payload['family_counts'])->toMatchArray([
'assigned_findings' => 1,
'intake_findings' => 1,
'stale_operations' => 2,
'alert_delivery_failures' => 1,
'review_follow_up' => 2,
]);
$sections = collect($payload['sections'])->keyBy('key');
expect($sections['assigned_findings']['dominant_action_url'])
->toContain('/admin/findings/my-work')
->toContain('nav%5Bback_label%5D=Back+to+governance+inbox')
->and($sections['stale_operations']['dominant_action_label'])->toBe('Open terminal follow-up')
->and($sections['stale_operations']['dominant_action_url'])->toContain('problemClass=terminal_follow_up')
->and($sections['alert_delivery_failures']['dominant_action_url'])->toContain('tableFilters%5Bstatus%5D%5Bvalue%5D=failed')
->and(collect($sections['review_follow_up']['entries'])->pluck('status_label')->all())
->toBe(['Follow-up needed', 'Changed since review']);
});
it('keeps an explicitly selected visible family with an honest empty state when tenant filtering removes every row', function (): void {
$workspace = Workspace::factory()->create();
$user = User::factory()->create();
$alphaTenant = Tenant::factory()->create([
'workspace_id' => (int) $workspace->getKey(),
'name' => 'Alpha Tenant',
'external_id' => 'alpha-tenant',
]);
$bravoTenant = Tenant::factory()->create([
'workspace_id' => (int) $workspace->getKey(),
'name' => 'Bravo Tenant',
'external_id' => 'bravo-tenant',
]);
AlertDelivery::factory()->create([
'tenant_id' => null,
'workspace_id' => (int) $workspace->getKey(),
'status' => AlertDelivery::STATUS_FAILED,
]);
$payload = app(GovernanceInboxSectionBuilder::class)->build(
user: $user,
workspace: $workspace,
authorizedTenants: [$alphaTenant, $bravoTenant],
visibleFindingTenants: [],
reviewTenants: [],
canViewAlerts: true,
selectedTenant: $alphaTenant,
selectedFamily: 'alert_delivery_failures',
);
expect($payload['sections'])->toHaveCount(1)
->and($payload['sections'][0]['key'])->toBe('alert_delivery_failures')
->and($payload['sections'][0]['count'])->toBe(0)
->and($payload['sections'][0]['empty_state'])->toContain('tenant filter');
});

View File

@ -1,52 +0,0 @@
<?php
declare(strict_types=1);
use App\Models\Workspace;
use App\Support\Workspaces\WorkspaceResolver;
use Illuminate\Foundation\Testing\RefreshDatabase;
uses(RefreshDatabase::class);
it('resolves a workspace by slug or id', function (): void {
$workspace = Workspace::factory()->create([
'slug' => 'resolver-smoke-workspace',
]);
$resolver = app(WorkspaceResolver::class);
expect($resolver->resolve('resolver-smoke-workspace')?->is($workspace))->toBeTrue()
->and($resolver->resolve((string) $workspace->getKey())?->is($workspace))->toBeTrue();
});
it('resolves a Livewire serialized workspace route parameter', function (): void {
$workspace = Workspace::factory()->create([
'slug' => 'serialized-route-workspace',
]);
$payload = json_encode([
'id' => $workspace->getKey(),
'name' => $workspace->name,
'slug' => $workspace->slug,
], JSON_THROW_ON_ERROR);
expect(app(WorkspaceResolver::class)->resolve($payload)?->is($workspace))->toBeTrue();
});
it('falls back to serialized id when a Livewire route payload has no slug', function (): void {
$workspace = Workspace::factory()->create();
$payload = json_encode([
'id' => (string) $workspace->getKey(),
], JSON_THROW_ON_ERROR);
expect(app(WorkspaceResolver::class)->resolve($payload)?->is($workspace))->toBeTrue();
});
it('returns null for an unsupported serialized route payload', function (): void {
$payload = json_encode([
'name' => 'Missing key',
], JSON_THROW_ON_ERROR);
expect(app(WorkspaceResolver::class)->resolve($payload))->toBeNull();
});

View File

@ -62,7 +62,7 @@ services:
- laravel.test - laravel.test
- pgsql - pgsql
- redis - redis
command: php artisan queue:listen --tries=3 --timeout=300 --sleep=3 command: php artisan queue:work --tries=3 --timeout=300 --sleep=3 --max-jobs=1000
pgsql: pgsql:
image: 'postgres:16' image: 'postgres:16'

View File

@ -15,7 +15,7 @@ ## Purpose
## Current Product Position ## Current Product Position
TenantPilot ist aktuell ein starkes internes Governance- und Operations-Produkt mit belastbaren Foundations fuer Execution Truth, Baselines/Drift, Findings, Evidence, Reviews, Review Packs, Supportability, Telemetry und Safety Controls. Die Repo-Wahrheit liegt damit ueber einer simplen Lesart von "R1 done / R2 partial". Gleichzeitig ist das Produkt noch nicht voll als kundenseitig konsumierbare Review- und Portfolio-Plattform ausgereift: Customer-safe Review Consumption, Cross-Tenant-Workflows und kommerzielle Lifecycle-Reife sind noch unvollstaendig. Zusaetzlich zeigt der Repo-Stand eine schmale Findings-Cleanup-Lane: sichtbare Lifecycle-Backfill-Runtime-Surfaces, `acknowledged`-Kompatibilitaet und fehlende explizite Creation-Time-Invariant-Absicherung sollten als getrennte Folgespecs behandelt werden. TenantPilot ist aktuell ein starkes internes Governance- und Operations-Produkt mit belastbaren Foundations fuer Execution Truth, Baselines/Drift, Findings, Evidence, Reviews, Review Packs, Supportability, Telemetry und Safety Controls. Die Repo-Wahrheit liegt damit ueber einer simplen Lesart von "R1 done / R2 partial". Gleichzeitig ist das Produkt noch nicht voll als kundenseitig konsumierbare Review- und Portfolio-Plattform ausgereift: Customer-safe Review Consumption, Cross-Tenant-Workflows und kommerzielle Lifecycle-Reife sind noch unvollstaendig.
## Status Model ## Status Model
@ -51,7 +51,7 @@ ## Roadmap Coverage Summary
| Product Scalability & Self-Service Foundation | implemented_partial | strong | yes | repo tests, not run | almost | Onboarding, Support, Help und Entitlements sind weit; Billing, Trial und Demo-Reife fehlen. | | Product Scalability & Self-Service Foundation | implemented_partial | strong | yes | repo tests, not run | almost | Onboarding, Support, Help und Entitlements sind weit; Billing, Trial und Demo-Reife fehlen. |
| R2.0 Canonical Control Catalog Foundation | implemented_verified | strong | partial | repo tests, not run | foundation-only | Bereits implementiert und in Evidence/Reviews referenziert, aber kein eigenstaendiger Kundennutzen-Surface. | | R2.0 Canonical Control Catalog Foundation | implemented_verified | strong | partial | repo tests, not run | foundation-only | Bereits implementiert und in Evidence/Reviews referenziert, aber kein eigenstaendiger Kundennutzen-Surface. |
| R2 Completion: customer review, support, help | implemented_partial | strong | yes | repo tests, not run | almost | Support und Help sind real; kundensichere Review-Consumption ist noch offen. | | R2 Completion: customer review, support, help | implemented_partial | strong | yes | repo tests, not run | almost | Support und Help sind real; kundensichere Review-Consumption ist noch offen. |
| Findings Workflow v2 / Execution Layer | implemented_partial | strong | yes | repo tests, not run | almost | Triage, Ownership, Alerts und Hygiene sind vorhanden; der naechste Operator-Layer fehlt und Legacy-Cleanup um Backfill-/Status-Kompatibilitaet bleibt offen. | | Findings Workflow v2 / Execution Layer | implemented_partial | strong | yes | repo tests, not run | almost | Triage, Ownership, Alerts und Hygiene sind vorhanden; der naechste Operator-Layer fehlt. |
| Policy Lifecycle / Ghost Policies | specified | weak | no | no | no | Als Richtung sichtbar, aber nicht als repo-verifizierter Workflow. | | Policy Lifecycle / Ghost Policies | specified | weak | no | no | no | Als Richtung sichtbar, aber nicht als repo-verifizierter Workflow. |
| Platform Operations Maturity | implemented_partial | strong | yes | repo tests, not run | almost | System Panel, Control Tower und Ops Controls sind real; CSV/Raw Drilldowns bleiben offen. | | Platform Operations Maturity | implemented_partial | strong | yes | repo tests, not run | almost | System Panel, Control Tower und Ops Controls sind real; CSV/Raw Drilldowns bleiben offen. |
| Product Usage, Customer Health & Operational Controls | adopted | strong | yes | repo tests, not run | almost | Diese Mid-term-Lane ist im Repo bereits substanziell vorhanden. | | Product Usage, Customer Health & Operational Controls | adopted | strong | yes | repo tests, not run | almost | Diese Mid-term-Lane ist im Repo bereits substanziell vorhanden. |
@ -106,7 +106,7 @@ ## Foundation-Only Capabilities
## Partial Capabilities ## Partial Capabilities
- Customer-facing review consumption: Tenant Reviews, Evidence Snapshots und Review Packs sind stark, aber ein repo-verifizierter Customer Review Workspace fehlt. - Customer-facing review consumption: Tenant Reviews, Evidence Snapshots und Review Packs sind stark, aber ein repo-verifizierter Customer Review Workspace fehlt.
- Findings Workflow v2: Triage, Assignment, Hygiene und Notifications sind vorhanden, aber kein konsolidierter Decision-/Inbox-Layer; zusaetzlich bleibt Cleanup debt um Lifecycle-Backfill-Surfaces, `acknowledged`-Kompatibilitaet und explizite Creation-Time-Invarianten. - Findings Workflow v2: Triage, Assignment, Hygiene und Notifications sind vorhanden, aber kein konsolidierter Decision-/Inbox-Layer.
- Product scalability and self-service: Onboarding, Support, Help und Entitlements sind weit, Billing-, Trial- und Demo-Reife aber nicht. - Product scalability and self-service: Onboarding, Support, Help und Entitlements sind weit, Billing-, Trial- und Demo-Reife aber nicht.
- MSP portfolio operations: Portfolio-Triage ist vorhanden, Cross-Tenant Compare und Promotion fehlen. - MSP portfolio operations: Portfolio-Triage ist vorhanden, Cross-Tenant Compare und Promotion fehlen.
- Platform operations maturity: Control Tower und Ops Controls sind stark, aber einige geplante operatorseitige Drilldowns/Exports fehlen noch. - Platform operations maturity: Control Tower und Ops Controls sind stark, aber einige geplante operatorseitige Drilldowns/Exports fehlen noch.
@ -179,9 +179,6 @@ ## Open Gaps & Blockers
|---|---|---|---|---| |---|---|---|---|---|
| Customer-safe review workspace is missing | Release blocker | Existing review and evidence assets cannot yet be consumed as a clear customer-facing surface | R2 completion / Tenant Reviews | P0 Customer Review Workspace v1 | | Customer-safe review workspace is missing | Release blocker | Existing review and evidence assets cannot yet be consumed as a clear customer-facing surface | R2 completion / Tenant Reviews | P0 Customer Review Workspace v1 |
| No consolidated operator decision inbox | UX blocker | Operators still move between findings, runs, alerts and portfolio surfaces to act | Findings Workflow / MSP Portfolio | P0 Decision-Based Governance Inbox v1 | | No consolidated operator decision inbox | UX blocker | Operators still move between findings, runs, alerts and portfolio surfaces to act | Findings Workflow / MSP Portfolio | P0 Decision-Based Governance Inbox v1 |
| Findings lifecycle backfill runtime surfaces remain productized | Cleanup blocker | Runbooks, commands, capabilities and tenant actions still expose a pre-production repair path that should not ship as product truth | Findings Workflow / Legacy Removal | P1 Remove Findings Lifecycle Backfill Runtime Surfaces |
| Legacy `acknowledged` status compatibility still survives | Semantics blocker | Status helpers, filters, badges, capability aliases and tests keep non-canonical workflow semantics alive | Findings Workflow / RBAC | P1 Remove Legacy Acknowledged Finding Status Compatibility |
| Creation-time finding invariants are implied but not explicitly protected | Integrity blocker | Future finding generators could regress into partial lifecycle writes and recreate the need for repair tooling | Findings Workflow / Data Integrity | P1 Enforce Creation-Time Finding Invariants |
| Cross-tenant compare and promotion is not repo-proven | Release blocker | MSP portfolio story remains partial | MSP Portfolio & Operations | P1 Cross-Tenant Compare and Promotion v1 | | Cross-tenant compare and promotion is not repo-proven | Release blocker | MSP portfolio story remains partial | MSP Portfolio & Operations | P1 Cross-Tenant Compare and Promotion v1 |
| Localization foundation is absent | UX blocker | Product polish and DACH-readiness remain limited | R1.9 Platform Localization v1 | P1 Localization v1 | | Localization foundation is absent | UX blocker | Product polish and DACH-readiness remain limited | R1.9 Platform Localization v1 | P1 Localization v1 |
| Entitlements stop short of full commercial lifecycle | Commercialization blocker | Plan gating exists, but trial, grace and suspension semantics remain incomplete | Product Scalability & Self-Service Foundation | P2 Commercial Entitlements and Billing-State Maturity | | Entitlements stop short of full commercial lifecycle | Commercialization blocker | Plan gating exists, but trial, grace and suspension semantics remain incomplete | Product Scalability & Self-Service Foundation | P2 Commercial Entitlements and Billing-State Maturity |
@ -194,9 +191,6 @@ ## Recommended Next Specs
- `P0 Customer Review Workspace v1`: turns existing reviews, evidence and review-pack outputs into a customer-safe read-only product surface. - `P0 Customer Review Workspace v1`: turns existing reviews, evidence and review-pack outputs into a customer-safe read-only product surface.
- `P0 Decision-Based Governance Inbox v1`: consolidates existing findings, runs, alerts and triage signals into one operator work surface. - `P0 Decision-Based Governance Inbox v1`: consolidates existing findings, runs, alerts and triage signals into one operator work surface.
- `P1 Remove Findings Lifecycle Backfill Runtime Surfaces`: removes visible pre-production repair tooling from runbooks, commands, actions, capabilities and deploy/runtime hooks.
- `P1 Remove Legacy Acknowledged Finding Status Compatibility`: collapses findings workflow semantics onto the canonical `triaged` model and removes stale RBAC/query aliases.
- `P1 Enforce Creation-Time Finding Invariants`: proves that new findings are lifecycle-ready at write time so no repair backfill has to return later.
- `P1 Cross-Tenant Compare and Promotion v1`: needed to move from portfolio visibility to portfolio action. - `P1 Cross-Tenant Compare and Promotion v1`: needed to move from portfolio visibility to portfolio action.
- `P1 Localization v1`: still absent in repo and becomes more expensive the later it lands. - `P1 Localization v1`: still absent in repo and becomes more expensive the later it lands.
- `P2 Commercial Entitlements and Billing-State Maturity`: extends the already real entitlement substrate into a usable commercial lifecycle. - `P2 Commercial Entitlements and Billing-State Maturity`: extends the already real entitlement substrate into a usable commercial lifecycle.

View File

@ -3,7 +3,7 @@ # Spec Candidates
> Repo-based next-spec queue for TenantPilot. > Repo-based next-spec queue for TenantPilot.
> This file is not a wishlist. It tracks only open gaps that are still worth turning into new or refreshed specs. > This file is not a wishlist. It tracks only open gaps that are still worth turning into new or refreshed specs.
> **Last reviewed**: 2026-04-28 > **Last reviewed**: 2026-04-27
> **Basis**: `implementation-ledger.md`, `roadmap.md`, current `specs/` truth > **Basis**: `implementation-ledger.md`, `roadmap.md`, current `specs/` truth
--- ---
@ -138,94 +138,6 @@ ### Localization v1
- locale-aware formatting does not affect audit or export truth - locale-aware formatting does not affect audit or export truth
- targeted regression coverage exists for fallback and key critical flows - targeted regression coverage exists for fallback and key critical flows
### Remove Findings Lifecycle Backfill Runtime Surfaces
- **Priority**: P1
- **Why this stays active**: Repo audit shows visible runtime surfaces for a pre-production findings lifecycle repair path even though active finding generators already write the relevant lifecycle fields directly. The remaining path is not just ballast; it appears partially detached from current operational-control truth and keeps internal repair tooling productized.
- **Roadmap relationship**: Findings workflow cleanup / legacy removal.
- **Dependencies**:
- current finding generators that already set lifecycle fields directly
- system runbook registry and execution surfaces
- tenant findings actions
- operation catalog, capability, and seeder bindings
- backfill jobs, runbook service, and deploy hooks
- **Scope**:
- remove the system runbook `Rebuild Findings Lifecycle`
- remove the tenant action `Backfill findings lifecycle`
- remove the command `tenantpilot:findings:backfill-lifecycle`
- remove findings lifecycle backfill jobs, runbook services, and deploy/runtime hooks
- remove operation-catalog, capability, seeder, and test traces that exist only for this backfill path
- **Non-scope**:
- removing the legacy `acknowledged` status or related compatibility helpers
- changing normal finding workflow actions such as triage, assignment, progress, resolve, or risk acceptance
- changing ownership, assignee, SLA, due-date, or risk-governance semantics
- changing historical migrations or adding replacement backfills
- **Acceptance criteria**:
- no `/admin` surface exposes `Backfill findings lifecycle`
- no system runbook exposes `Rebuild Findings Lifecycle`
- `tenantpilot:findings:backfill-lifecycle` is no longer a supported command
- deploy or operational hooks do not start a findings lifecycle backfill
- `findings.lifecycle.backfill` is no longer used as an operational-control key, operation type, or capability
- tests no longer expect backfill preflight, start, or completion behavior
- normal finding workflows keep working unchanged for triage, assignment, start progress, resolve, and risk acceptance
- **Notes**: This is the first and most important cleanup candidate because it removes visible product ballast without changing the canonical findings workflow semantics.
### Remove Legacy Acknowledged Finding Status Compatibility
- **Priority**: P1
- **Why this stays active**: Repo audit indicates that `acknowledged` compatibility still survives in status helpers, filters, badges, capabilities, and tests even though the current operator workflow is centered on `triaged`. Keeping both semantics alive weakens workflow clarity and RBAC consistency.
- **Roadmap relationship**: Findings workflow semantics / RBAC cleanup.
- **Dependencies**:
- finding status constants and model helpers
- badge and filter catalogs
- role capability mappings and capability aliases
- workflow and bulk-action tests that still speak in acknowledge semantics
- **Scope**:
- remove `Finding::STATUS_ACKNOWLEDGED`
- remove or simplify compatibility helpers that only map `acknowledged` to `triaged`
- remove `openStatusesForQuery()` compatibility for `acknowledged`
- remove legacy capability aliases such as `tenant_findings.acknowledge`
- rename, adapt, or remove tests that only protect the old acknowledge vocabulary
- ensure active workflow actions consistently use `triage` / `triaged`
- **Non-scope**:
- removing findings lifecycle backfill runtime surfaces in the same slice
- changing SLA, ownership, assignee, or risk-acceptance behavior
- introducing new workflow states or new customer-facing workflow surfaces
- changing finding generators unless they still emit `acknowledged`
- **Acceptance criteria**:
- no productive code path writes `acknowledged`
- no productive code path expects `acknowledged` as a valid workflow status
- `tenant_findings.acknowledge` no longer exists as a capability or alias
- workflow actions, filters, badges, and tests consistently use `triage` / `triaged`
- existing finding flows remain functional from `new` to `triaged`, `in_progress`, `resolved`, and risk-accepted outcomes
- **Notes**: Keep this separate from backfill removal because it reaches deeper into workflow semantics, queries, badges, and RBAC mappings.
### Enforce Creation-Time Finding Invariants
- **Priority**: P1
- **Why this stays active**: Removing lifecycle backfills only stays safe if new findings are always created in a lifecycle-ready state. The repo already hints at good direct-write behavior, but those invariants still need explicit protection so future generators do not recreate the need for repair jobs.
- **Roadmap relationship**: Findings data integrity / workflow hardening.
- **Dependencies**:
- drift and baseline compare finding generation
- permission posture finding generation
- Entra admin roles finding generation
- rediscovery, reopen, and deduplication behavior around recurrence keys and lifecycle timestamps
- **Scope**:
- review active finding generators and verify lifecycle-ready creation
- add or tighten invariant tests around canonical status, first/last seen timestamps, `times_seen`, `sla_days`, and `due_at` where applicable
- verify reopen and rediscovery behavior
- verify drift idempotency and recurrence-key semantics
- consider a tightly bounded DB constraint only if the repo proves a safe, narrow case
- **Non-scope**:
- reintroducing any backfill or repair runtime surface
- historical data migration work
- forcing owner or assignee fields to become mandatory
- introducing new finding types or broader customer review workflow changes
- **Acceptance criteria**:
- repo-verified finding generators have tests that prove lifecycle-ready creation
- no new finding generation path relies on a later backfill or repair run
- repeated drift detection does not create uncontrolled canonical duplicates
- reopen or rediscovery behavior updates lifecycle fields correctly
- accountability remains a governance state rather than a forced owner/assignee requirement
- **Notes**: This should follow the visible cleanup work and protects the target state so findings do not regress back into repair-job dependency.
### P2 — Commercial / Scale ### P2 — Commercial / Scale
### Commercial Entitlements and Billing-State Maturity ### Commercial Entitlements and Billing-State Maturity

View File

@ -1,50 +0,0 @@
import json
import subprocess
import sys
import time
def send(proc, payload):
proc.stdin.write((json.dumps(payload) + "\n").encode("utf-8"))
proc.stdin.flush()
def read_line(proc, timeout=10.0):
start = time.time()
while time.time() - start < timeout:
line = proc.stdout.readline()
if line:
return line.decode("utf-8", errors="replace").strip()
time.sleep(0.05)
return ""
def main():
proc = subprocess.Popen(
["python3", "scripts/run-gitea-mcp.py"],
stdin=subprocess.PIPE,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
)
try:
send(proc, {
"jsonrpc": "2.0",
"id": 1,
"method": "initialize",
"params": {
"protocolVersion": "2024-11-05",
"capabilities": {},
"clientInfo": {"name": "test", "version": "1.0.0"},
},
})
init_resp = read_line(proc)
send(proc, {
"jsonrpc": "2.0",
"id": 2,
"method": "tools/list",
"params": {}
})
tools_resp = read_line(proc)
print(tools_resp)
finally:
proc.terminate()
if __name__ == "__main__":
main()

View File

@ -1,72 +0,0 @@
# Preparation Review Checklist: Customer Review Workspace v1
**Purpose**: Validate the customer-safe review-consumption package against the repo's guardrail, disclosure, shared-family, and close-out workflow before implementation
**Created**: 2026-04-27
**Feature**: [spec.md](../spec.md)
## Applicability And Low-Impact Gate
- [x] CHK001 The package explicitly treats this as an operator-facing and read-only/customer-safe surface change, so the low-impact `N/A` path is not used.
- [x] CHK002 The spec, plan, and tasks carry the same native/shared-primitives-first classification, shared-family relevance, state ownership, and close-out targeting without inventing second wording.
## Native, Shared-Family, And State Ownership
- [x] CHK003 The primary surface remains a native Filament page that composes existing review, pack, and evidence viewers instead of introducing a fake-native shell or standalone customer portal.
- [x] CHK004 Shared detail families remain shared: tenant review, review pack, and evidence detail stay on their existing resource routes, while the new page stays a calm entry point rather than a parallel viewer family.
- [x] CHK005 Shell, page, and URL/query state owners are named once, and the package does not collapse them into new persisted customer-review state.
- [x] CHK006 The likely next operator action and primary inspect/open model stay coherent: `Open latest review` is primary, `Download review pack` is the only safe inline shortcut, and deeper proof stays secondary.
## Shared Pattern Reuse
- [x] CHK007 Cross-cutting interaction classes are explicit, and the shared reuse path is named once through `TenantReviewRegisterService`, existing resource URL helpers, `ArtifactTruthPresenter`, `ReviewPackService`, `RedactionIntegrity`, and the audit pipeline.
- [x] CHK008 The package extends existing shared paths where they are sufficient, and any fallback to a bounded page-local read helper or additive audit action is explicitly constrained as a last resort rather than a new default abstraction.
- [x] CHK009 The package does not create a parallel customer-review UX language; it reuses current artifact-truth, publication-readiness, review-pack, and redaction vocabulary.
## OperationRun Start UX Contract
- [x] CHK019 The package explicitly states that the new page does not create, queue, deduplicate, resume, block, complete, or deep-link to an `OperationRun` as a primary workflow.
- [x] CHK020 Any existing `OperationRun` links remain on reused detail surfaces, so queued toast/link/browser-event/dedupe behavior is not reimplemented on the customer workspace page.
- [x] CHK021 No queued DB notification behavior or terminal notification path is added because the slice stays read-only and never starts a run.
- [x] CHK022 No OperationRun exception is required; if implementation later promotes run-oriented behavior onto the page, that deviation must be recorded in the active close-out entry before merge.
## Provider Boundary And Vocabulary
- [x] CHK010 The package keeps provider-specific semantics behind existing normalized review, evidence, and artifact-truth seams and does not spread provider language into a new platform-core contract.
- [x] CHK011 No retained provider-specific shared boundary is introduced; the slice stays within current workspace, tenant, review, evidence, review-pack, accepted-risk, and audit vocabulary.
## Signals, Exceptions, And Test Depth
- [x] CHK012 The triggered repository signal is explicitly handled as `review-mandatory`, with no hidden hard-stop drift accepted into the package.
- [x] CHK013 No bounded exception is required in the preparation package; if implementation discovers a local read helper or additive audit action is unavoidable, that exception must be documented in the active feature close-out entry instead of becoming silent spread.
- [x] CHK014 The required surface test profile is explicit: `standard-native-filament` for the page plus `shared-detail-family` for navigation into existing review, pack, and evidence detail surfaces.
- [x] CHK015 The chosen lane mix is the narrowest honest proof for this disclosure-heavy slice: focused Feature coverage plus one bounded Browser smoke, with optional Unit coverage only if a small read helper is extracted.
## Audience-Aware Disclosure And Decision Hierarchy
- [x] CHK023 Default-visible content stays decision-first and clearly separated from deeper diagnostics and support/raw evidence.
- [x] CHK024 The read-only/customer-safe default path does not expose raw JSON, copied payloads, fingerprints, internal reason ownership, platform-debug semantics, or unrestricted audit detail by default.
- [x] CHK025 Exactly one dominant next action is primary: `Open latest review`; safe artifact download remains secondary and does not compete at equal weight.
- [x] CHK026 Duplicate visible blocker, status, or next-action summaries are avoided by reusing one artifact-truth summary per row and leaving detailed proof to the existing detail surfaces.
- [x] CHK027 Support/raw sections remain hidden or capability-gated through reused detail routes only, and the page keeps Filament visual language, progressive disclosure, and calm read-only presentation.
## Review Outcome
- [x] CHK016 Review outcome class: `acceptable-special-case`
- [x] CHK017 Workflow outcome: `keep`
- [x] CHK018 The final note location is explicit: the active feature PR close-out entry `Guardrail / Exception / Smoke Coverage` records the guardrail result, smoke outcome, and any bounded implementation exception.
## Notes
- This checklist validates the preparation package only: `spec.md`, `plan.md`, `tasks.md`, and the supporting design artifacts. It does not claim application code already exists.
- The slice remains bounded to one read-only customer-safe workspace surface in the current admin plane. No new identity plane, persistence layer, review-generation workflow, remediation path, or raw-diagnostic default path is approved by this package.
- If implementation later proves `TenantReviewRegisterService` reuse insufficient or shows that explicit artifact access requires a new stable `AuditActionId`, that must be recorded as a bounded note in `Guardrail / Exception / Smoke Coverage` rather than silently widening the architecture.
## Implementation Close-out Addendum
- Implemented surface: native `CustomerReviewWorkspace` page and Blade view in the existing admin-plane reviews family, still reusing current tenant review, review-pack, evidence, artifact-truth, RBAC, and audit seams.
- T010 outcome: direct workspace links landed on tenant review detail, review-pack detail, evidence related context, and the tenant review-pack widget. `ReviewRegister` and `EvidenceOverview` remained acceptable reuse via existing row/detail navigation.
- T020 outcome: pack-download plumbing changed, so `ReviewPackDownloadTest.php` and `ReviewPackRbacTest.php` were updated and passed after request-time membership plus `REVIEW_PACK_VIEW` enforcement was added to the signed download route.
- T023 outcome: the current audit infrastructure was reused with additive `tenant_review.opened` and `review_pack.downloaded` action IDs. No new audit store was introduced.
- Smoke evidence outcome: the implementation close-out used the bounded Pest browser smoke plus the focused feature lane as executed smoke proof. No separate manual integrated-browser run was completed.
- Final review outcome class: `acceptable-special-case`.
- Final workflow outcome: `keep`.

View File

@ -1,261 +0,0 @@
openapi: 3.0.3
info:
title: TenantPilot Customer Review Workspace v1 (Conceptual)
version: 0.1.0
description: |
Conceptual contract for the read-oriented customer-safe workspace review
surface planned by Spec 249.
NOTE: The canonical page is planned as a native Filament / Livewire page in
the existing admin plane. The JSON response shapes below describe the
derived workspace view model for planning purposes; they do not require a
new public REST API in v1.
servers:
- url: /
paths:
/admin/reviews/workspace:
get:
summary: View the customer review workspace
description: |
Canonical admin-plane workspace page for customer-safe review
consumption. The page stays read-only and derives its rows from
existing tenant review, review-pack, evidence, and entitlement truth.
parameters:
- in: query
name: tenant_id
required: false
schema:
type: integer
description: |
Optional tenant prefilter carried from an existing tenant-scoped
review, review-pack, evidence, or dashboard entry point.
- in: query
name: source
required: false
schema:
type: string
description: Optional launch-context hint used for page highlighting only.
responses:
'200':
description: Workspace page rendered
content:
text/html:
schema:
type: string
application/json:
schema:
$ref: '#/components/schemas/CustomerReviewWorkspaceCollection'
'403':
description: Forbidden after workspace membership is established but required capability is missing
'404':
description: Not found for non-members or explicit out-of-scope tenant targeting
/admin/t/{tenant}/reviews/{review}:
get:
summary: Open the latest tenant review detail
description: |
Existing tenant-scoped review detail route reused as the primary inspect
action from the workspace page.
parameters:
- in: path
name: tenant
required: true
schema:
type: integer
- in: path
name: review
required: true
schema:
type: integer
responses:
'200':
description: Tenant review detail rendered
content:
text/html:
schema:
type: string
'403':
description: Forbidden for an entitled member missing the review capability
'404':
description: Not found for non-members or tenant / review mismatches
/admin/t/{tenant}/evidence/{evidenceSnapshot}:
get:
summary: Open evidence detail from the customer review flow
description: |
Existing tenant-scoped evidence detail route reused only as optional
proof when the actor explicitly asks for it and has the required
capability.
parameters:
- in: path
name: tenant
required: true
schema:
type: integer
- in: path
name: evidenceSnapshot
required: true
schema:
type: integer
responses:
'200':
description: Evidence detail rendered
content:
text/html:
schema:
type: string
'403':
description: Forbidden for an entitled member missing the evidence capability
'404':
description: Not found for non-members or tenant / evidence mismatches
/admin/review-packs/{reviewPack}/download:
get:
summary: Download the current review pack
description: |
Existing signed download route reused by the workspace page. The pack
must already exist, be ready, and not be expired.
parameters:
- in: path
name: reviewPack
required: true
schema:
type: integer
responses:
'200':
description: Review pack download stream
content:
application/zip:
schema:
type: string
format: binary
'403':
description: Forbidden due to missing signature or invalid signed URL
'404':
description: Review pack not found, not ready, or expired
components:
schemas:
CustomerReviewWorkspaceCollection:
type: object
required:
- workspace_id
- entries
properties:
workspace_id:
type: integer
tenant_prefilter_id:
type: integer
nullable: true
highlighted_tenant_id:
type: integer
nullable: true
launch_source:
type: string
nullable: true
entries:
type: array
items:
$ref: '#/components/schemas/CustomerReviewWorkspaceEntry'
empty_state_message:
type: string
nullable: true
CustomerReviewWorkspaceEntry:
type: object
required:
- tenant_id
- tenant_name
- review_pack_available
properties:
tenant_id:
type: integer
tenant_name:
type: string
latest_published_review_id:
type: integer
nullable: true
latest_review_generated_at:
type: string
format: date-time
nullable: true
latest_review_published_at:
type: string
format: date-time
nullable: true
review_outcome_label:
type: string
nullable: true
review_outcome_explanation:
type: string
nullable: true
key_findings_summary:
$ref: '#/components/schemas/FindingsSummary'
accepted_risk_summary:
$ref: '#/components/schemas/AcceptedRiskSummary'
review_pack:
$ref: '#/components/schemas/ReviewPackAccess'
evidence_proof:
$ref: '#/components/schemas/EvidenceProof'
primary_review_url:
type: string
nullable: true
redaction_note:
type: string
nullable: true
absence_note:
type: string
nullable: true
FindingsSummary:
type: object
nullable: true
properties:
total_visible:
type: integer
attention_required_count:
type: integer
summary_text:
type: string
AcceptedRiskSummary:
type: object
nullable: true
properties:
accepted_count:
type: integer
follow_up_required_count:
type: integer
summary_text:
type: string
ReviewPackAccess:
type: object
required:
- available
properties:
available:
type: boolean
review_pack_id:
type: integer
nullable: true
download_url:
type: string
nullable: true
status_message:
type: string
nullable: true
EvidenceProof:
type: object
nullable: true
properties:
evidence_snapshot_id:
type: integer
nullable: true
detail_url:
type: string
nullable: true
freshness_label:
type: string
nullable: true

View File

@ -1,210 +0,0 @@
# Data Model — Customer Review Workspace v1
**Spec**: [spec.md](spec.md)
No new persisted tables or customer-review entities are required for v1. The feature reuses existing tenant-owned review, review-pack, evidence, findings, and audit truth, then derives one workspace-scoped read model for page presentation.
## Persisted Truth Reused
### Workspace / Tenant Entitlement Context
**Purpose**: Establish the current workspace boundary and the entitled tenant set before any review rows are composed.
**Persisted carriers**:
- existing workspace membership records
- existing tenant membership pivot records and tenant role assignments
- existing capability registry and role-capability map
**Relevant fields / contracts**:
- `workspace_id`
- `tenant_id`
- tenant membership role
- capability grants derived from [../../apps/platform/app/Support/Auth/Capabilities.php](../../apps/platform/app/Support/Auth/Capabilities.php)
**Validation rules**:
- current actor must be a member of the current workspace or the page resolves as not found
- tenant rows may only be composed for tenants in the current workspace where the actor is entitled through the canonical role-capability map
- no cross-workspace or cross-tenant fallback lookups are allowed
### TenantReview
**Purpose**: Canonical source for the latest customer-safe review posture, summary text, findings summary, accepted-risk summary, and primary inspect target.
**Persisted carrier**: existing `tenant_reviews` rows via `TenantReview`
**Relevant fields / relationships**:
- `id`
- `workspace_id`
- `tenant_id`
- `status`
- `generated_at`
- `published_at`
- `summary`
- `evidence_snapshot_id`
- `current_export_review_pack_id`
- `tenant`
- `evidenceSnapshot`
- `currentExportReviewPack`
- `sections`
**Validation / usage rules**:
- the default customer-safe path uses the latest published review per entitled tenant
- draft, ready, failed, archived, and superseded reviews stay off the default-visible page summary unless explicitly reused as internal proof elsewhere
- summary data already shaped into the review artifact remains the preferred source for findings and review-level posture messaging
### TenantReviewSection
**Purpose**: Supporting persisted proof for accepted-risk and section-level disclosure without introducing a new workspace summary store.
**Persisted carrier**: existing `tenant_review_sections` rows
**Relevant fields / relationships**:
- `tenant_review_id`
- `section_key`
- `title`
- `completeness_state`
- `summary_payload`
- `render_payload`
**Validation / usage rules**:
- accepted-risk summaries should come from the existing review section payloads that were already composed for the review artifact
- section payload reuse must remain read-only and redaction-safe
### ReviewPack
**Purpose**: Canonical packaged artifact for customer-safe review consumption and download.
**Persisted carrier**: existing `review_packs` rows via `ReviewPack`
**Relevant fields / relationships**:
- `id`
- `workspace_id`
- `tenant_id`
- `tenant_review_id`
- `status`
- `generated_at`
- `expires_at`
- `summary`
- `file_path`
- `file_disk`
- `sha256`
- `tenantReview`
- `evidenceSnapshot`
**Validation / usage rules**:
- download is available only when the current pack is ready and not expired
- the workspace page may surface availability and a signed download path only when `REVIEW_PACK_VIEW` applies
- the workspace page must not start generation, regeneration, or recovery flows
### EvidenceSnapshot
**Purpose**: Existing proof artifact for freshness and evidence completeness when the actor explicitly asks for supporting detail.
**Persisted carrier**: existing `evidence_snapshots` rows via `EvidenceSnapshot`
**Relevant fields / relationships**:
- `id`
- `workspace_id`
- `tenant_id`
- `status`
- `completeness_state`
- `generated_at`
- `expires_at`
- `summary`
- `items`
**Validation / usage rules**:
- evidence detail is not part of the default-visible customer path
- drilldown remains explicit and capability-gated by `EVIDENCE_VIEW`
- evidence truth remains tenant-owned and derived from the existing snapshot lifecycle
### Audit Log Event Family
**Purpose**: Existing audit truth for explicit review-artifact access and download actions.
**Persisted carrier**: existing `audit_logs` rows via `WorkspaceAuditLogger`
**Relevant fields / contracts**:
- stable `AuditActionId`
- `workspace_id`
- optional `tenant_id`
- actor metadata
- target resource type / id / label
- action context metadata
**Validation / usage rules**:
- no new audit store is introduced
- explicit artifact open/download events should reuse the current audit pipeline
- page render itself should not become a noisy new audit family
## Derived Read Model
### CustomerReviewWorkspaceEntry
**Purpose**: Derived page row or card summarizing the latest customer-safe review state for one entitled tenant.
**Persistence**: none; computed at request time
**Fields**:
- `workspace_id`
- `tenant_id`
- `tenant_name`
- `latest_published_review_id` (nullable)
- `latest_review_generated_at` (nullable)
- `latest_review_published_at` (nullable)
- `review_outcome_label` (nullable, derived from existing artifact truth)
- `review_outcome_explanation` (nullable)
- `key_findings_summary` (nullable, derived from existing review summary)
- `accepted_risk_summary` (nullable, derived from existing review section payloads)
- `review_pack_id` (nullable)
- `review_pack_available` (boolean)
- `review_pack_status_note` (nullable derived string)
- `evidence_snapshot_id` (nullable)
- `primary_review_url` (nullable)
- `review_pack_download_url` (nullable)
- `evidence_detail_url` (nullable)
- `redaction_note` (nullable)
- `absence_note` (nullable derived string)
**Derivation rules**:
- exactly one entry exists per entitled tenant visible in the current workspace scope
- when a published review exists, the entry derives customer-safe posture from that latest published review only
- when no published review exists for an entitled tenant, the entry may carry a derived absence note such as `No published review available yet`; this remains view logic, not domain state
- raw JSON, raw provider payloads, unrestricted audit metadata, fingerprints, and debug-only context are never part of the entry
**Validation rules**:
- entries may only be built for tenants in the current workspace and current entitlement scope
- `review_pack_download_url` is present only when a current pack exists and the actor can view it
- `evidence_detail_url` is present only when the actor can view evidence detail
- absence or unavailable wording must not hint at hidden drafts or hidden operator-only artifacts
## Request-Scoped Page State
### CustomerReviewWorkspaceState
**Purpose**: Livewire-safe page state carrying tenant launch context and remembered filters.
**Persistence**: request, query, and session-backed page state only
**Fields**:
- `tenant_id` (nullable requested prefilter)
- `highlight_tenant_id` (nullable)
- `launch_source` (nullable string such as review, review_pack, evidence, or dashboard)
- `search` (nullable)
- `tableFilters` (session-backed when the implementation uses table filters)
**Validation rules**:
- requested tenant filters must resolve to an entitled tenant or the page should respond as not found for explicit tenant targeting
- state that needs to survive Livewire interactions must remain hydrated public or query/session-backed state
- if implementation adds a secondary status filter, it must operate on customer-safe derived labels only, not raw internal lifecycle states
## State Transition Summary
This slice introduces no new persisted lifecycle or status family. Only derived page-state transitions are expected:
- default workspace view -> tenant-prefiltered view
- tenant-prefiltered view -> cleared workspace view
- published review available -> inspect or download action available subject to capability checks
- no published review available -> truthful absence message only
No queue, publish, generate, regenerate, remediate, or archive transition belongs to this page.

View File

@ -1,310 +0,0 @@
# Implementation Plan: Customer Review Workspace v1
**Branch**: `249-customer-review-workspace` | **Date**: 2026-04-27 | **Spec**: [spec.md](spec.md)
**Input**: Feature specification from [spec.md](spec.md)
**Note**: This template is filled in by the `/speckit.plan` command. See `.specify/scripts/` for helper scripts.
## Summary
Introduce one canonical customer-safe review workspace inside the existing `/admin` plane by adding a native Filament v5 read-only page that derives its content from existing tenant review, review-pack, evidence, findings, redaction, and audit truth. The page should answer the first customer question quickly, then reuse the existing tenant-scoped review, review-pack, and evidence detail routes for proof instead of creating a new truth layer.
This slice is explicitly consumption-only. It does not create or publish reviews, generate or regenerate review packs, remediate findings, widen the identity model, or add persistence. Livewire remains v4 under Filament v5, panel-provider registration stays in [../../apps/platform/bootstrap/providers.php](../../apps/platform/bootstrap/providers.php), no new globally searchable resource is introduced, and no new asset bundle is expected for v1.
## Technical Context
**Language/Version**: PHP 8.4, Laravel 12
**Primary Dependencies**: Filament v5, Livewire v4, Pest v4, existing review/evidence/review-pack/audit/RBAC support services
**Storage**: PostgreSQL via existing `tenant_reviews`, `review_packs`, `evidence_snapshots`, findings / finding-exception truth, workspace memberships, and `audit_logs`; no new persistence planned
**Testing**: Pest v4 feature coverage plus one browser smoke slice, with optional narrow unit coverage only if a row-composition helper emerges during implementation
**Validation Lanes**: confidence, browser
**Target Platform**: Laravel monolith in `apps/platform` running via Sail, with existing `/admin` and tenant-scoped `/admin/t/{tenant}` surfaces
**Project Type**: Web application (Laravel monolith with Filament panels)
**Performance Goals**: page render remains DB-only and workspace-scoped; no Graph calls, no queue starts, and no remote work on render; latest review lookup should stay within one eager-loaded workspace read path
**Constraints**: preserve deny-as-not-found workspace and tenant isolation; keep the first slice in the existing admin plane; keep raw diagnostics and debug semantics out of the default path; avoid new persistence, new customer role families, and new presenter/taxonomy layers
**Scale/Scope**: 1 new admin page, 1 derived workspace summary per entitled tenant, reuse of 5 existing read surfaces and their services, 0 new runtime entities, and 1 explicit browser smoke slice
## Likely Affected Repo Surfaces
- [../../apps/platform/app/Filament/Pages/Reviews/ReviewRegister.php](../../apps/platform/app/Filament/Pages/Reviews/ReviewRegister.php) for the existing workspace review register pattern, filter behavior, and action-surface expectations.
- [../../apps/platform/app/Filament/Pages/Monitoring/EvidenceOverview.php](../../apps/platform/app/Filament/Pages/Monitoring/EvidenceOverview.php) for canonical workspace-page state persistence, tenant-prefilter handling, and clickable-row read-only reporting patterns.
- [../../apps/platform/app/Services/TenantReviews/TenantReviewRegisterService.php](../../apps/platform/app/Services/TenantReviews/TenantReviewRegisterService.php) as the preferred entitlement and workspace query seam to extend or reuse before adding any new helper.
- [../../apps/platform/app/Filament/Resources/TenantReviewResource.php](../../apps/platform/app/Filament/Resources/TenantReviewResource.php), [../../apps/platform/app/Filament/Resources/ReviewPackResource.php](../../apps/platform/app/Filament/Resources/ReviewPackResource.php), and [../../apps/platform/app/Filament/Resources/EvidenceSnapshotResource.php](../../apps/platform/app/Filament/Resources/EvidenceSnapshotResource.php) for existing tenant-scoped proof routes, action-surface rules, and capability gates.
- [../../apps/platform/app/Services/ReviewPackService.php](../../apps/platform/app/Services/ReviewPackService.php), [../../apps/platform/app/Http/Controllers/ReviewPackDownloadController.php](../../apps/platform/app/Http/Controllers/ReviewPackDownloadController.php), and [../../apps/platform/routes/web.php](../../apps/platform/routes/web.php) for signed download generation, current pack availability semantics, and the real download route.
- [../../apps/platform/app/Services/TenantReviews/TenantReviewService.php](../../apps/platform/app/Services/TenantReviews/TenantReviewService.php) plus the existing `TenantReview` summary/section payloads for published review truth, findings summaries, and accepted-risk source data.
- [../../apps/platform/app/Support/Ui/GovernanceArtifactTruth/ArtifactTruthPresenter.php](../../apps/platform/app/Support/Ui/GovernanceArtifactTruth/ArtifactTruthPresenter.php) and [../../apps/platform/app/Support/RedactionIntegrity.php](../../apps/platform/app/Support/RedactionIntegrity.php) for customer-safe outcome and redaction wording.
- [../../apps/platform/app/Services/Auth/RoleCapabilityMap.php](../../apps/platform/app/Services/Auth/RoleCapabilityMap.php), [../../apps/platform/app/Support/Auth/Capabilities.php](../../apps/platform/app/Support/Auth/Capabilities.php), and existing tenant review / evidence policies for capability-first RBAC.
- [../../apps/platform/app/Services/Audit/WorkspaceAuditLogger.php](../../apps/platform/app/Services/Audit/WorkspaceAuditLogger.php) and [../../apps/platform/app/Support/Audit/AuditActionId.php](../../apps/platform/app/Support/Audit/AuditActionId.php) for audit reuse.
- Likely new implementation files if code work later proceeds: `App\Filament\Pages\Reviews\CustomerReviewWorkspace`, a matching Blade view under `resources/views/filament/pages/reviews/`, and focused tests under `tests/Feature/Reviews/` and `tests/Browser/Reviews/`.
## UI / Filament & Livewire Fit
- Implement as a native Filament v5 `Page` using the same `HasTable` / `InteractsWithTable` style already used by the workspace review and evidence overview pages. Do not introduce a new Resource, portal shell, custom SPA, or second panel.
- Keep the page in the existing admin-plane reporting family with one primary inspect affordance and, at most, one inline safe download shortcut. The workspace page itself should not expose bulk actions, More-menu sprawl, or lifecycle controls.
- Livewire v4 hydration must preserve tenant prefilter and launch-context state through public, query-backed, or session-backed state. Do not rely on private page properties for any state that must survive a Livewire interaction.
- Tenant detail links should continue using the existing tenant-scoped route helpers from the resource layer so the workspace page stays a navigation surface, not a duplicate detail renderer.
- The new surface is a Page, not a globally searchable Resource. Existing tenant review, review-pack, and evidence resources already have global search disabled, and that remains unchanged for this slice.
## RBAC / Policy Fit
- Workspace membership remains the first gate. The preferred access check is the existing workspace-entitlement path already used by `TenantReviewRegisterService::canAccessWorkspace(...)` and the current workspace context.
- The safe v1 audience anchor remains the existing readonly-capable tenant role in [../../apps/platform/app/Services/Auth/RoleCapabilityMap.php](../../apps/platform/app/Services/Auth/RoleCapabilityMap.php). No new customer role family or external customer identity plane is planned.
- Page visibility and row composition should derive entitled tenants from the canonical capability registry: `TENANT_VIEW` plus `TENANT_REVIEW_VIEW` for page entry, with `REVIEW_PACK_VIEW`, `EVIDENCE_VIEW`, `TENANT_FINDINGS_VIEW`, `FINDING_EXCEPTION_VIEW`, and `AUDIT_VIEW` gating optional secondary proof.
- Non-members or explicit out-of-scope tenant targets must resolve as not found. Once workspace and tenant membership are established, missing secondary capabilities remain normal authorization failures for execution paths and should not leak hidden content through the UI.
- Policy and gate checks stay capability-first. No role-string checks or customer-only bypasses should appear in the implementation.
## Audit / Logging Fit
- Reuse the existing audit infrastructure through `WorkspaceAuditLogger` and `AuditActionId`. The feature should not create a separate audit store, mirror page-view ledger, or custom analytics table.
- Existing review export generation already logs `tenant_review.exported`, and review-pack download already has a real signed route. The plan assumes explicit customer-facing artifact open/download events can remain on the current audit pipeline.
- If the workspace page needs a distinct access event beyond what current review-pack or review actions already capture, add a stable `AuditActionId` case and log it through the shared audit path rather than page-local ad hoc logging.
- Default page render should not emit noisy audit events. The auditable boundary is explicit artifact access or download, not passive page paint.
## Data & Query Fit
- The preferred row source is a derived workspace read over entitled tenants and their latest published `TenantReview`, with eager-loaded `tenant`, `evidenceSnapshot`, and `currentExportReviewPack` relations.
- Accepted-risk and findings summaries should come from existing review summary / section payloads, not from a new customer-specific aggregation model. `TenantReviewSectionFactory` already shapes accepted-risk and finding outcome data into the review artifact.
- Absence handling must remain derived view logic. The page may surface `No published review available yet` or pack-unavailable messaging, but that language must not become a new persisted lifecycle or publication taxonomy.
- No new table, cache, or materialized view is planned. If the existing register service cannot express the exact latest-published-per-tenant query safely, extend that service or add a bounded page-local read helper rather than introducing a new projector or presenter family.
- Pack availability should remain tied to the current review/export relationship and existing signed download semantics. The page must not trigger generation or regeneration.
## UI / Surface Guardrail Plan
> **Fill for operator-facing or guardrail-relevant workflow changes. Docs-only or template-only work may use concise `N/A`. Copy the spec classification forward; do not rename or expand it here.**
- **Guardrail scope**: changed surfaces
- **Native vs custom classification summary**: native Filament
- **Shared-family relevance**: reporting, evidence/report viewers, navigation entry points, review/download actions, disclosure hierarchy
- **State layers in scope**: page, URL-query
- **Audience modes in scope**: customer/read-only, operator-MSP
- **Decision/diagnostic/raw hierarchy plan**: decision-first, diagnostics-second, support-raw-third
- **Raw/support gating plan**: capability-gated and hidden by default through reused detail routes only
- **One-primary-action / duplicate-truth control**: the dominant next action remains `Open latest review`; download is the only inline safe shortcut, and deeper proof stays on existing detail surfaces instead of being repeated on the workspace page
- **Handling modes by drift class or surface**: review-mandatory
- **Repository-signal treatment**: review-mandatory now, future hard-stop candidate if implementation introduces a second customer-review truth path
- **Special surface test profiles**: standard-native-filament, shared-detail-family
- **Required tests or manual smoke**: functional-core, bounded-browser-smoke
- **Exception path and spread control**: none expected; any need for a local presenter, custom disclosure taxonomy, or new detail shell should be treated as exception-required drift
- **Active feature PR close-out entry**: Guardrail / Exception / Smoke Coverage
## Shared Pattern & System Fit
> **Fill when the feature touches notifications, status messaging, action links, header actions, dashboard signals/cards, navigation entry points, alerts, evidence/report viewers, or any other shared interaction family. Docs-only or template-only work may use concise `N/A`. Carry the same decision forward from the spec instead of renaming it here.**
- **Cross-cutting feature marker**: yes
- **Systems touched**: `ReviewRegister`, `EvidenceOverview`, `TenantReviewRegisterService`, `TenantReviewResource`, `ReviewPackResource`, `EvidenceSnapshotResource`, `ReviewPackService`, `TenantReviewService`, `ArtifactTruthPresenter`, `SurfaceCompressionContext`, `ActionSurfaceDeclaration`, `RedactionIntegrity`, `WorkspaceAuditLogger`, `AuditActionId`, and the existing capability / policy seams
- **Shared abstractions reused**: `TenantReviewRegisterService`, `ArtifactTruthPresenter`, `SurfaceCompressionContext`, Filament action-surface declarations, existing tenant-scoped resource URL helpers, `ReviewPackService`, `RedactionIntegrity`, and the existing audit pipeline
- **New abstraction introduced? why?**: none by default. If implementation discovers that the current register service cannot safely express latest-published-per-tenant rows, the smallest acceptable addition is a bounded read helper or service extension for this page only
- **Why the existing abstraction was sufficient or insufficient**: current review, evidence, and pack abstractions already hold the truth and disclosure language; they are insufficient only because there is no calm customer-safe workspace entry point today
- **Bounded deviation / spread control**: none planned. The new page must compose existing truth, not rename it or mirror it into a customer-specific presenter framework
## OperationRun UX Impact
> **Fill when the feature creates, queues, deduplicates, resumes, blocks, completes, or deep-links to an `OperationRun`. Docs-only or template-only work may use concise `N/A`.**
- **Touches OperationRun start/completion/link UX?**: no
- **Central contract reused**: `N/A`
- **Delegated UX behaviors**: `N/A`
- **Surface-owned behavior kept local**: read-only inspection and signed artifact download only; any existing `OperationRun` links stay on reused detail surfaces and are not promoted into the default customer path
- **Queued DB-notification policy**: `N/A`
- **Terminal notification path**: `N/A`
- **Exception path**: none
## Provider Boundary & Portability Fit
> **Fill when the feature touches shared provider/platform seams, identity scope, governed-subject taxonomy, compare strategy selection, provider connection descriptors, or operator vocabulary that may leak provider-specific semantics into platform-core truth. Docs-only or template-only work may use concise `N/A`.**
- **Shared provider/platform boundary touched?**: no
- **Provider-owned seams**: `N/A`
- **Platform-core seams**: existing workspace, tenant, review, evidence, findings, and audit vocabulary only
- **Neutral platform terms / contracts preserved**: `workspace`, `tenant`, `review`, `evidence`, `review pack`, `accepted risk`, and existing artifact-truth wording
- **Retained provider-specific semantics and why**: none; the feature consumes provider-shaped artifacts only through already-normalized platform surfaces
- **Bounded extraction or follow-up path**: `N/A`
## Constitution Check
*GATE: Must pass before Phase 0 research. Re-check after Phase 1 design.*
- Inventory-first / snapshot truth: PASS. The slice consumes existing `TenantReview`, `ReviewPack`, and `EvidenceSnapshot` artifacts as read-only truth and does not redefine source-of-truth boundaries.
- Read/write separation: PASS. The workspace page is read-only and adds no create, publish, regenerate, expire, triage, or remediation action. Any destructive-like action that already exists on reused detail pages remains outside the default path and must continue using confirmation.
- Graph contract path: PASS. No new Graph calls or provider contract work are part of this slice.
- Deterministic capabilities: PASS. The plan reuses the canonical capability registry in [../../apps/platform/app/Support/Auth/Capabilities.php](../../apps/platform/app/Support/Auth/Capabilities.php) and the existing role map.
- Workspace isolation + tenant isolation: PASS. Workspace membership remains a 404 boundary, tenant entitlement remains a 404 boundary, and explicit out-of-scope tenant filters must not leak existence.
- RBAC-UX plane separation: PASS. Everything stays in the existing `/admin` plane and tenant-scoped detail routes; no `/system` surface or cross-plane flow is added.
- Destructive confirmation standard: PASS. No destructive action is planned on the workspace page. If implementation later discovers any destructive affordance on a reused surface must be exposed, it must remain confirmation-protected and out of the default customer-safe path.
- Global search safety: PASS. The new slice is a Page, not a Resource. Existing tenant review, review-pack, and evidence resources are already not globally searchable, and no new searchable resource is introduced.
- OperationRun and Ops-UX: PASS by non-use. The workspace page starts no run, emits no run UX, and performs no queue orchestration.
- Data minimization: PASS. Default-visible content stays decision-first; raw JSON, unrestricted audit metadata, fingerprints, debug semantics, and raw provider payloads stay hidden.
- Test governance (TEST-GOV-001): PASS. Planned proof stays in focused feature tests plus one bounded browser smoke slice, with optional unit coverage only if a local read helper appears.
- Proportionality / no premature abstraction: PASS. No new persistence, presenter family, enum family, or identity plane is planned; the narrowest shape is one page over existing truth seams.
- Persisted truth (PERSIST-001): PASS. No new table, cache, or stored customer-review projection is planned.
- Behavioral state (STATE-001): PASS. Any unavailable or no-published-review wording remains derived UI state, not a new persisted lifecycle.
- UI semantics / shared pattern first / Filament-native UI: PASS. The plan reuses Filament pages/resources, existing badge and artifact-truth presenters, existing download service, and the current disclosure language rather than inventing a new UI framework.
- Provider boundary (PROV-001): PASS. The slice stays within already-normalized review and evidence artifacts and does not deepen provider coupling.
- Filament / Laravel planning contract: PASS. Filament v5 remains on Livewire v4, provider registration stays in [../../apps/platform/bootstrap/providers.php](../../apps/platform/bootstrap/providers.php), no new panel registration is needed, and no new panel-only or shared asset registration is expected.
- Asset strategy: PASS. Default assumption is no new assets. If implementation later registers any Filament asset anyway, deployment continues to require `cd apps/platform && php artisan filament:assets`.
**Gate evaluation**: PASS.
- The slice stays inside the existing admin plane and current workspace/tenant membership model.
- The page remains a customer-safe consumption surface, not a new review-generation or remediation workflow.
- Existing review, evidence, pack, redaction, and audit seams are sufficient for v1 if the implementation resists adding a second presenter or persistence layer.
**Post-design re-check**: PASS (design artifacts: [research.md](research.md), [data-model.md](data-model.md), [quickstart.md](quickstart.md), [contracts/customer-review-workspace.openapi.yaml](contracts/customer-review-workspace.openapi.yaml)).
## Test Governance Check
> **Fill for any runtime-changing or test-affecting feature. Docs-only or template-only work may state concise `N/A` or `none`.**
- **Test purpose / classification by changed surface**: Feature for workspace-page behavior, authorization, and pack-access semantics; Browser for the calm customer-safe disclosure smoke path; optional Unit only if a bounded row-composition helper is extracted
- **Affected validation lanes**: confidence, browser
- **Why this lane mix is the narrowest sufficient proof**: feature coverage is the cheapest way to prove deny-as-not-found behavior, capability-gated secondary actions, empty states, and signed download wiring on a native Filament page; one browser smoke is justified because the product value of this slice is the disclosure experience itself
- **Narrowest proving command(s)**:
- `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/Reviews/CustomerReviewWorkspacePageTest.php`
- `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/Reviews/CustomerReviewWorkspaceAuthorizationTest.php`
- `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/Reviews/CustomerReviewWorkspacePackAccessTest.php`
- `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Browser/Reviews/CustomerReviewWorkspaceSmokeTest.php`
- **Fixture / helper / factory / seed / context cost risks**: moderate but contained; reuse existing workspace membership, tenant membership, published review, ready pack, evidence snapshot, findings, and finding-exception fixtures rather than introducing heavy new helpers
- **Expensive defaults or shared helper growth introduced?**: no; any helper added for row composition or launch-context state should stay explicit and page-local
- **Heavy-family additions, promotions, or visibility changes**: exactly one browser smoke slice only; no broader browser family or heavy-governance lane expansion should be needed
- **Surface-class relief / special coverage rule**: standard-native-filament relief for route, auth, filters, and empty states; shared-detail-family checks only where navigation into existing review/pack/evidence surfaces needs proof
- **Closing validation and reviewer handoff**: rerun the four focused commands above, verify the page never shows admin or remediation controls by default, verify out-of-scope tenant targeting stays 404-safe, and verify download remains signed and capability-bound through the existing pack path
- **Budget / baseline / trend follow-up**: none expected beyond a small feature-local browser cost
- **Review-stop questions**: lane fit, hidden fixture cost, accidental browser family growth, duplicated detail rendering, audit proof adequacy
- **Escalation path**: `document-in-feature` for contained audit-test placement drift; `reject-or-split` if implementation grows into write workflows, new persistence, or a larger browser suite
- **Active feature PR close-out entry**: Guardrail / Exception / Smoke Coverage
- **Why no dedicated follow-up spec is needed**: routine test and disclosure upkeep stays inside this feature unless implementation proves a structural need for a broader customer-access program
## Rollout & Risk Controls
- Keep the v1 audience anchored to the current readonly-capable tenant role plus existing review/evidence capabilities. No navigation or deep link should become visible without those gates.
- Treat the page as a new read-only entry point only. Do not move generation, publish, regenerate, refresh, expire, triage, or remediation controls onto it during implementation.
- Prefer extending the existing register and detail seams over introducing any persisted projection, local presenter family, or customer-only vocabulary.
- Keep signed pack download on the existing route and controller path. Do not replace it with a new download endpoint just for the workspace page.
- Validate the page with one browser smoke before considering any broader navigation prominence changes. The rollout risk is disclosure drift, not infrastructure change.
- No migration, queue worker change, or asset build sequence change is expected for this slice.
## Project Structure
### Documentation (this feature)
```text
specs/249-customer-review-workspace/
├── plan.md
├── research.md
├── data-model.md
├── quickstart.md
├── contracts/
│ └── customer-review-workspace.openapi.yaml
└── tasks.md # Created later by /speckit.tasks, not by this plan step
```
### Source Code (repository root)
```text
apps/platform/
├── app/
│ ├── Filament/
│ │ ├── Pages/Reviews/
│ │ │ ├── ReviewRegister.php
│ │ │ └── CustomerReviewWorkspace.php # likely new page if implementation proceeds
│ │ ├── Pages/Monitoring/EvidenceOverview.php
│ │ └── Resources/
│ │ ├── TenantReviewResource.php
│ │ ├── ReviewPackResource.php
│ │ └── EvidenceSnapshotResource.php
│ ├── Http/Controllers/ReviewPackDownloadController.php
│ ├── Models/TenantReview.php
│ ├── Services/
│ │ ├── Audit/WorkspaceAuditLogger.php
│ │ ├── ReviewPackService.php
│ │ └── TenantReviews/
│ │ ├── TenantReviewRegisterService.php
│ │ └── TenantReviewService.php
│ ├── Support/
│ │ ├── Audit/AuditActionId.php
│ │ ├── Auth/Capabilities.php
│ │ ├── RedactionIntegrity.php
│ │ └── Ui/GovernanceArtifactTruth/ArtifactTruthPresenter.php
│ └── Policies/
│ ├── TenantReviewPolicy.php
│ └── EvidenceSnapshotPolicy.php
├── bootstrap/providers.php
├── resources/views/filament/pages/reviews/ # likely new page view if implementation proceeds
├── routes/web.php
└── tests/
├── Browser/Reviews/
├── Feature/Reviews/
└── Feature/ReviewPack/
```
**Structure Decision**: Laravel monolith. Implementation should stay entirely inside `apps/platform`, add at most one new read-only page and matching Blade view, and reuse the existing review, pack, evidence, RBAC, and audit seams rather than creating a separate customer-facing subsystem.
## Complexity Tracking
| Violation | Why Needed | Simpler Alternative Rejected Because |
|-----------|------------|-------------------------------------|
| None expected | The intended implementation is one native page plus derived queries over existing truth | A separate portal, persistence layer, or customer presenter framework would import unnecessary scope and ownership cost |
## Proportionality Review
- **Current operator problem**: review artifacts already exist, but readonly-capable tenant actors still lack one coherent customer-safe workspace surface to consume them.
- **Existing structure is insufficient because**: current review, pack, and evidence surfaces are truthful but fragmented and more operator-oriented than a calm customer-first entry point.
- **Narrowest correct implementation**: add one native Filament page over existing tenant review, review-pack, evidence, findings, redaction, and audit seams, with only derived row composition and no new persistence.
- **Ownership cost created**: one page, one view, one bounded query/composition seam if required, and focused feature/browser tests.
- **Alternative intentionally rejected**: a new customer portal, a new identity plane, and a new persisted customer-review projection were all rejected because existing admin-plane RBAC and review artifacts are sufficient for the first slice.
- **Release truth**: current-release customer-safe consumption slice, not future-release portal preparation.
## Phase 0 — Research (output: research.md)
Research resolves the remaining implementation-shaping decisions:
- place the new page in the existing admin-plane reviews family rather than extending `ReviewRegister` into a dual-persona page or creating a portal shell
- reuse `TenantReviewRegisterService` as the entitlement/query seam before adding any new helper
- keep tenant prefilter and launch context in Livewire-safe public/query/session-backed state
- reuse `ArtifactTruthPresenter`, existing review summary payloads, and `RedactionIntegrity` for disclosure instead of adding a customer presenter layer
- reuse the existing signed review-pack download route and audit pipeline rather than inventing a new consumption endpoint
**Output**: [research.md](research.md)
## Phase 1 — Design (outputs: data-model.md, contracts/, quickstart.md)
Design artifacts capture the narrow implementation shape:
- no new persistence; reused truth stays in tenant reviews, review packs, evidence snapshots, findings/exceptions, memberships, and audit logs
- one derived workspace entry model documents what the new page must compose without becoming a stored entity
- the conceptual contract documents the page route, tenant-detail handoff, and signed pack-download semantics
- quickstart records the intended implementation order, validation commands, Filament/Livewire assumptions, provider-registration location, and no-new-assets posture
**Artifacts**:
- [data-model.md](data-model.md)
- [contracts/customer-review-workspace.openapi.yaml](contracts/customer-review-workspace.openapi.yaml)
- [quickstart.md](quickstart.md)
## Phase 2 — Planning (for tasks.md)
Dependency-ordered implementation outline for the later `tasks.md` step:
1. Add a native `CustomerReviewWorkspace` admin page and Blade view in the reviews family, keeping the action surface read-only and customer-safe.
2. Reuse or minimally extend `TenantReviewRegisterService` to resolve workspace access, entitled tenants, and the latest published review per entitled tenant with the required eager loads.
3. Compose row content from existing review summary / section payloads, `ArtifactTruthPresenter`, current export review-pack relationships, and `RedactionIntegrity` notes, without creating a new presenter or persistence layer.
4. Preserve launch-context tenant prefiltering and Livewire-safe filter state using the same workspace-page state patterns already proven in `ReviewRegister` and `EvidenceOverview`.
5. Wire the dominant inspect action to the existing tenant-scoped review detail route and keep review-pack download on the current signed route; evidence drilldown remains explicit and capability-gated.
6. Reuse the audit pipeline for explicit artifact access or download events only if the current path does not already emit a truthful stable event; do not add a new audit store.
7. Add focused feature coverage for page behavior, authorization, and pack access, then one browser smoke test for calm disclosure and absence of admin controls. Run Pint after implementation.
## Guardrail / Exception / Smoke Coverage
- Guardrail result: PASS. Filament remains v5 on Livewire v4, panel provider registration stays unchanged in `apps/platform/bootstrap/providers.php`, the feature adds no new globally searchable Resource, no destructive action on the workspace page, and no new asset bundle. Deployment asset handling stays unchanged: `cd apps/platform && php artisan filament:assets` only matters if future registered assets are added.
- Shared seam outcome: `TenantReviewRegisterService` remained the entitlement and latest-published query seam. No local helper or second customer-review truth layer was introduced.
- Launch-path outcome: direct customer-workspace links landed on tenant review detail, review-pack detail, evidence related context, and the tenant review-pack widget. `ReviewRegister` and `EvidenceOverview` were satisfied through existing row/detail navigation reuse instead of duplicate workspace buttons.
- Read-only detail outcome: the workspace handoff now appends a dedicated customer-workspace context query flag, and `ViewTenantReview` suppresses management actions in that customer-safe flow while preserving the established operator detail route behavior outside that flow.
- Pack-download outcome: the existing signed route was retained, but it now also enforces tenant membership plus `REVIEW_PACK_VIEW` at request time. That touched download plumbing and required `ReviewPackDownloadTest.php` plus `ReviewPackRbacTest.php` updates.
- Audit outcome: the existing audit infrastructure was reused with additive `tenant_review.opened` and `review_pack.downloaded` action IDs logged through `WorkspaceAuditLogger`. No new audit store or parallel logging path was introduced.
- Validation lane result: `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/Reviews/CustomerReviewWorkspacePageTest.php tests/Feature/Reviews/CustomerReviewWorkspaceAuthorizationTest.php tests/Feature/Reviews/CustomerReviewWorkspacePackAccessTest.php tests/Feature/Reviews/CustomerReviewWorkspaceLaunchLinksTest.php tests/Feature/Evidence/EvidenceSnapshotResourceTest.php tests/Feature/ReviewPack/ReviewPackWidgetTest.php tests/Feature/ReviewPack/ReviewPackResourceTest.php tests/Feature/ReviewPack/ReviewPackDownloadTest.php tests/Feature/ReviewPack/ReviewPackRbacTest.php tests/Feature/TenantReview/TenantReviewUiContractTest.php tests/Browser/Reviews/CustomerReviewWorkspaceSmokeTest.php` passed with `83 passed (372 assertions)`.
- Smoke evidence: the executed smoke proof was the bounded Pest browser harness in `tests/Browser/Reviews/CustomerReviewWorkspaceSmokeTest.php`, which passed with `1 passed (19 assertions)`. No separate manual integrated-browser run was performed.
- Formatting result: `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail bin pint --dirty --format agent` passed.
- Review outcome class: `acceptable-special-case`.
- Workflow outcome: `keep`.
- Exception note: none. The implementation stayed within the planned admin-plane, read-only, shared-primitives-first shape.

View File

@ -1,59 +0,0 @@
# Quickstart — Customer Review Workspace v1
## Preconditions
- Docker is running and the Sail stack for `apps/platform` is available.
- The feature remains inside the existing Laravel monolith and admin plane.
- The first slice stays read-oriented: no new customer portal, no new identity plane, no new persistence, and no remediation or generation workflow.
## Intended Implementation Order
1. Add the native admin `CustomerReviewWorkspace` page and its Blade view under the existing reviews family.
2. Reuse or minimally extend `TenantReviewRegisterService` to resolve workspace membership, entitled tenants, and latest published reviews per entitled tenant.
3. Compose customer-safe row content from existing `TenantReview` summary / section payloads, `ArtifactTruthPresenter`, `currentExportReviewPack`, and `RedactionIntegrity`.
4. Preserve tenant launch context and remembered filters through Livewire-safe public/query/session-backed state.
5. Wire `Open latest review` to the existing tenant-scoped review detail route and keep review-pack consumption on the existing signed download path.
6. Reuse the existing audit pipeline for any explicit artifact access event that is not already covered by the current review / export flow.
7. Add focused feature coverage and one browser smoke test, then run Pint.
## Targeted Validation Commands (after implementation)
- `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/Reviews/CustomerReviewWorkspacePageTest.php`
- `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/Reviews/CustomerReviewWorkspaceAuthorizationTest.php`
- `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/Reviews/CustomerReviewWorkspacePackAccessTest.php`
- `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Browser/Reviews/CustomerReviewWorkspaceSmokeTest.php`
- If implementation changes pack-download plumbing directly: `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/ReviewPack/ReviewPackDownloadTest.php`
- `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail bin pint --dirty --format agent`
## Smoke Checklist Reference (after implementation)
Implementation close-out used the bounded browser smoke in `tests/Browser/Reviews/CustomerReviewWorkspaceSmokeTest.php` plus the focused feature lane as the executed smoke evidence. The checklist below remains the human reference checklist, but no separate manual integrated-browser run was executed for this implementation close-out.
1. Sign in to `/admin` as a readonly-capable tenant actor, select a workspace, and open `/admin/reviews/workspace`.
2. Confirm that the page shows only entitled tenants, the latest customer-safe review posture, and no create, publish, regenerate, refresh, expire, triage, or remediation controls.
3. Launch the page from an existing tenant-scoped review or evidence route and confirm the tenant prefilter survives the first page load.
4. Open the latest review for a tenant with a published review and confirm the detail remains read-oriented for the readonly actor.
5. Use the pack action for a tenant with a current pack and confirm the download path stays signed and customer-safe; for a tenant without a current pack, confirm the page shows a calm unavailable state instead of a generation action.
6. Attempt an explicit out-of-scope tenant filter or deep link and confirm the result stays not found without leaking tenant existence.
## Executed Validation Evidence
- `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/Reviews/CustomerReviewWorkspacePageTest.php tests/Feature/Reviews/CustomerReviewWorkspaceAuthorizationTest.php tests/Feature/Reviews/CustomerReviewWorkspacePackAccessTest.php tests/Feature/Reviews/CustomerReviewWorkspaceLaunchLinksTest.php tests/Feature/Evidence/EvidenceSnapshotResourceTest.php tests/Feature/ReviewPack/ReviewPackWidgetTest.php tests/Feature/ReviewPack/ReviewPackResourceTest.php tests/Feature/ReviewPack/ReviewPackDownloadTest.php tests/Feature/ReviewPack/ReviewPackRbacTest.php tests/Feature/TenantReview/TenantReviewUiContractTest.php tests/Browser/Reviews/CustomerReviewWorkspaceSmokeTest.php` -> `83 passed (372 assertions)`
- `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Browser/Reviews/CustomerReviewWorkspaceSmokeTest.php` -> `1 passed (19 assertions)`
- `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail bin pint --dirty --format agent` -> `pass`
## Close-out Notes
- `TenantReviewRegisterService` reuse held; no page-local helper was needed.
- The review-pack download route remained signed, but now also enforces tenant membership and `REVIEW_PACK_VIEW` at request time.
- Explicit artifact access is now audited through additive `tenant_review.opened` and `review_pack.downloaded` action IDs on the existing audit pipeline.
- `ReviewRegister` and `EvidenceOverview` satisfied the launch-path requirement through existing row/detail navigation reuse rather than new duplicate workspace buttons.
## Notes
- Filament v5 already runs on Livewire v4 in this repo.
- Panel providers remain registered through [../../apps/platform/bootstrap/providers.php](../../apps/platform/bootstrap/providers.php); this slice does not add or move providers.
- No new globally searchable Resource is part of v1. Existing review, review-pack, and evidence Resources already keep global search disabled.
- No destructive action belongs on the new workspace page. If implementation accidentally introduces one, it must use `->requiresConfirmation()` and stay outside the customer-safe default path.
- No new registered asset bundle is expected. If implementation later registers a Filament asset anyway, deployment still requires `cd apps/platform && php artisan filament:assets`.
- This remains a customer-safe consumption slice only. Review creation, publication, regeneration, remediation, and operator/debug workflows remain on existing internal surfaces or future specs.

View File

@ -1,166 +0,0 @@
# Research — Customer Review Workspace v1
**Date**: 2026-04-27
**Spec**: [spec.md](spec.md)
This document resolves the planning decisions that shape the smallest safe implementation slice for Spec 249.
## Decision 1 — Place the new surface as a native admin reviews page
**Decision**: Implement the customer-safe workspace as a new native Filament page under the existing admin reviews family, with the planned route shape `/admin/reviews/workspace`. Do not create a new panel, a public/customer portal shell, or a new Resource just to host the view.
**Rationale**:
- The repo already has native workspace-level read-only pages for reporting and monitoring.
- The existing review, review-pack, and evidence Resources already own tenant-scoped detail and proof routes.
- A dedicated page keeps the first slice calm and customer-safe without overloading an operator-oriented registry.
**Evidence**:
- [../../apps/platform/app/Filament/Pages/Reviews/ReviewRegister.php](../../apps/platform/app/Filament/Pages/Reviews/ReviewRegister.php) already provides the workspace review register pattern.
- [../../apps/platform/app/Filament/Pages/Monitoring/EvidenceOverview.php](../../apps/platform/app/Filament/Pages/Monitoring/EvidenceOverview.php) already provides a workspace-scoped read-only page pattern with tenant prefilters.
- [../../apps/platform/bootstrap/providers.php](../../apps/platform/bootstrap/providers.php) already registers the existing panel providers. No new provider registration is needed.
**Alternatives considered**:
- Extend `ReviewRegister` into a dual-persona page.
- Rejected: it already carries operator-oriented filters and export semantics, which would blur the customer-safe default path.
- Create a new customer portal or new identity plane.
- Rejected: outside the bounded v1 scope and unnecessary because the current admin plane plus readonly-capable roles already exists.
## Decision 2 — Reuse `TenantReviewRegisterService` as the entitlement and query seam
**Decision**: Prefer extending or reusing [../../apps/platform/app/Services/TenantReviews/TenantReviewRegisterService.php](../../apps/platform/app/Services/TenantReviews/TenantReviewRegisterService.php) for workspace access checks, entitled-tenant discovery, and the base review query before adding any new helper.
**Rationale**:
- The service already centralizes workspace membership and entitled tenant selection for the current review register.
- Reusing it keeps entitlement logic in one place and avoids new raw tenant-role queries inside the page.
- It is the narrowest existing seam that can be extended toward latest-published-per-tenant behavior.
**Evidence**:
- `authorizedTenants(...)` already derives tenant scope from the canonical role/capability map.
- `query(...)` already scopes tenant reviews to the current workspace and eager-loads `tenant`, `evidenceSnapshot`, and `currentExportReviewPack`.
- `canAccessWorkspace(...)` already exposes the workspace-membership check needed for deny-as-not-found page gating.
**Alternatives considered**:
- Build a new persisted workspace summary table.
- Rejected: violates the no-new-persistence rule for a derived read surface.
- Recreate entitlement logic directly inside the page class.
- Rejected: duplicates existing membership and capability behavior.
## Decision 3 — Derive the default path from the latest published tenant review per entitled tenant
**Decision**: The default workspace page should derive each tenant summary from the latest published `TenantReview` for that entitled tenant, with eager-loaded `currentExportReviewPack` and `evidenceSnapshot` relationships.
**Rationale**:
- The spec requires the default path to stay customer-safe and exclude draft, failed, and other internal-only states.
- The current `TenantReview` model already distinguishes published reviews and holds the summary relationships the new page needs.
- This keeps the page read-oriented and avoids a separate customer-review lifecycle.
**Evidence**:
- [../../apps/platform/app/Models/TenantReview.php](../../apps/platform/app/Models/TenantReview.php) already exposes `published()` and `currentExportReviewPack()`.
- [../../apps/platform/app/Services/TenantReviews/TenantReviewService.php](../../apps/platform/app/Services/TenantReviews/TenantReviewService.php) already stores review summary payloads, evidence basis, and export readiness.
- Existing review composition already emits `finding_outcomes` and accepted-risk related payloads through the review artifact family.
**Alternatives considered**:
- Surface draft or ready reviews when no published review exists.
- Rejected: leaks internal lifecycle meaning into the customer-safe path.
- Create a second customer-review publication model.
- Rejected: duplicates review truth and imports unnecessary workflow complexity.
## Decision 4 — Keep page state Livewire-safe and tenant-prefilter aware
**Decision**: Tenant launch context, requested tenant filters, and any remembered page state must live in public, query-backed, or session-backed state, following the existing workspace-page patterns. Do not keep required filter state in private properties.
**Rationale**:
- Existing workspace pages already show how canonical admin pages preserve tenant prefilters and survive Livewire follow-up requests.
- This repo has already hit Livewire state-reset issues when tenant context lived in non-hydrated private properties.
- The workspace page needs tenant prefiltering from review, evidence, and related entry points.
**Evidence**:
- [../../apps/platform/app/Filament/Pages/Reviews/ReviewRegister.php](../../apps/platform/app/Filament/Pages/Reviews/ReviewRegister.php) uses canonical admin filter-state sync on mount.
- [../../apps/platform/app/Filament/Pages/Monitoring/EvidenceOverview.php](../../apps/platform/app/Filament/Pages/Monitoring/EvidenceOverview.php) documents its page-state contract for tenant prefilters and remembered search/filter state.
- Repo memory already records that private page state can reset during Livewire actions on admin canonical pages.
**Alternatives considered**:
- Keep launch context in a private property only.
- Rejected: too brittle across Livewire requests.
- Use only client-side state.
- Rejected: breaks server-side truth and shareable canonical page behavior.
## Decision 5 — Reuse artifact-truth and redaction seams for customer-safe disclosure
**Decision**: Reuse [../../apps/platform/app/Support/Ui/GovernanceArtifactTruth/ArtifactTruthPresenter.php](../../apps/platform/app/Support/Ui/GovernanceArtifactTruth/ArtifactTruthPresenter.php), `SurfaceCompressionContext`, and [../../apps/platform/app/Support/RedactionIntegrity.php](../../apps/platform/app/Support/RedactionIntegrity.php) for outcome, freshness, and redaction-safe wording.
**Rationale**:
- These seams already normalize review, review-pack, and evidence truth into operator-safe summaries.
- Reusing them preserves vocabulary and prevents a second customer-review explanation system.
- `RedactionIntegrity` already owns the repos protected-value and support-diagnostics notes.
**Evidence**:
- `ArtifactTruthPresenter::for(...)` already supports `TenantReview`, `ReviewPack`, and `EvidenceSnapshot`.
- `ReviewRegister`, `EvidenceOverview`, `TenantReviewResource`, and `ReviewPackResource` already depend on these truth envelopes.
- `RedactionIntegrity` already defines reusable disclosure notes for protected values and support diagnostics.
**Alternatives considered**:
- Introduce a customer-only presenter or status taxonomy.
- Rejected: duplicates shared artifact truth and increases review drift risk.
- Inline page-local disclosure strings only.
- Rejected: likely to diverge from existing review and pack semantics.
## Decision 6 — Keep review-pack consumption on the existing signed download path
**Decision**: Pack consumption should stay on the existing signed route and download controller, with the workspace page only generating or surfacing the already-authorized download path through [../../apps/platform/app/Services/ReviewPackService.php](../../apps/platform/app/Services/ReviewPackService.php).
**Rationale**:
- The repo already has a real signed download route and a dedicated download controller.
- Reusing that path keeps the new page consumption-only and avoids inventing a customer-specific download endpoint.
- The page must not trigger pack generation or regeneration.
**Evidence**:
- [../../apps/platform/app/Services/ReviewPackService.php](../../apps/platform/app/Services/ReviewPackService.php) already generates signed download URLs.
- [../../apps/platform/routes/web.php](../../apps/platform/routes/web.php) already exposes `/admin/review-packs/{reviewPack}/download` as the signed download route.
- [../../apps/platform/app/Http/Controllers/ReviewPackDownloadController.php](../../apps/platform/app/Http/Controllers/ReviewPackDownloadController.php) already enforces pack readiness and expiry constraints.
**Alternatives considered**:
- Add a new workspace-page-specific download endpoint.
- Rejected: duplicates current signed download behavior.
- Offer generate/regenerate from the workspace page.
- Rejected: out of scope and not customer-safe for v1.
## Decision 7 — Reuse the current audit pipeline and add new action IDs only if needed
**Decision**: Reuse `WorkspaceAuditLogger` and `AuditActionId` for any explicit artifact access or download events surfaced by the new page, and only add new stable action IDs if the existing review/export path does not already provide a truthful event.
**Rationale**:
- The repo already has a canonical workspace-scoped audit path.
- This slice needs auditability for explicit artifact consumption, not a new access-analytics subsystem.
- Stable action IDs are preferable to page-local logging if an additional event is truly required.
**Evidence**:
- [../../apps/platform/app/Services/TenantReviews/TenantReviewService.php](../../apps/platform/app/Services/TenantReviews/TenantReviewService.php) already logs review creation/refresh through `WorkspaceAuditLogger`.
- [../../apps/platform/app/Services/ReviewPackService.php](../../apps/platform/app/Services/ReviewPackService.php) already logs review-pack export activity.
- [../../apps/platform/app/Support/Audit/AuditActionId.php](../../apps/platform/app/Support/Audit/AuditActionId.php) is the stable audit action registry.
**Alternatives considered**:
- Add a new customer-review audit table.
- Rejected: violates the no-new-persistence rule.
- Emit page-render audits for every visit.
- Rejected: too noisy and not aligned with the explicit-artifact-access requirement.
## Decision 8 — Keep the slice Filament-native, asset-light, and non-searchable
**Decision**: Keep the slice on the existing Filament v5 / Livewire v4 stack, do not add a new Resource or global-search entry, and plan for no new asset bundle unless implementation proves otherwise.
**Rationale**:
- The feature is a new page over existing truth, not a new object family.
- Existing review, pack, and evidence Resources already disable global search because they are tenant-scoped.
- A native page avoids a second shell and keeps the deploy story unchanged.
**Evidence**:
- `TenantReviewResource`, `ReviewPackResource`, and `EvidenceSnapshotResource` already set global search off.
- Panel providers are already registered in [../../apps/platform/bootstrap/providers.php](../../apps/platform/bootstrap/providers.php).
- The repos Filament guidance already expects provider registration to remain in `bootstrap/providers.php` and assets to stay minimal unless explicitly registered.
**Alternatives considered**:
- Add a new searchable Resource just for the workspace page.
- Rejected: the surface is a page-level dashboard, not a new record type.
- Add a custom asset bundle or custom portal shell up front.
- Rejected: unnecessary for the first read-only slice.

View File

@ -1,299 +0,0 @@
# Feature Specification: Customer Review Workspace v1
**Feature Branch**: `249-customer-review-workspace`
**Created**: 2026-04-27
**Status**: Draft
**Input**: User description: "Prepare the Spec Kit feature for Customer Review Workspace v1 as the smallest customer-safe read-only review consumption slice in the existing admin plane, reusing current review, evidence, review-pack, RBAC, redaction, and audit truth without inventing a new customer portal or remediation flow."
## Spec Candidate Check *(mandatory - SPEC-GATE-001)*
- **Problem**: TenantPilot already has strong tenant review, evidence snapshot, and review-pack foundations, but customers and readonly-capable tenant actors still lack one calm, trustworthy workspace surface to consume the latest review state without being dropped into operator-heavy reporting detail.
- **Today's failure**: The product can generate review artifacts, but it cannot yet present them as a clearly customer-safe, read-only review experience. That leaves a sellable release gap and risks pushing readonly actors toward internal surfaces with too much operator context or unclear next steps.
- **User-visible improvement**: An authorized readonly-capable actor can open one workspace review surface, see the latest customer-safe review state per entitled tenant, understand key findings and accepted risks, and open or download existing review artifacts without seeing admin or remediation controls.
- **Smallest enterprise-capable version**: One canonical read-only workspace review page in the current `/admin` plane, defaulting to the latest published customer-safe review per entitled tenant, with calm outcome summaries, accepted-risk visibility, existing review-pack consumption, redaction-safe disclosure, and explicit absence of admin/remediation actions.
- **Explicit non-goals**: No new customer portal, no new identity plane, no new persistence model, no review authoring or publishing workflow, no remediation or exception editing, no review-pack generation/regeneration flow, no support desk workflow, no broad cross-tenant decision inbox, and no raw JSON or platform-debug surface.
- **Permanent complexity imported**: One new canonical read-only page, one bounded derived workspace projection over existing review/evidence/review-pack truth, focused authorization and audit coverage, and one explicit browser smoke slice for customer-safe disclosure.
- **Why now**: The implementation ledger marks this as a P0 release blocker. Existing review strength is real, but customer-safe review consumption is still the clearest missing sellable surface in the current queue.
- **Why not local**: Reusing isolated links into `TenantReviewResource`, `ReviewPackResource`, and `EvidenceSnapshotResource` without a canonical workspace entry point would preserve the current fragmentation and would not create a truthful customer-safe default path.
- **Approval class**: Core Enterprise
- **Red flags triggered**: Multi-surface reuse and customer-facing wording. Defense: the slice stays inside the existing admin plane, imports no new persistence or identity system, reuses current artifact truth and RBAC seams, and explicitly forbids write paths.
- **Score**: Nutzen: 2 | Dringlichkeit: 2 | Scope: 2 | Komplexitaet: 1 | Produktnaehe: 2 | Wiederverwendung: 2 | **Gesamt: 11/12**
- **Decision**: approve
## Spec Scope Fields *(mandatory)*
- **Scope**: canonical-view
- **Primary Routes**:
- new canonical admin route for a read-only customer review workspace under `/admin/reviews/workspace`
- existing `/admin/reviews` workspace review register on `App\Filament\Pages\Reviews\ReviewRegister` as supporting context, not the primary customer-safe path
- existing tenant-scoped review detail on `App\Filament\Resources\TenantReviewResource`
- existing tenant-scoped review-pack detail/download on `App\Filament\Resources\ReviewPackResource`
- existing tenant-scoped evidence detail on `App\Filament\Resources\EvidenceSnapshotResource`
- **Data Ownership**: All consumed truth remains tenant-owned and derived from existing `TenantReview`, `ReviewPack`, `EvidenceSnapshot`, finding/exception, and audit records bound to the current workspace and tenant. No new workspace-owned customer-review table, cache, mirror entity, or publication store is introduced.
- **RBAC**:
- workspace membership remains the first isolation boundary
- page entry requires established workspace scope plus at least one entitled tenant where the actor has `Capabilities::TENANT_VIEW` and `Capabilities::TENANT_REVIEW_VIEW`
- tenant rows and deep links only render for tenants the actor can access in the current workspace
- review-pack download remains gated by `Capabilities::REVIEW_PACK_VIEW`
- evidence drilldown remains gated by `Capabilities::EVIDENCE_VIEW`
- findings and accepted-risk sections reuse `Capabilities::TENANT_FINDINGS_VIEW` and `Capabilities::FINDING_EXCEPTION_VIEW`
- audit-related secondary disclosure, if present, remains gated by `Capabilities::AUDIT_VIEW`
- no new role family or customer identity plane is introduced; existing readonly-capable roles in `App\Services\Auth\RoleCapabilityMap` remain authoritative for v1
For canonical-view specs, the spec MUST define:
- **Default filter behavior when tenant-context is active**: When launched from a tenant-scoped review, review-pack, evidence, or tenant dashboard surface, the workspace page prefilters to that tenant and highlights its latest customer-safe review first. Without a launch context, it shows all entitled tenants in the current workspace.
- **Explicit entitlement checks preventing cross-tenant leakage**: Workspace membership is checked before page render. Tenant-scoped rows, summaries, and deep links are resolved only for tenants where the actor is both a workspace member and tenant-entitled. Explicit tenant filters or record opens that reference an inaccessible tenant resolve as not found rather than showing an empty hint.
## Cross-Cutting / Shared Pattern Reuse *(mandatory when the feature touches notifications, status messaging, action links, header actions, dashboard signals/cards, alerts, navigation entry points, evidence/report viewers, or any other existing shared operator interaction family; otherwise write `N/A - no shared interaction family touched`)*
- **Cross-cutting feature?**: yes
- **Interaction class(es)**: evidence/report viewers, status messaging, navigation entry points, review/download actions, and artifact-truth presentation
- **Systems touched**: `App\Filament\Pages\Reviews\ReviewRegister`, `App\Filament\Pages\Monitoring\EvidenceOverview`, `App\Filament\Resources\TenantReviewResource`, `App\Filament\Resources\ReviewPackResource`, `App\Filament\Resources\EvidenceSnapshotResource`, `App\Services\ReviewPackService`, `App\Services\TenantReviews\TenantReviewService`, `App\Support\Ui\GovernanceArtifactTruth\ArtifactTruthPresenter`, `App\Support\RedactionIntegrity`, `App\Support\OperationRunLinks`, existing audit infrastructure, and tenant/workspace authorization seams
- **Existing pattern(s) to extend**: current read-only registry/detail reporting surfaces, existing governance artifact truth envelopes, existing review-pack download semantics, existing redaction notes, and existing workspace/tenant-scoped navigation patterns
- **Shared contract / presenter / builder / renderer to reuse**: `ArtifactTruthPresenter`, `SurfaceCompressionContext`, `ActionSurfaceDeclaration`, `ReviewPackService`, `RedactionIntegrity`, and existing tenant-scoped resource view surfaces
- **Why the existing shared path is sufficient or insufficient**: Existing review/evidence/review-pack surfaces already provide the underlying truth, disclosure semantics, and safe detail rendering. They are insufficient only because they do not offer one calm workspace entry point oriented around customer-safe consumption. The feature should add that entry point, not a parallel truth layer.
- **Allowed deviation and why**: none. The new page must reuse current truth, badge, redaction, and download language instead of inventing a second customer-review vocabulary.
- **Consistency impact**: Outcome, freshness, accepted-risk, pack-availability, and redaction notes must keep the same meaning across the new workspace page and the reused review, evidence, and review-pack detail surfaces.
- **Review focus**: Reviewers must block any new page-local status taxonomy, raw-payload viewer, or customer-specific mirror presenter that duplicates the existing review and artifact truth contracts.
## OperationRun UX Impact *(mandatory when the feature creates, queues, deduplicates, resumes, blocks, completes, or deep-links to an `OperationRun`; otherwise write `N/A - no OperationRun start or link semantics touched`)*
- **Touches OperationRun start/completion/link UX?**: no
- **Shared OperationRun UX contract/layer reused**: `N/A`
- **Delegated start/completion UX behaviors**: `N/A`
- **Local surface-owned behavior that remains**: The workspace page is read-only. Existing `OperationRun` links stay on reused detail surfaces and are not promoted into the default-visible customer path.
- **Queued DB-notification policy**: `N/A`
- **Terminal notification path**: `N/A`
- **Exception required?**: none
## Provider Boundary / Platform Core Check *(mandatory when the feature changes shared provider/platform seams, identity scope, governed-subject taxonomy, compare strategy selection, provider connection descriptors, or operator vocabulary that may leak provider-specific semantics into platform-core truth; otherwise write `N/A - no shared provider/platform boundary touched`)*
N/A - no shared provider/platform boundary is widened. The feature consumes existing review and evidence artifacts without introducing new provider-shaped contracts or customer-identity semantics.
## UI / Surface Guardrail Impact *(mandatory when operator-facing surfaces are changed; otherwise write `N/A`)*
| Surface / Change | Operator-facing surface change? | Native vs Custom | Shared-Family Relevance | State Layers Touched | Exception Needed? | Low-Impact / `N/A` Note |
|---|---|---|---|---|---|---|
| Customer review workspace page | yes | Native Filament page reusing existing review/detail resources | reporting, evidence viewers, download actions, disclosure hierarchy | page state, tenant prefilter state, disclosure state | no | Adds one canonical customer-safe workspace path without creating a separate portal shell |
## Decision-First Surface Role *(mandatory when operator-facing surfaces are changed)*
| Surface | Decision Role | Human-in-the-loop Moment | Immediately Visible for First Decision | On-Demand Detail / Evidence | Why This Is Primary or Why Not | Workflow Alignment | Attention-load Reduction |
|---|---|---|---|---|---|---|---|
| Customer review workspace page | Primary Decision Surface | A readonly-capable tenant actor decides whether the latest review is consumable as-is or needs a follow-up conversation with the workspace operator team | latest customer-safe review outcome, key finding counts, accepted-risk summary, published date, and pack availability | latest review detail, review-pack detail/download, and evidence detail only when explicitly opened and capability-allowed | Primary because it becomes the first truthful customer-safe entry point instead of forcing users to reconstruct the answer from internal reporting resources | Keeps review consumption inside one calm workspace path and uses existing detail routes only when the user asks for proof | Replaces cross-surface searching with one page that summarizes what matters first and delays diagnostics until requested |
## Audience-Aware Disclosure *(mandatory when operator-facing surfaces are changed)*
| Surface | Audience Modes In Scope | Decision-First Default-Visible Content | Operator Diagnostics | Support / Raw Evidence | One Dominant Next Action | Hidden / Gated By Default | Duplicate-Truth Prevention |
|---|---|---|---|---|---|---|---|
| Customer review workspace page | customer-read-only, operator-MSP | latest published review state, executive outcome, key findings, accepted risks, published or generated time, and review-pack availability | deeper evidence freshness, full section detail, and secondary related links only after explicit open | raw JSON, unrestricted audit metadata, provider payloads, and platform-only debug semantics remain hidden and are never part of the default page | `Open latest review` | raw/support detail is excluded from the page; evidence and audit drilldown remain capability-gated on reused detail routes | the workspace page states one summary truth per tenant and relies on existing review/pack/evidence detail pages for proof instead of repeating the same explanation in parallel blocks |
## UI/UX Surface Classification *(mandatory when operator-facing surfaces are changed)*
| Surface | Action Surface Class | Surface Type | Likely Next Operator Action | Primary Inspect/Open Model | Row Click | Secondary Actions Placement | Destructive Actions Placement | Canonical Collection Route | Canonical Detail Route | Scope Signals | Canonical Noun | Critical Truth Visible by Default | Exception Type / Justification |
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| Customer review workspace page | List / Table / Read-only workspace report | Read-only registry report | Open the latest review for one tenant or download the latest available review pack | full-row navigation to the latest customer-safe tenant review | required | one safe inline download shortcut when a pack is already available; any deeper proof remains inside the opened detail view | none | `/admin/reviews/workspace` | `/admin/t/{tenant}/reviews/{record}` with secondary reuse of tenant-scoped review-pack and evidence detail routes | workspace context, tenant filter, and latest published-review status | Customer review | whether a tenant has a current customer-safe review, what it says at a high level, and whether a pack is available | none |
## Operator Surface Contract *(mandatory when operator-facing surfaces are changed)*
| Surface | Primary Persona | Decision / Operator Action Supported | Surface Type | Primary Operator Question | Default-visible Information | Diagnostics-only Information | Status Dimensions Used | Mutation Scope | Primary Actions | Dangerous Actions |
|---|---|---|---|---|---|---|---|---|---|---|
| Customer review workspace page | Readonly tenant actor inside the existing admin plane | Consume the latest customer-safe review and decide whether a follow-up conversation is needed | Workspace read-only review overview | What is the latest reviewed state for my entitled tenant, what risks are already accepted, and what can I safely open or download? | tenant identity, latest published review state, outcome summary, key findings summary, accepted-risk summary, latest review time, and review-pack availability | secondary proof routes, evidence freshness detail, and audit-aware artifact provenance only after explicit drilldown | review lifecycle, governance outcome, evidence freshness, pack availability | none | Open latest review, Download review pack | none |
## Proportionality Review *(mandatory when structural complexity is introduced)*
- **New source of truth?**: no
- **New persisted entity/table/artifact?**: no
- **New abstraction?**: no. V1 should reuse existing review, evidence, redaction, and artifact-truth seams directly.
- **New enum/state/reason family?**: no
- **New cross-domain UI framework/taxonomy?**: no
- **Current operator problem**: Review artifacts already exist, but there is still no product-honest customer-safe way to consume them as a coherent workspace review experience.
- **Existing structure is insufficient because**: Existing review register and tenant-scoped resource views are good proof surfaces, but they are not a calm customer-default path and they spread the answer across several internal pages.
- **Narrowest correct implementation**: Add one read-only workspace page over existing tenant review, review-pack, evidence, redaction, and RBAC truth, and defer any customer-specific identity, publishing workflow, or portal shell.
- **Ownership cost**: One page, one bounded workspace query/projection, focused authorization tests, and a small browser smoke slice.
- **Alternative intentionally rejected**: A separate customer portal or customer-specific persistence model was rejected because the repo already has the required review artifacts and readonly-capable roles in the current admin plane.
- **Release truth**: current-release blocker, not future-release preparation
### Compatibility posture
This feature assumes a pre-production environment.
Backward compatibility, legacy aliases, migration shims, historical fixtures, and compatibility-specific tests are out of scope unless explicitly required by this spec.
Canonical replacement is preferred over preservation.
## Testing / Lane / Runtime Impact *(mandatory for runtime behavior changes)*
- **Test purpose / classification**: Feature, Browser
- **Validation lane(s)**: confidence, browser
- **Why this classification and these lanes are sufficient**: Focused feature tests prove workspace and tenant isolation, capability gating, default-visible disclosure, deep-link rules, and no-write behavior. One explicit browser smoke test proves the calm read-only surface, the absence of admin actions, and the expected open/download flow under realistic UI conditions.
- **New or expanded test families**: one bounded `Reviews/CustomerReviewWorkspace` feature family and one explicit browser smoke test for the same surface
- **Fixture / helper cost impact**: moderate but contained; reuse existing workspace membership, tenant membership, tenant review, review pack, evidence snapshot, finding, finding exception, and audit fixtures instead of adding new heavy provider or queue defaults
- **Heavy-family visibility / justification**: exactly one browser smoke is justified because the core value of this slice is a customer-safe disclosure experience; no broader browser or heavy-governance family is introduced
- **Special surface test profile**: standard-native-filament, shared-detail-family
- **Standard-native relief or required special coverage**: standard Filament feature coverage is sufficient for routing, authorization, empty states, and deep-link rules; a single browser smoke should verify that the default-visible page stays calm and read-only
- **Reviewer handoff**: Reviewers must confirm that readonly actors can use the surface, unauthorized tenant filters or deep links do not leak tenant presence, raw diagnostics never appear by default, and no create, publish, regenerate, refresh, expire, triage, or remediation action becomes visible on the customer workspace page.
- **Budget / baseline / trend impact**: low feature-local increase only
- **Escalation needed**: none
- **Active feature PR close-out entry**: Guardrail / Exception / Smoke Coverage
- **Planned validation commands**:
- `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/Reviews/CustomerReviewWorkspacePageTest.php`
- `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/Reviews/CustomerReviewWorkspaceAuthorizationTest.php`
- `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/Reviews/CustomerReviewWorkspacePackAccessTest.php`
- `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Browser/Reviews/CustomerReviewWorkspaceSmokeTest.php`
## Scope Boundaries
### In Scope
- one canonical workspace-level read-only customer review surface in the existing admin plane
- latest published customer-safe review state per entitled tenant
- key findings and accepted-risk summaries derived from existing review and finding-exception truth
- opening existing tenant review detail pages from the workspace surface
- opening or downloading existing review-pack artifacts when already available and permitted
- optional drilldown into existing evidence detail only through explicit, capability-gated navigation
- redaction-safe disclosure using existing redaction semantics and notes
- auditability for explicit artifact access and download actions using the current audit infrastructure
### Non-Goals
- any new customer portal shell, customer account model, or external identity plane
- authoring, publishing, archiving, regenerating, refreshing, expiring, or deleting review artifacts
- exception editing, risk acceptance changes, or findings remediation flows
- raw JSON, provider payloads, unrestricted audit metadata, support diagnostics, or platform-debug semantics in the default path
- new review persistence, new publication state families, or new workspace-owned review entities
- support desk flow, billing, contracts, or broader customer lifecycle workflows
- cross-tenant decision inboxes, promotion workflows, or broad MSP workboards
## Assumptions
- The customer-safe default path should use the latest published review for each entitled tenant. Draft, failed, or otherwise internal-only review states stay off the default workspace page.
- Existing readonly-capable tenant roles are sufficient for v1 and do not require a new customer-only role family.
- Accepted-risk disclosure can be derived from existing finding and finding-exception truth without creating a parallel customer-review reason model.
- Existing redaction notes and review-pack download controls are sufficient for v1 customer-safe disclosure.
## Risks
- Some tenants may have strong internal review artifacts but no published customer-safe review yet, which can make the new surface appear empty unless absence states are explained clearly.
- Existing review detail pages may still contain operator-oriented sections that need tighter entry rules or more careful disclosure when reached from the new workspace path.
- Partial capability combinations could produce uneven disclosure if the implementation does not clearly separate page-level access from optional deep-link sections.
- A later implementation could try to fold review-pack generation or broader customer portal scope into this slice; that must be rejected as out-of-scope growth.
## Follow-up Candidates
- customer-facing portal or external identity work only if the current admin-plane read-only model becomes insufficient
- support diagnostic pack linkage from customer review artifacts once the support packaging flow needs direct customer-facing entry
- explicit review publication workflow maturity if published versus ready review semantics need a broader operator workflow
- broader customer lifecycle and commercial packaging once review consumption no longer fits inside the existing admin plane
## User Scenarios & Testing *(mandatory)*
### User Story 1 - Open the latest customer-safe review (Priority: P1)
As a readonly-capable tenant actor, I want one workspace page that shows the latest customer-safe review state for my entitled tenant so I can understand the current posture without navigating several internal reporting screens.
**Why this priority**: This is the core product gap. If the user still needs to reconstruct the latest review state manually, the slice fails its purpose.
**Independent Test**: Sign in as a readonly-capable tenant actor with one or more entitled tenants, open the customer review workspace, and verify that each visible tenant row shows only the latest published customer-safe review summary.
**Acceptance Scenarios**:
1. **Given** the actor is entitled to one or more tenants with published reviews, **When** they open the workspace review page, **Then** they see one latest customer-safe review entry per entitled tenant and no draft-only review rows.
2. **Given** the actor launches the page from a tenant-scoped review or evidence route, **When** the workspace page opens, **Then** that tenant is prefiltered and its latest published review is highlighted first.
3. **Given** the actor has no entitled tenants with published reviews, **When** they open the page, **Then** they see a truthful absence state that does not reveal hidden drafts or inaccessible tenants.
---
### User Story 2 - Understand findings and accepted risks without admin controls (Priority: P1)
As a readonly-capable tenant actor, I want the latest review summary to explain key findings and accepted risks in calm language so I can understand what matters without seeing remediation or operator-only actions.
**Why this priority**: Customer-safe review consumption is not useful if the page still looks like an operator console or hides the meaning behind the review outcome.
**Independent Test**: Open the workspace page and the latest review detail for a tenant that has findings and accepted risks, then verify that the user can understand the current outcome without seeing create, publish, regenerate, expire, triage, or remediation controls.
**Acceptance Scenarios**:
1. **Given** a tenant has a published review with findings and accepted risks, **When** the actor opens the workspace page, **Then** the row or summary exposes the high-level counts and meaning of those items without requiring a drilldown first.
2. **Given** the actor opens the latest review detail from the workspace page, **When** the detail loads, **Then** the review remains read-only and does not expose admin or remediation actions the actor cannot use.
3. **Given** raw diagnostics or unrestricted audit metadata exist behind the review, **When** the actor uses the customer workspace flow, **Then** those details remain hidden from the default-visible path.
---
### User Story 3 - Consume the current review pack safely (Priority: P2)
As a readonly-capable tenant actor, I want to open or download the current review pack when it already exists so I can consume the packaged review output without triggering generation or seeing unsafe disclosure.
**Why this priority**: Review consumption is incomplete if the user can read the summary but cannot reach the packaged artifact that already represents the customer-safe deliverable.
**Independent Test**: From the workspace page, open a tenant that has a current review pack and verify that download works through existing access and redaction rules, while tenants without an available pack show a calm unavailable state.
**Acceptance Scenarios**:
1. **Given** a tenant has a current review pack and the actor has `REVIEW_PACK_VIEW`, **When** they choose the pack action, **Then** they can open or download the existing artifact without any generate or regenerate prompt.
2. **Given** a tenant has no current downloadable review pack, **When** the actor views the workspace page, **Then** the page shows that the pack is unavailable and does not offer a generation action.
3. **Given** a review pack includes redaction-safe content only, **When** the actor downloads it, **Then** the artifact and surrounding disclosure continue to honor existing redaction semantics.
### Edge Cases
- What happens when a tenant has a ready review but nothing published yet? The workspace page shows `No published review available yet` rather than exposing internal-only lifecycle states.
- What happens when a query parameter or remembered filter points at a tenant outside the actor's scope? The page resolves as not found for explicit tenant targeting and silently omits inaccessible tenants from broad workspace listings.
- What happens when the actor can view reviews but not review packs or evidence? The page remains usable, but pack and evidence actions are absent rather than replaced with leaking hints.
- What happens when a review pack exists but is expired or otherwise unavailable for consumption? The page shows an unavailable state and does not offer regeneration or admin recovery actions.
## Requirements *(mandatory)*
**Constitution alignment (required):** This feature does not introduce Graph calls, write/change behavior, or long-running work. It does change runtime behavior, authorization posture, disclosure rules, and audit expectations for a new read-only customer-facing surface in the existing admin plane.
**Constitution alignment (PROP-001 / ABSTR-001 / PERSIST-001 / STATE-001 / BLOAT-001):** This feature must stay derived. It must not add new persistence, new customer-state families, new publication semantics, or a parallel presenter framework.
**Constitution alignment (XCUT-001):** The feature must extend existing review, evidence, review-pack, and artifact-truth paths rather than creating a local customer-review semantic layer.
**Constitution alignment (DECIDE-AUD-001 / OPSURF-001):** The default path must remain customer-readable, decision-first, and free from raw diagnostics, with deeper proof only on demand.
**Constitution alignment (TEST-GOV-001):** The implementation must add focused feature tests plus one explicit browser smoke test; no hidden heavy family may spread from this slice.
**Constitution alignment (RBAC-UX):** Workspace and tenant membership remain deny-as-not-found boundaries; page and deep-link authorization must use canonical capability checks rather than raw role checks.
**Constitution alignment (UI-FIL-001 / UI-NAMING-001 / DECIDE-001 / ACTSURF-001):** The new surface must remain a native Filament read-only reporting page with one dominant inspect action, one optional safe download shortcut, and no destructive or remediation controls.
### Functional Requirements
- **FR-001**: The system MUST provide one canonical read-only customer review workspace in the existing `/admin` plane for the current workspace.
- **FR-002**: The system MUST list only entitled tenants and MUST derive each visible row or card from existing tenant-owned review, evidence, review-pack, and findings truth.
- **FR-003**: The default-visible page MUST show the latest published customer-safe review state per entitled tenant and MUST NOT expose draft, failed, or other internal-only review states as the primary customer path.
- **FR-004**: The page MUST show, for each visible tenant, the current review outcome, latest review time, key findings summary, accepted-risk summary, and review-pack availability in calm, read-only language.
- **FR-005**: The page MUST offer a primary inspect action that opens the existing tenant-scoped review detail for the latest customer-safe review.
- **FR-006**: The page MUST allow entitled actors to open or download an existing review pack only through current `REVIEW_PACK_VIEW` access and existing redaction-safe artifact rules.
- **FR-007**: The page MUST NOT expose review generation, publication, regeneration, refresh, expire, triage, risk acceptance, remediation, or admin-setting actions.
- **FR-008**: The page and its deep links MUST enforce workspace and tenant isolation such that non-members or out-of-scope tenant targets resolve as not found.
- **FR-009**: Within an established workspace and tenant scope, optional sections and actions MUST be gated through the canonical capability registry, including `TENANT_VIEW`, `TENANT_REVIEW_VIEW`, `REVIEW_PACK_VIEW`, `EVIDENCE_VIEW`, `TENANT_FINDINGS_VIEW`, `FINDING_EXCEPTION_VIEW`, and `AUDIT_VIEW` where relevant.
- **FR-010**: The feature MUST reuse existing artifact truth and publication-readiness semantics from current review, review-pack, and evidence surfaces and MUST NOT create a separate customer-review truth model.
- **FR-011**: Raw operator diagnostics, raw JSON or provider payloads, unrestricted audit metadata, and platform-only debug semantics MUST remain out of the default-visible customer workspace path.
- **FR-012**: Explicit artifact opens or downloads exposed through this surface MUST remain auditable using the current audit infrastructure without introducing a new audit store.
- **FR-013**: When entered from a tenant-scoped review, review-pack, evidence, or related tenant context, the workspace page MUST preserve that tenant context as a safe prefilter.
- **FR-014**: When no published customer-safe review or downloadable review pack exists, the page MUST show a truthful unavailable state instead of hinting at hidden drafts, operator-only artifacts, or unavailable generation paths.
## UI Action Matrix *(mandatory when Filament is changed)*
| Surface | Location | Header Actions | Inspect Affordance (List/Table) | Row Actions (max 2 visible) | Bulk Actions (grouped) | Empty-State CTA(s) | View Header Actions | Create/Edit Save+Cancel | Audit log? | Notes / Exemptions |
|---|---|---|---|---|---|---|---|---|---|---|
| Customer Review Workspace | new `App\Filament\Pages\Reviews\CustomerReviewWorkspace` | `Clear filters` only when a tenant or status prefilter is active | clickable row or card opening the latest tenant review | `Open latest review`, `Download review pack` when already available and permitted | none | `Clear filters` when filtered; otherwise an explanatory no-data state is allowed because the page is strictly read-only and intentionally has no create CTA | `N/A` - detail actions remain on reused tenant-scoped review and review-pack resources | `N/A` | yes - explicit artifact access and download events only | No destructive actions. No More menu required unless the implementation cannot keep open/download as the only visible actions. |
### Key Entities *(include if feature involves data)*
- **Customer Review Workspace Entry**: A derived workspace-scoped summary for one entitled tenant that combines the latest published tenant review, high-level findings and accepted-risk summaries, and current review-pack availability without becoming a persisted entity.
- **TenantReview**: The existing tenant-owned review artifact that anchors the latest customer-safe review state, lifecycle, executive summary, and deep-link target.
- **ReviewPack**: The existing tenant-owned downloadable artifact that packages review consumption and already carries redaction-aware access rules.
- **EvidenceSnapshot**: The existing tenant-owned supporting artifact that proves freshness and completeness when the actor explicitly drills deeper than the customer-safe default path.
## Success Criteria *(mandatory)*
### Measurable Outcomes
- **SC-001**: An entitled readonly-capable actor can reach the latest customer-safe review state for an entitled tenant in two steps or fewer from workspace context.
- **SC-002**: In 100% of validated readonly scenarios, the default-visible customer workspace path shows no admin, remediation, regeneration, or raw-diagnostics actions.
- **SC-003**: In 100% of validated unauthorized workspace or tenant access scenarios, the feature does not reveal another tenant's presence, review existence, or artifact availability.
- **SC-004**: For tenants with a published review and an available review pack, entitled users can open the latest review or download the pack on their first attempt without operator assistance.
- **SC-005**: For tenants without a published customer-safe review or current pack, the surface explains the absence truthfully without exposing draft-only or operator-only state.

View File

@ -1,205 +0,0 @@
---
description: "Task list for feature implementation"
---
# Tasks: Customer Review Workspace v1
**Input**: Design documents from `/Users/ahmeddarrazi/Documents/projects/wt-plattform/specs/249-customer-review-workspace/`
**Prerequisites**: `/Users/ahmeddarrazi/Documents/projects/wt-plattform/specs/249-customer-review-workspace/plan.md` (required), `/Users/ahmeddarrazi/Documents/projects/wt-plattform/specs/249-customer-review-workspace/spec.md` (required), `/Users/ahmeddarrazi/Documents/projects/wt-plattform/specs/249-customer-review-workspace/checklists/requirements.md` (required), `/Users/ahmeddarrazi/Documents/projects/wt-plattform/specs/249-customer-review-workspace/research.md`, `/Users/ahmeddarrazi/Documents/projects/wt-plattform/specs/249-customer-review-workspace/data-model.md`, `/Users/ahmeddarrazi/Documents/projects/wt-plattform/specs/249-customer-review-workspace/contracts/customer-review-workspace.openapi.yaml`, `/Users/ahmeddarrazi/Documents/projects/wt-plattform/specs/249-customer-review-workspace/quickstart.md`
**Tests**: Required (Pest) for all runtime behavior changes. Keep proof in the narrow `confidence` lane plus one explicit `browser` smoke slice, using the targeted Sail commands already captured in the feature spec, plan, and quickstart artifacts.
## Test Governance Notes
- Lane assignment: `confidence` plus one explicit `browser` smoke slice are the narrowest sufficient proof for latest-published selection, deny-as-not-found boundaries, capability-gated pack access, and calm customer-safe disclosure.
- Keep new coverage inside `/Users/ahmeddarrazi/Documents/projects/wt-plattform/apps/platform/tests/Feature/Reviews/CustomerReviewWorkspace*.php` plus `/Users/ahmeddarrazi/Documents/projects/wt-plattform/apps/platform/tests/Browser/Reviews/CustomerReviewWorkspaceSmokeTest.php`; do not widen this slice into a new portal or customer-journey test family.
- Reuse existing workspace membership, tenant membership, published review, review-pack, evidence snapshot, finding, and finding-exception fixtures; any helper introduced for row composition or launch-context state must stay explicit and cheap by default.
- If implementation needs a bounded local read helper or a new audit action ID, record the outcome as `document-in-feature` or escalate to `follow-up-spec` in the final close-out task.
---
## Phase 1: Setup (Shared Infrastructure)
**Purpose**: Lock the bounded slice, proof commands, and guardrail expectations before runtime edits begin.
- [x] T001 Review the bounded slice, explicit non-goals, open planning choices, and guardrail outcomes in `/Users/ahmeddarrazi/Documents/projects/wt-plattform/specs/249-customer-review-workspace/spec.md`, `/Users/ahmeddarrazi/Documents/projects/wt-plattform/specs/249-customer-review-workspace/plan.md`, and `/Users/ahmeddarrazi/Documents/projects/wt-plattform/specs/249-customer-review-workspace/checklists/requirements.md`
- [x] T002 [P] Review the latest-published selection contract, absence-state rules, signed pack-download boundary, and audit expectations in `/Users/ahmeddarrazi/Documents/projects/wt-plattform/specs/249-customer-review-workspace/research.md`, `/Users/ahmeddarrazi/Documents/projects/wt-plattform/specs/249-customer-review-workspace/data-model.md`, and `/Users/ahmeddarrazi/Documents/projects/wt-plattform/specs/249-customer-review-workspace/contracts/customer-review-workspace.openapi.yaml`
- [x] T003 [P] Confirm the focused Sail/Pest commands, browser smoke command, and smoke-checklist/substitution note in `/Users/ahmeddarrazi/Documents/projects/wt-plattform/specs/249-customer-review-workspace/quickstart.md` and keep the validation plan unchanged unless touched runtime truth requires an adjacent proof file
---
## Phase 2: Foundational (Blocking Prerequisites)
**Purpose**: Establish the shared page shell, isolation enforcement, and query seam that every user story depends on.
**⚠️ CRITICAL**: No user story work should begin until this phase is complete.
- [x] T004 [P] Add shared authorization coverage for workspace membership, explicit tenant-prefilter targeting, deny-as-not-found 404 boundaries, and capability-first 403 semantics in `/Users/ahmeddarrazi/Documents/projects/wt-plattform/apps/platform/tests/Feature/Reviews/CustomerReviewWorkspaceAuthorizationTest.php`
- [x] T005 Create the native read-only workspace page shell and Blade view in `/Users/ahmeddarrazi/Documents/projects/wt-plattform/apps/platform/app/Filament/Pages/Reviews/CustomerReviewWorkspace.php` and `/Users/ahmeddarrazi/Documents/projects/wt-plattform/apps/platform/resources/views/filament/pages/reviews/customer-review-workspace.blade.php`, keeping it in the same reviews family as `/Users/ahmeddarrazi/Documents/projects/wt-plattform/apps/platform/app/Filament/Pages/Reviews/ReviewRegister.php` and touching explicit panel discovery only if repo verification proves the page is not auto-discovered
- [x] T006 Resolve the row-query seam by reusing or minimally extending `/Users/ahmeddarrazi/Documents/projects/wt-plattform/apps/platform/app/Services/TenantReviews/TenantReviewRegisterService.php` for workspace access and latest-published-per-entitled-tenant reads; only if that seam cannot safely express the query add a bounded helper beside `/Users/ahmeddarrazi/Documents/projects/wt-plattform/apps/platform/app/Filament/Pages/Reviews/CustomerReviewWorkspace.php` and record the choice in `/Users/ahmeddarrazi/Documents/projects/wt-plattform/specs/249-customer-review-workspace/plan.md`
- [x] T007 [P] Thread Livewire-safe tenant prefilter, highlight, and clear-filter state through `/Users/ahmeddarrazi/Documents/projects/wt-plattform/apps/platform/app/Filament/Pages/Reviews/CustomerReviewWorkspace.php`, reusing the current workspace-page state patterns from `/Users/ahmeddarrazi/Documents/projects/wt-plattform/apps/platform/app/Filament/Pages/Reviews/ReviewRegister.php` and `/Users/ahmeddarrazi/Documents/projects/wt-plattform/apps/platform/app/Filament/Pages/Monitoring/EvidenceOverview.php`
**Checkpoint**: Foundation ready. The customer-safe page shell, 404/403 boundaries, and query-seam decision are in place.
---
## Phase 3: User Story 1 - Open The Latest Customer-Safe Review (Priority: P1) 🎯 MVP
**Goal**: Let a readonly-capable tenant actor open one workspace page that shows the latest published customer-safe review for each entitled tenant without surfacing internal-only review states.
**Independent Test**: Sign in as a readonly-capable tenant actor, open `/admin/reviews/workspace`, and confirm each visible tenant shows only its latest published review summary while tenants without a published review show a truthful absence state.
### Tests for User Story 1
- [x] T008 [P] [US1] Add workspace page feature coverage for latest published review selection, tenant launch-context highlighting, and truthful no-published-review absence handling in `/Users/ahmeddarrazi/Documents/projects/wt-plattform/apps/platform/tests/Feature/Reviews/CustomerReviewWorkspacePageTest.php`
### Implementation for User Story 1
- [x] T009 [US1] Compose one derived workspace entry per entitled tenant from existing `TenantReview`, `currentExportReviewPack`, and `evidenceSnapshot` truth in `/Users/ahmeddarrazi/Documents/projects/wt-plattform/apps/platform/app/Filament/Pages/Reviews/CustomerReviewWorkspace.php` and `/Users/ahmeddarrazi/Documents/projects/wt-plattform/apps/platform/app/Services/TenantReviews/TenantReviewRegisterService.php` or the bounded helper chosen in T006
- [x] T010 [US1] Add or reuse safe customer-workspace launch links from `/Users/ahmeddarrazi/Documents/projects/wt-plattform/apps/platform/app/Filament/Pages/Reviews/ReviewRegister.php`, `/Users/ahmeddarrazi/Documents/projects/wt-plattform/apps/platform/app/Filament/Pages/Monitoring/EvidenceOverview.php`, `/Users/ahmeddarrazi/Documents/projects/wt-plattform/apps/platform/app/Filament/Resources/TenantReviewResource.php`, `/Users/ahmeddarrazi/Documents/projects/wt-plattform/apps/platform/app/Filament/Resources/ReviewPackResource.php`, `/Users/ahmeddarrazi/Documents/projects/wt-plattform/apps/platform/app/Filament/Resources/EvidenceSnapshotResource.php`, and the nearest tenant dashboard review entry surface under `/Users/ahmeddarrazi/Documents/projects/wt-plattform/apps/platform/app/Filament/Widgets/Tenant/` so tenant context arrives as a safe prefilter without creating a second summary shell
- [x] T011 [US1] Render the calm row summary and route the dominant `Open latest review` affordance through the existing tenant review detail path in `/Users/ahmeddarrazi/Documents/projects/wt-plattform/apps/platform/app/Filament/Pages/Reviews/CustomerReviewWorkspace.php`, `/Users/ahmeddarrazi/Documents/projects/wt-plattform/apps/platform/resources/views/filament/pages/reviews/customer-review-workspace.blade.php`, and `/Users/ahmeddarrazi/Documents/projects/wt-plattform/apps/platform/app/Filament/Resources/TenantReviewResource.php`
- [x] T012 [US1] Keep tenants without a published review visible only as truthful absence states and never as draft, ready, failed, or internal-only fallbacks in `/Users/ahmeddarrazi/Documents/projects/wt-plattform/apps/platform/app/Filament/Pages/Reviews/CustomerReviewWorkspace.php` and `/Users/ahmeddarrazi/Documents/projects/wt-plattform/apps/platform/resources/views/filament/pages/reviews/customer-review-workspace.blade.php`
**Checkpoint**: User Story 1 is independently functional when the workspace page truthfully selects the latest published review and handles no-published-review tenants safely.
---
## Phase 4: User Story 2 - Understand Findings And Accepted Risks Without Admin Controls (Priority: P1)
**Goal**: Let a readonly-capable tenant actor understand key findings and accepted risks from the latest review in calm language without seeing remediation, publishing, or debug controls.
**Independent Test**: Open the workspace page and the linked latest review detail for a tenant with findings and accepted risks, then confirm the actor can understand the review outcome without seeing admin or remediation actions.
### Tests for User Story 2
- [x] T013 [P] [US2] Extend workspace page feature coverage for key-finding and accepted-risk summaries, hidden raw/support detail by default, and absent admin or remediation controls on the workspace page in `/Users/ahmeddarrazi/Documents/projects/wt-plattform/apps/platform/tests/Feature/Reviews/CustomerReviewWorkspacePageTest.php`
- [x] T014 [P] [US2] Add browser smoke coverage for calm default-visible content, one dominant `Open latest review` action, safe secondary actions, and absent admin or remediation controls in `/Users/ahmeddarrazi/Documents/projects/wt-plattform/apps/platform/tests/Browser/Reviews/CustomerReviewWorkspaceSmokeTest.php`
- [x] T015 [P] [US2] Extend the smallest existing tenant-review detail readonly or action-surface test under `/Users/ahmeddarrazi/Documents/projects/wt-plattform/apps/platform/tests/Feature/Reviews/` after repo verification so the workspace launch path proves detail inspection stays read-only for readonly-capable actors
### Implementation for User Story 2
- [x] T016 [US2] Render key-finding and accepted-risk summaries by reusing review summary and section payloads from `/Users/ahmeddarrazi/Documents/projects/wt-plattform/apps/platform/app/Services/TenantReviews/TenantReviewService.php` together with `/Users/ahmeddarrazi/Documents/projects/wt-plattform/apps/platform/app/Support/Ui/GovernanceArtifactTruth/ArtifactTruthPresenter.php`, `/Users/ahmeddarrazi/Documents/projects/wt-plattform/apps/platform/app/Support/RedactionIntegrity.php`, `/Users/ahmeddarrazi/Documents/projects/wt-plattform/apps/platform/app/Filament/Pages/Reviews/CustomerReviewWorkspace.php`, and `/Users/ahmeddarrazi/Documents/projects/wt-plattform/apps/platform/resources/views/filament/pages/reviews/customer-review-workspace.blade.php`, extending shared helpers only if repo verification shows a missing customer-safe summary field
- [x] T017 [US2] Keep default-visible content limited to customer-safe outcome, findings, accepted risks, freshness context, and explicit secondary proof links while excluding raw JSON, unrestricted audit metadata, and diagnostics from `/Users/ahmeddarrazi/Documents/projects/wt-plattform/apps/platform/resources/views/filament/pages/reviews/customer-review-workspace.blade.php`
- [x] T018 [US2] If the workspace-to-detail handoff exposes any admin, remediation, publish, regenerate, expire, triage, or exception-edit controls to readonly-capable actors, tighten the smallest existing tenant-review detail surface in `/Users/ahmeddarrazi/Documents/projects/wt-plattform/apps/platform/app/Filament/Resources/TenantReviewResource.php` or its matching page class after repo verification instead of adding a second customer-detail shell
**Checkpoint**: User Story 2 is independently functional when summaries stay calm, raw detail stays secondary, and readonly actors never see admin or remediation controls in the customer-safe flow.
---
## Phase 5: User Story 3 - Consume The Current Review Pack Safely (Priority: P2)
**Goal**: Let a readonly-capable tenant actor open or download the current review pack when it already exists, while keeping unavailable states calm and preserving signed-download safety.
**Independent Test**: From the workspace page, use the pack action for a tenant with a current pack and for one without a current pack, then confirm only the existing safe download path is exposed and no generation or regeneration flow appears.
### Tests for User Story 3
- [x] T019 [P] [US3] Add review-pack access feature coverage for visible download action only with `REVIEW_PACK_VIEW`, calm unavailable state when no current pack exists, preserved signed download behavior, and truthful audit reuse or additive action-ID wiring in `/Users/ahmeddarrazi/Documents/projects/wt-plattform/apps/platform/tests/Feature/Reviews/CustomerReviewWorkspacePackAccessTest.php`
- [x] T020 [P] [US3] If workspace implementation touches pack-download plumbing, extend `/Users/ahmeddarrazi/Documents/projects/wt-plattform/apps/platform/tests/Feature/ReviewPack/ReviewPackDownloadTest.php` to prove no generate or regenerate path was introduced; otherwise leave pack-download regression coverage unchanged and record that outcome in the final close-out task
### Implementation for User Story 3
- [x] T021 [US3] Surface current review-pack availability and the one safe inline `Download review pack` shortcut from the existing current-export relation and signed route semantics in `/Users/ahmeddarrazi/Documents/projects/wt-plattform/apps/platform/app/Filament/Pages/Reviews/CustomerReviewWorkspace.php`, `/Users/ahmeddarrazi/Documents/projects/wt-plattform/apps/platform/resources/views/filament/pages/reviews/customer-review-workspace.blade.php`, and `/Users/ahmeddarrazi/Documents/projects/wt-plattform/apps/platform/app/Services/ReviewPackService.php`
- [x] T022 [US3] Keep review-pack and evidence secondary actions capability-gated through existing `REVIEW_PACK_VIEW` and `EVIDENCE_VIEW` checks plus the current resource route helpers in `/Users/ahmeddarrazi/Documents/projects/wt-plattform/apps/platform/app/Filament/Pages/Reviews/CustomerReviewWorkspace.php`, `/Users/ahmeddarrazi/Documents/projects/wt-plattform/apps/platform/resources/views/filament/pages/reviews/customer-review-workspace.blade.php`, `/Users/ahmeddarrazi/Documents/projects/wt-plattform/apps/platform/app/Filament/Resources/ReviewPackResource.php`, and `/Users/ahmeddarrazi/Documents/projects/wt-plattform/apps/platform/app/Filament/Resources/EvidenceSnapshotResource.php`
- [x] T023 [US3] Reuse the existing audit pipeline for explicit artifact open or download events surfaced by the workspace page, adding a stable `AuditActionId` and `WorkspaceAuditLogger` wiring only if repo verification shows the current review or pack path does not already emit a truthful event in `/Users/ahmeddarrazi/Documents/projects/wt-plattform/apps/platform/app/Support/Audit/AuditActionId.php`, `/Users/ahmeddarrazi/Documents/projects/wt-plattform/apps/platform/app/Services/Audit/WorkspaceAuditLogger.php`, and the smallest calling surface selected during implementation
**Checkpoint**: User Story 3 is independently functional when pack visibility and download stay capability-gated, unavailable states stay calm, and audit reuse remains bounded.
---
## Phase 6: Polish & Cross-Cutting Concerns
**Purpose**: Run the focused validation suite, capture executed smoke evidence, format touched files, and record the feature-local close-out without widening scope.
- [x] T024 Run the targeted workspace-page Sail/Pest command from `/Users/ahmeddarrazi/Documents/projects/wt-plattform/specs/249-customer-review-workspace/plan.md` and `/Users/ahmeddarrazi/Documents/projects/wt-plattform/specs/249-customer-review-workspace/quickstart.md` against `/Users/ahmeddarrazi/Documents/projects/wt-plattform/apps/platform/tests/Feature/Reviews/CustomerReviewWorkspacePageTest.php`
- [x] T025 Run the targeted authorization Sail/Pest command from `/Users/ahmeddarrazi/Documents/projects/wt-plattform/specs/249-customer-review-workspace/plan.md` and `/Users/ahmeddarrazi/Documents/projects/wt-plattform/specs/249-customer-review-workspace/quickstart.md` against `/Users/ahmeddarrazi/Documents/projects/wt-plattform/apps/platform/tests/Feature/Reviews/CustomerReviewWorkspaceAuthorizationTest.php`
- [x] T026 Run the targeted pack-access Sail/Pest command from `/Users/ahmeddarrazi/Documents/projects/wt-plattform/specs/249-customer-review-workspace/plan.md` and `/Users/ahmeddarrazi/Documents/projects/wt-plattform/specs/249-customer-review-workspace/quickstart.md` against `/Users/ahmeddarrazi/Documents/projects/wt-plattform/apps/platform/tests/Feature/Reviews/CustomerReviewWorkspacePackAccessTest.php` and `/Users/ahmeddarrazi/Documents/projects/wt-plattform/apps/platform/tests/Feature/ReviewPack/ReviewPackDownloadTest.php` if T020 touched that file
- [x] T027 Run the explicit browser smoke command from `/Users/ahmeddarrazi/Documents/projects/wt-plattform/specs/249-customer-review-workspace/plan.md` and `/Users/ahmeddarrazi/Documents/projects/wt-plattform/specs/249-customer-review-workspace/quickstart.md` against `/Users/ahmeddarrazi/Documents/projects/wt-plattform/apps/platform/tests/Browser/Reviews/CustomerReviewWorkspaceSmokeTest.php`
- [x] T028 Satisfy the smoke-evidence checklist in `/Users/ahmeddarrazi/Documents/projects/wt-plattform/specs/249-customer-review-workspace/quickstart.md` through either a human manual run or an explicitly documented bounded browser-smoke substitution for readonly workspace entry, tenant-prefilter launch, read-only review detail open, pack available or unavailable behavior, and out-of-scope tenant targeting
- [x] T029 Run dirty-only Pint through Sail for touched platform files using the command recorded in `/Users/ahmeddarrazi/Documents/projects/wt-plattform/specs/249-customer-review-workspace/quickstart.md`
- [x] T030 Record the final `Guardrail / Exception / Smoke Coverage` close-out, lane results, executed smoke-evidence outcome, review outcome class (`acceptable-special-case` unless implementation proves otherwise), workflow outcome (`keep` unless implementation proves otherwise), and any bounded `document-in-feature` note for the `TenantReviewRegisterService` versus local-helper choice or audit-action wiring in `/Users/ahmeddarrazi/Documents/projects/wt-plattform/specs/249-customer-review-workspace/plan.md`, `/Users/ahmeddarrazi/Documents/projects/wt-plattform/specs/249-customer-review-workspace/quickstart.md`, and `/Users/ahmeddarrazi/Documents/projects/wt-plattform/specs/249-customer-review-workspace/checklists/requirements.md`
## Close-out Notes
- T006 reused `TenantReviewRegisterService` for workspace entitlement and latest-published-per-tenant reads; no page-local helper was introduced.
- T010 landed direct customer-workspace launch links on tenant review detail, review-pack detail, evidence related context, and the tenant review-pack widget. `ReviewRegister` and `EvidenceOverview` satisfied the task through existing row/detail navigation reuse rather than new duplicate launch buttons.
- T018 was closed by making the tenant-review detail route enter a customer-safe read-only mode when launched from the workspace path, leaving the normal operator detail route behavior unchanged.
- T020 touched pack-download plumbing. `ReviewPackDownloadTest.php` and `ReviewPackRbacTest.php` were updated and passed after capability enforcement and audit logging were added to the signed download route.
- T023 reused the existing audit store and `WorkspaceAuditLogger` with additive `tenant_review.opened` and `review_pack.downloaded` action IDs; no new audit store or parallel audit pipeline was introduced.
- T028 used the bounded Pest browser smoke plus the focused feature lane as the executed smoke evidence. No separate human integrated-browser manual smoke run was performed.
---
## Dependencies & Execution Order
### Phase Dependencies
- **Phase 1 (Setup)**: starts immediately.
- **Phase 2 (Foundational)**: depends on Phase 1 and blocks all user stories until the page shell, auth boundaries, and query-seam choice are in place.
- **Phase 3 (US1)**: depends on Phase 2 and establishes the MVP customer-safe workspace path.
- **Phase 4 (US2)**: depends on Phase 2 and is safest after US1 because both stories extend the same page and view surfaces.
- **Phase 5 (US3)**: depends on Phase 2 and is safest after US1 because pack actions and audit reuse build on the same workspace rows.
- **Phase 6 (Polish)**: depends on every implemented story.
### User Story Dependencies
- **US1 (P1)**: first independently shippable increment once Phase 2 is complete.
- **US2 (P1)**: independently testable after Phase 2, but should merge after US1 because the same page and view files are shared hotspots.
- **US3 (P2)**: independently testable after Phase 2, but should merge after US1 because pack actions depend on the same workspace row composition.
### Within Each User Story
- Write the listed feature and browser coverage first and make it fail for the intended gap before implementation.
- Resolve shared service or route-helper decisions before widening the page view for that story.
- Re-run the narrowest relevant proof command after each story checkpoint before moving to the next story.
---
## Parallel Opportunities
### Phase 1
- T002 and T003 can run in parallel after T001 confirms the bounded slice.
### Phase 2
- T004 and T005 can run in parallel.
- After T005 establishes the page shell, T006 and T007 can proceed in parallel because the query seam and page-state plumbing touch different primary files.
### User Story 1
- T008 can run before implementation while T009 and T010 are split across service and entry-link work.
- T011 should follow T009 and T010 because the absence state depends on the final row composition.
### User Story 2
- T013, T014, and T015 can run in parallel.
- After the tests exist, T016 and T017 can overlap before T018 checks whether the reused detail surface needs a bounded hardening pass.
### User Story 3
- T019 and T020 can run in parallel.
- After pack-access tests are in place, T021 and T022 can overlap before T023 finalizes audit reuse or additive wiring.
---
## Implementation Strategy
### Suggested MVP Scope
- MVP = **Phase 2 + User Story 1** only. That delivers the canonical read-only workspace page, the latest-published selection rule, tenant-prefilter entry, and truthful no-published-review handling without widening into summary hardening or pack-specific follow-up.
### Incremental Delivery
1. Complete Phase 1 and Phase 2.
2. Deliver US1 and validate the customer-safe workspace path.
3. Deliver US2 and validate findings, accepted-risk summaries, and absence of admin controls.
4. Deliver US3 and validate pack visibility, download safety, and audit reuse.
5. Finish with Phase 6 validation, executed smoke evidence, formatting, and close-out recording.
### Team Strategy
1. Finish Phase 2 together before splitting story work.
2. Parallelize test authoring inside each story before converging on the shared page and view files.
3. Sequence merges touching `/Users/ahmeddarrazi/Documents/projects/wt-plattform/apps/platform/app/Filament/Pages/Reviews/CustomerReviewWorkspace.php` and `/Users/ahmeddarrazi/Documents/projects/wt-plattform/apps/platform/resources/views/filament/pages/reviews/customer-review-workspace.blade.php` story-by-story because they are the main conflict hotspots for this slice.

View File

@ -1,70 +0,0 @@
# Preparation Review Checklist: Decision-Based Governance Inbox v1
**Purpose**: Validate the governance inbox preparation package against the repo's guardrail, disclosure, shared-family, and close-out workflow before implementation
**Created**: 2026-04-28
**Feature**: [spec.md](../spec.md)
## Applicability And Low-Impact Gate
- [x] CHK001 The package explicitly treats this as an operator-facing workspace decision surface, so the low-impact `N/A` path is not used.
- [x] CHK002 The spec, plan, and tasks carry the same native/shared-primitives-first classification, shared-family relevance, state ownership, and close-out targeting without inventing second wording.
## Native, Shared-Family, And State Ownership
- [x] CHK003 The inbox remains a native Filament page that reuses existing source surfaces instead of introducing a fake-native task console or separate monitoring shell.
- [x] CHK004 Shared families remain shared: findings, operations, alerts, and review follow-up stay on their existing source pages, while the new page stays a routing and decision layer.
- [x] CHK005 Page and URL-query state owners are named once, and the package does not collapse them into new persisted workflow state.
- [x] CHK006 The likely next operator action and primary inspect/open model stay coherent: each section has one dominant source CTA and the page owns no mutation lane.
## Shared Pattern Reuse
- [x] CHK007 Cross-cutting interaction classes are explicit, and the shared reuse path is named once through `CanonicalNavigationContext`, `RelatedNavigationResolver`, `OperateHubShell`, `OperationRunLinks`, `BadgeRenderer`, `UiEnforcement`, and the existing source pages.
- [x] CHK008 The package extends existing shared paths where they are sufficient, and any fallback to a bounded `Support/GovernanceInbox/` seam is explicitly constrained as a last resort rather than a new default abstraction.
- [x] CHK009 The package does not create a parallel operator UX language for claim, acknowledge, stale-run handling, or review follow-up; it routes into the current source-family vocabulary.
## OperationRun Start UX Contract
- [x] CHK019 The package explicitly states that the inbox only deep-links into existing `OperationRun` detail and does not start, queue, or complete runs.
- [x] CHK020 Canonical operation URLs are delegated to the shared `OperationRunLinks` path rather than recomposed locally on the inbox page.
- [x] CHK021 No queued DB-notification or terminal-notification behavior is added because the slice is read-only.
- [x] CHK022 No OperationRun exception is required; if implementation later adds local run-start or blocked-run messaging, that would be out-of-scope drift.
## Provider Boundary And Vocabulary
- [x] CHK010 The package keeps provider-specific semantics behind existing normalized governance, alerting, and review seams and does not spread provider language into a new platform-core contract.
- [x] CHK011 No retained provider-specific shared boundary is introduced; the slice stays inside existing workspace, tenant, operations, findings, alerts, and review vocabulary.
## Signals, Exceptions, And Test Depth
- [x] CHK012 The triggered repository signal is explicitly handled as `review-mandatory`, with no hidden hard-stop drift accepted into the package.
- [x] CHK013 No bounded exception is required in the preparation package; if implementation proves a bounded assembly helper is necessary, it must be recorded in the active feature close-out entry.
- [x] CHK014 The required surface test profile is explicit: `global-context-shell`.
- [x] CHK015 The chosen lane mix is the narrowest honest proof for this slice: focused `Unit` plus `Feature` coverage only.
## Audience-Aware Disclosure And Decision Hierarchy
- [x] CHK023 Default-visible content stays decision-first and clearly separated from deeper diagnostics and support or raw evidence.
- [x] CHK024 The inbox default path does not expose raw JSON, copied payloads, provider diagnostics, or other debug semantics by default.
- [x] CHK025 Exactly one dominant next action remains primary per section or entry: open the relevant existing source surface.
- [x] CHK026 Duplicate visible blocker, status, or next-action summaries are avoided by keeping proof and detailed reasoning on the source pages.
- [x] CHK027 Support/raw sections remain off the inbox page entirely, and the page stays within Filament visual language, progressive disclosure, and calm read-only presentation.
## Review Outcome
- [x] CHK016 Review outcome class: `acceptable-special-case`
- [x] CHK017 Workflow outcome: `keep`
- [x] CHK018 The final note location is explicit: the active feature PR close-out entry `Guardrail / Exception / Smoke Coverage` records any bounded assembly-seam exception and the final proof outcome.
## Notes
- This checklist validates the preparation package only: `spec.md`, `plan.md`, `tasks.md`, and supporting design artifacts. It does not claim application code exists.
- The slice remains bounded to one read-only workspace decision surface in the current admin plane. No new task engine, no new attention state, and no local mutation lane are approved by this package.
- If implementation later proves that a bounded `Support/GovernanceInbox/` seam is necessary, that must stay derived and page-scoped rather than becoming a generalized workflow framework.
## Guardrail / Exception / Smoke Coverage
- Implementation status: complete for the bounded v1 slice.
- Guardrail result: PASS. The implemented page stayed native, read-only, shared-primitives-first, and inside the existing admin plane without adding a new task engine, persisted inbox truth, or local mutation lane.
- Bounded exception result: `document-in-feature`. `apps/platform/app/Support/GovernanceInbox/GovernanceInboxSectionBuilder.php` was added as the smallest readable cross-family assembly seam.
- Validation result: the focused unit and feature proof command passed with `10 passed (53 assertions)`, and dirty-only Pint passed.
- Smoke result: PASS. A manual integrated-browser run on `/admin/governance/inbox` verified route load, canonical operations drill-through with `nav` context, and successful return to the inbox.

View File

@ -1,159 +0,0 @@
openapi: 3.1.0
info:
title: Decision-Based Governance Inbox v1
version: 0.1.0
summary: Conceptual contract for the canonical governance inbox page.
paths:
/admin/governance/inbox:
get:
summary: Render the governance inbox page
description: >-
Returns the derived governance inbox composition for the current workspace actor.
This is a conceptual page contract used for planning, not a public API commitment.
parameters:
- in: query
name: tenant_id
schema:
type: integer
nullable: true
description: Optional tenant prefilter. Out-of-scope values resolve as not found.
- in: query
name: family
schema:
type: string
enum:
- assigned_findings
- intake_findings
- stale_operations
- alert_delivery_failures
- review_follow_up
description: Optional source-family filter. `stale_operations` is the canonical key for both stale and terminal-follow-up operations attention.
- in: query
name: nav[source_surface]
schema:
type: string
description: Optional shared navigation context source.
responses:
'200':
description: Derived governance inbox payload for page rendering.
content:
application/json:
schema:
type: object
required:
- title
- applied_scope
- sections
properties:
title:
type: string
example: Governance inbox
applied_scope:
type: object
properties:
tenant_id:
type: integer
nullable: true
family:
type: string
nullable: true
workspace_scoped:
type: boolean
sections:
type: array
items:
type: object
required:
- key
- label
- count
- summary
- dominant_action
- entries
properties:
key:
type: string
description: Family key; `stale_operations` covers stale and terminal-follow-up operations attention.
label:
type: string
count:
type: integer
summary:
type: string
empty_state:
type: string
nullable: true
description: Family-specific empty-state copy used when the family is explicitly selected but has no visible entries.
dominant_action:
type: object
required:
- label
- url
properties:
label:
type: string
url:
type: string
entries:
type: array
items:
type: object
required:
- family_key
- source_model
- source_key
- headline
- status_label
- destination_url
properties:
family_key:
type: string
description: Matches the owning section key; `stale_operations` covers stale and terminal-follow-up operations attention.
source_model:
type: string
source_key:
type: string
tenant_id:
type: integer
nullable: true
tenant_label:
type: string
nullable: true
headline:
type: string
subline:
type: string
nullable: true
urgency_rank:
type: integer
status_label:
type: string
destination_url:
type: string
back_label:
type: string
nullable: true
'404':
description: Workspace membership missing or explicit tenant prefilter is outside scope.
content:
application/json:
schema:
type: object
required:
- message
properties:
message:
type: string
example: Not Found
'403':
description: Workspace member is in scope but lacks every qualifying visible-family capability for the inbox.
content:
application/json:
schema:
type: object
required:
- message
properties:
message:
type: string
example: Forbidden

Some files were not shown because too many files have changed in this diff Show More