feat: review output resolve actions v1 (spec 351)
Some checks failed
PR Fast Feedback / fast-feedback (pull_request) Failing after 3m45s
Implemented the first version of review output resolve actions. Included a ReviewOutputResolveActionMapper, commands to seed browser fixtures, updated CustomerReviewWorkspace, EnvironmentReviewResource, UI enforcement, and related views. Also added extensive unit, feature, and browser tests, and updated the design coverage matrix.
@ -0,0 +1,542 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Filament\Resources\EnvironmentReviewResource;
|
||||
use App\Filament\Pages\Reviews\CustomerReviewWorkspace;
|
||||
use App\Models\EnvironmentReview;
|
||||
use App\Models\EnvironmentReviewSection;
|
||||
use App\Models\EvidenceSnapshot;
|
||||
use App\Models\EvidenceSnapshotItem;
|
||||
use App\Models\ManagedEnvironment;
|
||||
use App\Models\ManagedEnvironmentMembership;
|
||||
use App\Models\OperationRun;
|
||||
use App\Models\ReviewPack;
|
||||
use App\Models\StoredReport;
|
||||
use App\Models\User;
|
||||
use App\Models\UserTenantPreference;
|
||||
use App\Models\Workspace;
|
||||
use App\Models\WorkspaceMembership;
|
||||
use App\Services\EnvironmentReviews\EnvironmentReviewLifecycleService;
|
||||
use App\Services\EnvironmentReviews\EnvironmentReviewService;
|
||||
use App\Services\Evidence\EvidenceSnapshotService;
|
||||
use App\Support\EnvironmentReviewCompletenessState;
|
||||
use App\Support\EnvironmentReviewStatus;
|
||||
use App\Support\Evidence\EvidenceCompletenessState;
|
||||
use App\Support\Evidence\EvidenceSnapshotStatus;
|
||||
use App\Support\Governance\Controls\ComplianceEvidenceMappingV1;
|
||||
use App\Support\OperationRunOutcome;
|
||||
use App\Support\OperationRunStatus;
|
||||
use App\Support\OperationRunType;
|
||||
use Illuminate\Console\Command;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Hash;
|
||||
use Illuminate\Support\Facades\Queue;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
class SeedReviewOutputBrowserFixture extends Command
|
||||
{
|
||||
protected $signature = 'tenantpilot:review-output:seed-browser-fixture';
|
||||
|
||||
protected $description = 'Seed a local/testing browser fixture for the Spec 351 ready-review publish scenario.';
|
||||
|
||||
public function handle(): int
|
||||
{
|
||||
if (! app()->environment(['local', 'testing'])) {
|
||||
$this->error('This fixture command is limited to local and testing environments.');
|
||||
|
||||
return self::FAILURE;
|
||||
}
|
||||
|
||||
$fixture = config('tenantpilot.review_output.browser_smoke_fixture');
|
||||
|
||||
if (! is_array($fixture)) {
|
||||
$this->error('The review-output browser smoke fixture is not configured.');
|
||||
|
||||
return self::FAILURE;
|
||||
}
|
||||
|
||||
$workspaceConfig = is_array($fixture['workspace'] ?? null) ? $fixture['workspace'] : [];
|
||||
$userConfig = is_array($fixture['user'] ?? null) ? $fixture['user'] : [];
|
||||
$scenarioConfig = is_array($fixture['ready_draft'] ?? null) ? $fixture['ready_draft'] : [];
|
||||
|
||||
$workspace = Workspace::query()->updateOrCreate(
|
||||
['slug' => (string) ($workspaceConfig['slug'] ?? 'spec-351-review-output-smoke')],
|
||||
['name' => (string) ($workspaceConfig['name'] ?? 'Spec 351 Review Output Smoke')],
|
||||
);
|
||||
|
||||
$password = (string) ($userConfig['password'] ?? 'password');
|
||||
|
||||
$user = User::query()->updateOrCreate(
|
||||
['email' => (string) ($userConfig['email'] ?? 'smoke-requester+351@tenantpilot.local')],
|
||||
[
|
||||
'name' => (string) ($userConfig['name'] ?? 'Spec 351 Requester'),
|
||||
'password' => Hash::make($password),
|
||||
'email_verified_at' => now(),
|
||||
],
|
||||
);
|
||||
|
||||
$environment = ManagedEnvironment::query()->updateOrCreate(
|
||||
['slug' => (string) ($scenarioConfig['managed_environment_slug'] ?? 'spec-351-browser-ready-draft')],
|
||||
[
|
||||
'workspace_id' => (int) $workspace->getKey(),
|
||||
'name' => (string) ($scenarioConfig['managed_environment_name'] ?? 'Spec 351 Browser Ready Draft'),
|
||||
'lifecycle_status' => ManagedEnvironment::STATUS_ACTIVE,
|
||||
'kind' => 'dev',
|
||||
'is_current' => false,
|
||||
'metadata' => ['fixture' => 'spec-351-review-output-browser-smoke'],
|
||||
'rbac_status' => 'ok',
|
||||
'rbac_last_checked_at' => now(),
|
||||
],
|
||||
);
|
||||
|
||||
WorkspaceMembership::query()->updateOrCreate(
|
||||
['workspace_id' => (int) $workspace->getKey(), 'user_id' => (int) $user->getKey()],
|
||||
['role' => 'owner'],
|
||||
);
|
||||
|
||||
ManagedEnvironmentMembership::query()->updateOrCreate(
|
||||
['managed_environment_id' => (int) $environment->getKey(), 'user_id' => (int) $user->getKey()],
|
||||
['role' => 'owner', 'source' => 'manual', 'source_ref' => 'spec-351-review-output-browser-smoke'],
|
||||
);
|
||||
|
||||
if (Schema::hasColumn('users', 'last_workspace_id')) {
|
||||
$user->forceFill(['last_workspace_id' => (int) $workspace->getKey()])->save();
|
||||
}
|
||||
|
||||
if (Schema::hasTable('user_managed_environment_preferences')) {
|
||||
UserTenantPreference::query()->updateOrCreate(
|
||||
['user_id' => (int) $user->getKey(), 'managed_environment_id' => (int) $environment->getKey()],
|
||||
['last_used_at' => now()],
|
||||
);
|
||||
}
|
||||
|
||||
$readyReview = DB::transaction(function () use ($environment, $user, $scenarioConfig): EnvironmentReview {
|
||||
$this->resetFixtureGraph($environment);
|
||||
|
||||
$snapshot = $this->seedReadyEvidenceSnapshot($environment, $user, $scenarioConfig);
|
||||
|
||||
return $this->seedPublishedLoopWithReadySuccessor($environment, $user, $snapshot, $scenarioConfig);
|
||||
});
|
||||
|
||||
$workspaceUrl = CustomerReviewWorkspace::environmentFilterUrl($environment);
|
||||
$detailUrl = EnvironmentReviewResource::environmentScopedUrl('view', ['record' => $readyReview], $environment);
|
||||
$detailRedirect = $this->relativeAdminRedirect($detailUrl);
|
||||
$publishedReviewId = EnvironmentReview::query()
|
||||
->where('superseded_by_review_id', (int) $readyReview->getKey())
|
||||
->latest('id')
|
||||
->value('id');
|
||||
$loginUrl = route('admin.local.smoke-login', [
|
||||
'email' => (string) $user->email,
|
||||
'tenant' => (string) $environment->slug,
|
||||
'workspace' => (string) $workspace->slug,
|
||||
'redirect' => $detailRedirect,
|
||||
], absolute: false);
|
||||
|
||||
$this->table(
|
||||
['Fixture', 'Value'],
|
||||
[
|
||||
['Workspace', (string) $workspace->name],
|
||||
['User email', (string) $user->email],
|
||||
['User password', $password],
|
||||
['ManagedEnvironment', (string) $environment->name],
|
||||
['Workspace URL', $workspaceUrl],
|
||||
['Published review id', $publishedReviewId !== null ? (string) $publishedReviewId : '—'],
|
||||
['Ready review id', (string) $readyReview->getKey()],
|
||||
['Ready review detail URL', $detailUrl],
|
||||
['Fixture login URL', $loginUrl],
|
||||
],
|
||||
);
|
||||
|
||||
$this->info('The fixture seeds a published review plus a linked ready successor so the workspace shows Open draft review and the detail page shows Publish review.');
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
|
||||
private function resetFixtureGraph(ManagedEnvironment $environment): void
|
||||
{
|
||||
$environmentId = (int) $environment->getKey();
|
||||
$reviewIds = EnvironmentReview::query()
|
||||
->where('managed_environment_id', $environmentId)
|
||||
->pluck('id');
|
||||
$snapshotIds = EvidenceSnapshot::query()
|
||||
->where('managed_environment_id', $environmentId)
|
||||
->pluck('id');
|
||||
|
||||
if ($reviewIds->isNotEmpty()) {
|
||||
ReviewPack::query()->whereIn('environment_review_id', $reviewIds)->delete();
|
||||
EnvironmentReviewSection::query()->whereIn('environment_review_id', $reviewIds)->delete();
|
||||
EnvironmentReview::query()->whereIn('id', $reviewIds)->delete();
|
||||
}
|
||||
|
||||
if ($snapshotIds->isNotEmpty()) {
|
||||
EvidenceSnapshotItem::query()->whereIn('evidence_snapshot_id', $snapshotIds)->delete();
|
||||
EvidenceSnapshot::query()->whereIn('id', $snapshotIds)->delete();
|
||||
}
|
||||
|
||||
StoredReport::query()->where('managed_environment_id', $environmentId)->delete();
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $scenarioConfig
|
||||
*/
|
||||
private function seedReadyEvidenceSnapshot(
|
||||
ManagedEnvironment $environment,
|
||||
User $user,
|
||||
array $scenarioConfig,
|
||||
): EvidenceSnapshot {
|
||||
StoredReport::query()->create([
|
||||
'workspace_id' => (int) $environment->workspace_id,
|
||||
'managed_environment_id' => (int) $environment->getKey(),
|
||||
'report_type' => StoredReport::REPORT_TYPE_PERMISSION_POSTURE,
|
||||
'payload' => [
|
||||
'posture_score' => 92,
|
||||
'required_count' => 14,
|
||||
'granted_count' => 14,
|
||||
'checked_at' => now()->subMinutes(5)->toIso8601String(),
|
||||
'permissions' => [
|
||||
['key' => 'DeviceManagementConfiguration.ReadWrite.All', 'status' => 'granted'],
|
||||
],
|
||||
],
|
||||
]);
|
||||
|
||||
StoredReport::query()->create([
|
||||
'workspace_id' => (int) $environment->workspace_id,
|
||||
'managed_environment_id' => (int) $environment->getKey(),
|
||||
'report_type' => StoredReport::REPORT_TYPE_ENTRA_ADMIN_ROLES,
|
||||
'payload' => [
|
||||
'provider_key' => 'microsoft',
|
||||
'domain' => 'entra.admin_roles',
|
||||
'measured_at' => now()->subMinutes(5)->toIso8601String(),
|
||||
'roles' => [
|
||||
[
|
||||
'displayName' => 'Global Administrator',
|
||||
'userPrincipalName' => 'admin@contoso.com',
|
||||
],
|
||||
],
|
||||
],
|
||||
]);
|
||||
|
||||
$operationRun = OperationRun::query()->create([
|
||||
'workspace_id' => (int) $environment->workspace_id,
|
||||
'managed_environment_id' => (int) $environment->getKey(),
|
||||
'user_id' => (int) $user->getKey(),
|
||||
'initiator_name' => (string) ($scenarioConfig['operation_initiator_name'] ?? 'Spec 351 Browser Operator'),
|
||||
'type' => OperationRunType::PolicySync->value,
|
||||
'status' => OperationRunStatus::Completed->value,
|
||||
'outcome' => OperationRunOutcome::Succeeded->value,
|
||||
'run_identity_hash' => hash('sha256', sprintf(
|
||||
'spec-351-review-output-browser-smoke-%s-%s',
|
||||
$environment->getKey(),
|
||||
(string) Str::uuid(),
|
||||
)),
|
||||
'summary_counts' => [],
|
||||
'failure_summary' => [],
|
||||
'context' => ['fixture' => 'spec-351-review-output-browser-smoke'],
|
||||
'started_at' => now()->subMinutes(6),
|
||||
'completed_at' => now()->subMinutes(5),
|
||||
]);
|
||||
|
||||
/** @var EvidenceSnapshotService $service */
|
||||
$service = app(EvidenceSnapshotService::class);
|
||||
$payload = $service->buildSnapshotPayload($environment);
|
||||
|
||||
$snapshot = EvidenceSnapshot::query()->create([
|
||||
'workspace_id' => (int) $environment->workspace_id,
|
||||
'managed_environment_id' => (int) $environment->getKey(),
|
||||
'operation_run_id' => (int) $operationRun->getKey(),
|
||||
'initiated_by_user_id' => (int) $user->getKey(),
|
||||
'status' => EvidenceSnapshotStatus::Active->value,
|
||||
'fingerprint' => $payload['fingerprint'],
|
||||
'completeness_state' => $payload['completeness'],
|
||||
'summary' => $payload['summary'],
|
||||
'generated_at' => now()->subMinutes(4),
|
||||
]);
|
||||
|
||||
foreach ($payload['items'] as $item) {
|
||||
$snapshot->items()->create([
|
||||
'workspace_id' => (int) $environment->workspace_id,
|
||||
'managed_environment_id' => (int) $environment->getKey(),
|
||||
'dimension_key' => $item['dimension_key'],
|
||||
'state' => $item['state'],
|
||||
'required' => $item['required'],
|
||||
'source_kind' => $item['source_kind'],
|
||||
'source_record_type' => $item['source_record_type'],
|
||||
'source_record_id' => $item['source_record_id'],
|
||||
'source_fingerprint' => $item['source_fingerprint'],
|
||||
'measured_at' => $item['measured_at'],
|
||||
'freshness_at' => $item['freshness_at'],
|
||||
'summary_payload' => $item['summary_payload'],
|
||||
'sort_order' => $item['sort_order'],
|
||||
]);
|
||||
}
|
||||
|
||||
return $snapshot->load('items');
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $scenarioConfig
|
||||
*/
|
||||
private function seedPublishedLoopWithReadySuccessor(
|
||||
ManagedEnvironment $environment,
|
||||
User $user,
|
||||
EvidenceSnapshot $snapshot,
|
||||
array $scenarioConfig,
|
||||
): EnvironmentReview {
|
||||
/** @var EnvironmentReviewService $service */
|
||||
$service = app(EnvironmentReviewService::class);
|
||||
/** @var EnvironmentReviewLifecycleService $lifecycle */
|
||||
$lifecycle = app(EnvironmentReviewLifecycleService::class);
|
||||
|
||||
Queue::fake();
|
||||
|
||||
$publishedReview = $service->create($environment, $snapshot, $user)->refresh();
|
||||
|
||||
if ($publishedReview->generated_at === null || ! $publishedReview->sections()->exists()) {
|
||||
$publishedReview = $service->compose($publishedReview);
|
||||
}
|
||||
|
||||
$publishedReview = $this->markReadyReview($publishedReview);
|
||||
$publishedReview->forceFill([
|
||||
'status' => EnvironmentReviewStatus::Ready->value,
|
||||
'published_at' => null,
|
||||
'published_by_user_id' => null,
|
||||
])->save();
|
||||
|
||||
$publishedReview = $lifecycle->publish(
|
||||
$publishedReview->refresh(),
|
||||
$user,
|
||||
(string) ($scenarioConfig['publish_reason'] ?? 'Seed published predecessor for Spec 351 browser verification.'),
|
||||
);
|
||||
|
||||
$this->attachPublishedReviewPack($environment, $user, $publishedReview, $snapshot, $scenarioConfig);
|
||||
|
||||
$successorReview = $lifecycle->createNextReview($publishedReview->fresh(), $user, $snapshot)->refresh();
|
||||
|
||||
if ($successorReview->generated_at === null || ! $successorReview->sections()->exists()) {
|
||||
$successorReview = $service->compose($successorReview);
|
||||
}
|
||||
|
||||
$successorReview = $this->markReadyReview($successorReview);
|
||||
$successorReview->forceFill([
|
||||
'status' => EnvironmentReviewStatus::Ready->value,
|
||||
'published_at' => null,
|
||||
'published_by_user_id' => null,
|
||||
])->save();
|
||||
|
||||
return $successorReview->refresh()->load(['tenant', 'evidenceSnapshot.items', 'sections']);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $scenarioConfig
|
||||
*/
|
||||
private function attachPublishedReviewPack(
|
||||
ManagedEnvironment $environment,
|
||||
User $user,
|
||||
EnvironmentReview $review,
|
||||
EvidenceSnapshot $snapshot,
|
||||
array $scenarioConfig,
|
||||
): void {
|
||||
$filePath = (string) ($scenarioConfig['published_review_pack_path'] ?? 'review-packs/spec351-browser-ready-published.zip');
|
||||
$fileContents = (string) ($scenarioConfig['published_review_pack_contents'] ?? 'PK-spec351-browser-ready-published');
|
||||
|
||||
Storage::disk('exports')->put($filePath, $fileContents);
|
||||
|
||||
$fingerprint = hash('sha256', implode('|', [
|
||||
'spec-351-review-output-browser-smoke-pack',
|
||||
(string) $environment->getKey(),
|
||||
(string) $review->getKey(),
|
||||
$filePath,
|
||||
]));
|
||||
|
||||
$reviewPack = ReviewPack::query()->create([
|
||||
'workspace_id' => (int) $environment->workspace_id,
|
||||
'managed_environment_id' => (int) $environment->getKey(),
|
||||
'initiated_by_user_id' => (int) $user->getKey(),
|
||||
'evidence_snapshot_id' => (int) $snapshot->getKey(),
|
||||
'environment_review_id' => (int) $review->getKey(),
|
||||
'status' => ReviewPack::STATUS_READY,
|
||||
'fingerprint' => $fingerprint,
|
||||
'summary' => [
|
||||
'fixture' => 'spec-351-review-output-browser-smoke',
|
||||
],
|
||||
'options' => [
|
||||
'include_pii' => false,
|
||||
'include_operations' => true,
|
||||
],
|
||||
'file_disk' => 'exports',
|
||||
'file_path' => $filePath,
|
||||
'file_size' => strlen($fileContents),
|
||||
'sha256' => hash('sha256', $fileContents),
|
||||
'generated_at' => now()->subMinutes(3),
|
||||
'expires_at' => now()->addDays(7),
|
||||
]);
|
||||
|
||||
$summary = is_array($review->summary) ? $review->summary : [];
|
||||
$summary['has_ready_export'] = true;
|
||||
|
||||
$review->forceFill([
|
||||
'current_export_review_pack_id' => (int) $reviewPack->getKey(),
|
||||
'summary' => $summary,
|
||||
])->save();
|
||||
}
|
||||
|
||||
private function markReadyReview(EnvironmentReview $review): EnvironmentReview
|
||||
{
|
||||
$review->loadMissing(['sections', 'evidenceSnapshot.items']);
|
||||
|
||||
$disclosure = 'TenantPilot interprets available evidence for review readiness. This is not a certification, legal attestation, or compliance guarantee.';
|
||||
$controlSummary = [
|
||||
'control_key' => 'customer-output',
|
||||
'control_name' => 'Customer output',
|
||||
'domain_key' => 'customer_delivery',
|
||||
'readiness_bucket' => 'evidence_on_record',
|
||||
'readiness_label' => 'Evidence on record',
|
||||
'limitation_flags' => [],
|
||||
'limitation_labels' => [],
|
||||
'customer_summary' => 'Customer output has evidence on record in this released review.',
|
||||
'evidence_basis_summary' => '1 evidence signal references this control.',
|
||||
'accepted_risk_summary' => null,
|
||||
'recommended_next_action' => 'Publish review.',
|
||||
'detail_anchor' => 'control-customer-output',
|
||||
'supporting_finding_ids' => [],
|
||||
'finding_count' => 0,
|
||||
'open_finding_count' => 0,
|
||||
'accepted_risk_count' => 0,
|
||||
];
|
||||
$controlExplanation = [
|
||||
'title' => 'Customer output',
|
||||
'control_key' => 'customer-output',
|
||||
'control_name' => 'Customer output',
|
||||
'readiness_bucket' => 'evidence_on_record',
|
||||
'readiness_label' => 'Evidence on record',
|
||||
'limitation_flags' => [],
|
||||
'limitation_labels' => [],
|
||||
'customer_summary' => 'Customer output has evidence on record in this released review.',
|
||||
'evidence_basis_summary' => '1 evidence signal references this control.',
|
||||
'accepted_risk_summary' => null,
|
||||
'explanation_text' => 'Customer output has evidence on record in this released review.',
|
||||
'evidence_basis_items' => [
|
||||
'1 evidence signal references this control.',
|
||||
],
|
||||
'accepted_risk_context' => null,
|
||||
'recommended_next_action' => 'Publish review.',
|
||||
'proof_access_state' => 'available',
|
||||
'supporting_finding_ids' => [],
|
||||
];
|
||||
|
||||
$snapshot = $review->evidenceSnapshot;
|
||||
|
||||
if ($snapshot instanceof EvidenceSnapshot) {
|
||||
$snapshot->items->each(function (EvidenceSnapshotItem $item): void {
|
||||
$item->forceFill([
|
||||
'state' => EvidenceCompletenessState::Complete->value,
|
||||
])->save();
|
||||
});
|
||||
|
||||
$snapshotSummary = is_array($snapshot->summary) ? $snapshot->summary : [];
|
||||
$snapshotSummary['missing_dimensions'] = 0;
|
||||
$snapshotSummary['stale_dimensions'] = 0;
|
||||
$snapshotSummary['dimensions'] = collect($snapshotSummary['dimensions'] ?? [])
|
||||
->map(static function (mixed $dimension): mixed {
|
||||
if (! is_array($dimension)) {
|
||||
return $dimension;
|
||||
}
|
||||
|
||||
$dimension['state'] = EnvironmentReviewCompletenessState::Complete->value;
|
||||
|
||||
return $dimension;
|
||||
})
|
||||
->values()
|
||||
->all();
|
||||
|
||||
$snapshot->forceFill([
|
||||
'completeness_state' => EvidenceCompletenessState::Complete->value,
|
||||
'summary' => $snapshotSummary,
|
||||
])->save();
|
||||
}
|
||||
|
||||
$review->sections->each(function (EnvironmentReviewSection $section) use ($controlSummary, $controlExplanation, $disclosure): void {
|
||||
$attributes = [
|
||||
'completeness_state' => EnvironmentReviewCompletenessState::Complete->value,
|
||||
];
|
||||
|
||||
if ($section->isControlInterpretation()) {
|
||||
$attributes['summary_payload'] = array_replace_recursive([
|
||||
'version_key' => ComplianceEvidenceMappingV1::VERSION_KEY,
|
||||
'display_label' => 'Compliance evidence mapping v1',
|
||||
'non_certification_disclosure' => $disclosure,
|
||||
'mapped_control_count' => 1,
|
||||
'follow_up_required_count' => 0,
|
||||
'limitation_counts' => [],
|
||||
'limitations' => [],
|
||||
], is_array($section->summary_payload) ? $section->summary_payload : []);
|
||||
$attributes['render_payload'] = array_replace_recursive([
|
||||
'entries' => [$controlExplanation],
|
||||
'disclosure' => $disclosure,
|
||||
'next_actions' => ['Publish review.'],
|
||||
'empty_state' => null,
|
||||
], is_array($section->render_payload) ? $section->render_payload : []);
|
||||
}
|
||||
|
||||
$section->forceFill($attributes)->save();
|
||||
});
|
||||
|
||||
$sectionCount = $review->sections->count();
|
||||
$summary = is_array($review->summary) ? $review->summary : [];
|
||||
$existingControlInterpretation = is_array($summary['control_interpretation'] ?? null)
|
||||
? $summary['control_interpretation']
|
||||
: [];
|
||||
$existingControls = collect($existingControlInterpretation['controls'] ?? [])
|
||||
->filter(static fn (mixed $control): bool => is_array($control))
|
||||
->values();
|
||||
|
||||
$summary['control_interpretation'] = array_replace_recursive([
|
||||
'version_key' => ComplianceEvidenceMappingV1::VERSION_KEY,
|
||||
'display_label' => 'Compliance evidence mapping v1',
|
||||
'non_certification_disclosure' => $disclosure,
|
||||
'mapped_control_count' => 1,
|
||||
'follow_up_required_count' => 0,
|
||||
'limitation_counts' => [],
|
||||
'limitations' => [],
|
||||
'controls' => [$controlSummary],
|
||||
], $existingControlInterpretation);
|
||||
|
||||
$summary['control_interpretation']['controls'] = [
|
||||
array_replace(
|
||||
$controlSummary,
|
||||
is_array($existingControls->first()) ? $existingControls->first() : [],
|
||||
),
|
||||
];
|
||||
$summary['publish_blockers'] = [];
|
||||
$summary['section_count'] = $sectionCount;
|
||||
$summary['section_state_counts'] = [
|
||||
'complete' => $sectionCount,
|
||||
'partial' => 0,
|
||||
'missing' => 0,
|
||||
'stale' => 0,
|
||||
];
|
||||
|
||||
$review->forceFill([
|
||||
'completeness_state' => EnvironmentReviewCompletenessState::Complete->value,
|
||||
'summary' => $summary,
|
||||
])->save();
|
||||
|
||||
return $review->refresh();
|
||||
}
|
||||
|
||||
private function relativeAdminRedirect(string $url): string
|
||||
{
|
||||
$parsed = parse_url($url);
|
||||
|
||||
$path = '/'.ltrim((string) ($parsed['path'] ?? ''), '/');
|
||||
$query = isset($parsed['query']) ? '?'.$parsed['query'] : '';
|
||||
$fragment = isset($parsed['fragment']) ? '#'.$parsed['fragment'] : '';
|
||||
|
||||
return $path.$query.$fragment;
|
||||
}
|
||||
}
|
||||
@ -19,12 +19,15 @@
|
||||
use App\Models\User;
|
||||
use App\Models\Workspace;
|
||||
use App\Services\Audit\WorkspaceAuditLogger;
|
||||
use App\Services\Auth\CapabilityResolver;
|
||||
use App\Services\EnvironmentReviews\EnvironmentReviewAcknowledgementService;
|
||||
use App\Services\EnvironmentReviews\EnvironmentReviewLifecycleService;
|
||||
use App\Services\EnvironmentReviews\EnvironmentReviewRegisterService;
|
||||
use App\Services\ReviewPackService;
|
||||
use App\Support\Audit\AuditActionId;
|
||||
use App\Support\Auth\Capabilities;
|
||||
use App\Support\EnvironmentReviewCompletenessState;
|
||||
use App\Support\EnvironmentReviewStatus;
|
||||
use App\Support\Filament\TablePaginationProfiles;
|
||||
use App\Support\Findings\FindingOutcomeSemantics;
|
||||
use App\Support\Governance\Controls\ComplianceEvidenceMappingV1;
|
||||
@ -38,6 +41,7 @@
|
||||
use App\Support\ReviewPacks\ReviewPackOutputReadiness;
|
||||
use App\Support\ReviewPacks\ReviewPackOutputResolutionGuidance;
|
||||
use App\Support\ReviewPackStatus;
|
||||
use App\Support\Rbac\UiEnforcement;
|
||||
use App\Support\Ui\ActionSurface\ActionSurfaceDeclaration;
|
||||
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceInspectAffordance;
|
||||
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceProfile;
|
||||
@ -234,6 +238,69 @@ public function acknowledgeReviewAction(): Action
|
||||
});
|
||||
}
|
||||
|
||||
public function createNextReviewAction(): Action
|
||||
{
|
||||
return UiEnforcement::forAction(
|
||||
Action::make('create_next_review')
|
||||
->label(__('localization.review.create_next_review'))
|
||||
->icon('heroicon-o-document-duplicate')
|
||||
->color('primary')
|
||||
->record(fn (): ?EnvironmentReview => $this->workspaceLifecycleReview())
|
||||
->hidden(fn (): bool => ! ($this->workspaceLifecycleReview()?->isPublished() ?? false))
|
||||
->requiresConfirmation()
|
||||
->modalHeading(__('localization.review.create_next_review_heading'))
|
||||
->modalDescription(__('localization.review.create_next_review_description'))
|
||||
->modalSubmitActionLabel(__('localization.review.create_next_review_confirm'))
|
||||
->action(function (): void {
|
||||
$user = auth()->user();
|
||||
$review = $this->workspaceLifecycleReview();
|
||||
|
||||
if (! $user instanceof User || ! $review instanceof EnvironmentReview) {
|
||||
Notification::make()
|
||||
->title(__('localization.review.create_next_review_unavailable'))
|
||||
->danger()
|
||||
->send();
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
$nextReview = app(EnvironmentReviewLifecycleService::class)->createNextReview($review, $user);
|
||||
} catch (\Throwable $throwable) {
|
||||
Notification::make()
|
||||
->title(__('localization.review.create_next_review_failed'))
|
||||
->body($throwable->getMessage())
|
||||
->danger()
|
||||
->send();
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$tenant = $nextReview->tenant;
|
||||
|
||||
if (! $tenant instanceof ManagedEnvironment) {
|
||||
Notification::make()
|
||||
->title(__('localization.review.create_next_review_unavailable'))
|
||||
->danger()
|
||||
->send();
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$this->redirect(self::appendQuery(
|
||||
EnvironmentReviewResource::environmentScopedUrl('view', ['record' => $nextReview], $tenant),
|
||||
array_filter([
|
||||
'source_surface' => self::SOURCE_SURFACE,
|
||||
'tenant_filter_id' => $this->currentTenantFilterId(),
|
||||
], static fn (mixed $value): bool => $value !== null && $value !== ''),
|
||||
));
|
||||
}),
|
||||
)
|
||||
->requireCapability(Capabilities::ENVIRONMENT_REVIEW_MANAGE)
|
||||
->preserveVisibility()
|
||||
->apply();
|
||||
}
|
||||
|
||||
public function table(Table $table): Table
|
||||
{
|
||||
return $table
|
||||
@ -369,6 +436,7 @@ public function latestReviewConsumptionPayload(): ?array
|
||||
'currentExportReviewPack.operationRun',
|
||||
'evidenceSnapshot.operationRun',
|
||||
'operationRun',
|
||||
'supersededByReview.sections',
|
||||
]);
|
||||
|
||||
$publishedAt = $review->published_at ?? $review->generated_at ?? $review->created_at;
|
||||
@ -695,6 +763,7 @@ private function reviewReadinessForTenant(
|
||||
actions: $actions,
|
||||
)
|
||||
: $resolutionCase;
|
||||
$presentedResolutionCase = $this->decorateSuccessorResolutionCase($presentedResolutionCase, $review);
|
||||
$primaryAction = is_array($presentedResolutionCase['primary_action'] ?? null) ? $presentedResolutionCase['primary_action'] : null;
|
||||
$secondaryActions = is_array($presentedResolutionCase['secondary_actions'] ?? null) ? $presentedResolutionCase['secondary_actions'] : [];
|
||||
|
||||
@ -721,7 +790,9 @@ private function reviewReadinessForTenant(
|
||||
'secondary_action_url' => $secondaryActions[0]['url'] ?? null,
|
||||
'secondary_actions' => $secondaryActions,
|
||||
'resolution_case' => $presentedResolutionCase,
|
||||
'output_guidance' => $outputGuidance,
|
||||
'output_guidance' => array_replace($outputGuidance, [
|
||||
'action_help' => $this->workspaceActionHelpForResolutionCase($presentedResolutionCase, $outputGuidance),
|
||||
]),
|
||||
];
|
||||
}
|
||||
|
||||
@ -1393,75 +1464,122 @@ private function reviewPackPanelForReview(
|
||||
$snapshot = $review->evidenceSnapshot;
|
||||
$evidenceBasis = $this->evidenceBasisForReview($review, $packageAvailability, $outputReadiness);
|
||||
$sectionSummary = is_array($outputReadiness['section_summary'] ?? null) ? $outputReadiness['section_summary'] : [];
|
||||
$packageExistsState = match (true) {
|
||||
$pack instanceof ReviewPack && $pack->generated_at !== null => 'available',
|
||||
$packageAvailability['state'] === 'preparing' => 'preparing',
|
||||
default => 'unavailable',
|
||||
};
|
||||
$packageExistsLabel = match ($packageExistsState) {
|
||||
'available' => __('localization.review.available'),
|
||||
'preparing' => __('localization.review.preparing'),
|
||||
default => __('localization.review.unavailable'),
|
||||
};
|
||||
$packageExistsColor = match ($packageExistsState) {
|
||||
'available' => 'success',
|
||||
'preparing' => 'warning',
|
||||
default => 'gray',
|
||||
};
|
||||
$customerSharingState = (string) ($outputReadiness['customer_safe_state'] ?? 'requires_review');
|
||||
|
||||
return [
|
||||
'status_label' => $packageAvailability['label'],
|
||||
'status_color' => $this->governancePackageAvailabilityColor($tenant),
|
||||
'description' => $this->reviewPackPanelDescription($packageAvailability, $outputReadiness),
|
||||
'detail_rows' => [
|
||||
'sections' => [
|
||||
[
|
||||
'label' => __('localization.review.last_generated'),
|
||||
'value' => $pack instanceof ReviewPack && $pack->generated_at !== null
|
||||
? $pack->generated_at->format('M j, Y H:i')
|
||||
: __('localization.review.unavailable'),
|
||||
'color' => 'gray',
|
||||
'key' => 'package_exists',
|
||||
'title' => __('localization.review.package_exists'),
|
||||
'label' => $packageExistsLabel,
|
||||
'color' => $packageExistsColor,
|
||||
'description' => $this->reviewPackPackageExistsDescription($packageExistsState),
|
||||
'rows' => [
|
||||
[
|
||||
'label' => __('localization.review.last_generated'),
|
||||
'value' => $pack instanceof ReviewPack && $pack->generated_at !== null
|
||||
? $pack->generated_at->format('M j, Y H:i')
|
||||
: __('localization.review.unavailable'),
|
||||
'color' => 'gray',
|
||||
],
|
||||
[
|
||||
'label' => __('localization.review.evidence_source'),
|
||||
'value' => $snapshot instanceof EvidenceSnapshot && $snapshot->generated_at !== null
|
||||
? $snapshot->generated_at->format('M j, Y H:i')
|
||||
: __('localization.review.unavailable'),
|
||||
'color' => 'gray',
|
||||
],
|
||||
[
|
||||
'label' => __('localization.review.operation_proof'),
|
||||
'value' => $pack instanceof ReviewPack && $pack->operationRun instanceof OperationRun
|
||||
? OperationRunLinks::identifier($pack->operationRun)
|
||||
: __('localization.review.operation_proof_unavailable'),
|
||||
'color' => $pack instanceof ReviewPack && $pack->operationRun instanceof OperationRun ? 'info' : 'gray',
|
||||
],
|
||||
],
|
||||
],
|
||||
[
|
||||
'label' => __('localization.review.evidence_source'),
|
||||
'value' => $snapshot instanceof EvidenceSnapshot && $snapshot->generated_at !== null
|
||||
? $snapshot->generated_at->format('M j, Y H:i')
|
||||
: __('localization.review.unavailable'),
|
||||
'color' => 'gray',
|
||||
],
|
||||
[
|
||||
'label' => __('localization.review.export_availability'),
|
||||
'value' => $downloadUrl !== null
|
||||
'key' => 'internal_export',
|
||||
'title' => __('localization.review.internal_export'),
|
||||
'label' => $downloadUrl !== null
|
||||
? __('localization.review.export_ready')
|
||||
: __('localization.review.export_not_ready'),
|
||||
'color' => $downloadUrl !== null ? 'success' : 'gray',
|
||||
'color' => $downloadUrl !== null
|
||||
? 'success'
|
||||
: ($packageAvailability['state'] === 'preparing' ? 'warning' : 'gray'),
|
||||
'description' => $this->reviewPackInternalExportDescription($packageAvailability, $downloadUrl),
|
||||
'rows' => [
|
||||
[
|
||||
'label' => __('localization.review.export_availability'),
|
||||
'value' => $downloadUrl !== null
|
||||
? __('localization.review.export_ready')
|
||||
: __('localization.review.export_not_ready'),
|
||||
'color' => $downloadUrl !== null ? 'success' : 'gray',
|
||||
],
|
||||
[
|
||||
'label' => __('localization.review.evidence_basis_state'),
|
||||
'value' => $evidenceBasis['label'],
|
||||
'color' => $evidenceBasis['color'],
|
||||
],
|
||||
[
|
||||
'label' => __('localization.review.section_completeness'),
|
||||
'value' => $this->sectionCompletenessLabel($sectionSummary),
|
||||
'color' => ((int) ($sectionSummary['required_limited'] ?? 0)) > 0 ? 'warning' : 'success',
|
||||
],
|
||||
],
|
||||
],
|
||||
[
|
||||
'label' => __('localization.review.evidence_basis_state'),
|
||||
'value' => $evidenceBasis['label'],
|
||||
'color' => $evidenceBasis['color'],
|
||||
],
|
||||
[
|
||||
'label' => __('localization.review.section_completeness'),
|
||||
'value' => $this->sectionCompletenessLabel($sectionSummary),
|
||||
'color' => ((int) ($sectionSummary['required_limited'] ?? 0)) > 0 ? 'warning' : 'success',
|
||||
],
|
||||
[
|
||||
'label' => __('localization.review.sharing_boundary'),
|
||||
'value' => $this->workspaceBoundaryLabel((string) ($outputReadiness['customer_safe_state'] ?? 'requires_review')),
|
||||
'color' => $this->workspaceBoundaryColor((string) ($outputReadiness['customer_safe_state'] ?? 'requires_review')),
|
||||
],
|
||||
[
|
||||
'label' => __('localization.review.pii_state'),
|
||||
'value' => (bool) ($outputReadiness['contains_pii'] ?? false)
|
||||
? __('localization.review.contains_pii')
|
||||
: __('localization.review.pii_excluded'),
|
||||
'color' => (bool) ($outputReadiness['contains_pii'] ?? false) ? 'warning' : 'success',
|
||||
],
|
||||
[
|
||||
'label' => __('localization.review.protected_values'),
|
||||
'value' => (bool) ($outputReadiness['protected_values_hidden'] ?? true)
|
||||
? __('localization.review.protected_values_hidden')
|
||||
: __('localization.review.unavailable'),
|
||||
'color' => (bool) ($outputReadiness['protected_values_hidden'] ?? true) ? 'success' : 'warning',
|
||||
],
|
||||
[
|
||||
'label' => __('localization.review.disclosure'),
|
||||
'value' => (bool) ($outputReadiness['disclosure_present'] ?? false)
|
||||
? __('localization.review.disclosure_present')
|
||||
: __('localization.review.unavailable'),
|
||||
'color' => (bool) ($outputReadiness['disclosure_present'] ?? false) ? 'success' : 'warning',
|
||||
],
|
||||
[
|
||||
'label' => __('localization.review.operation_proof'),
|
||||
'value' => $pack instanceof ReviewPack && $pack->operationRun instanceof OperationRun
|
||||
? OperationRunLinks::identifier($pack->operationRun)
|
||||
: __('localization.review.operation_proof_unavailable'),
|
||||
'color' => $pack instanceof ReviewPack && $pack->operationRun instanceof OperationRun ? 'info' : 'gray',
|
||||
'key' => 'customer_sharing',
|
||||
'title' => __('localization.review.customer_sharing'),
|
||||
'label' => $this->workspaceBoundaryLabel($customerSharingState),
|
||||
'color' => $this->workspaceBoundaryColor($customerSharingState),
|
||||
'description' => $this->reviewPackCustomerSharingDescription($outputReadiness),
|
||||
'rows' => [
|
||||
[
|
||||
'label' => __('localization.review.sharing_boundary'),
|
||||
'value' => $this->workspaceBoundaryLabel($customerSharingState),
|
||||
'color' => $this->workspaceBoundaryColor($customerSharingState),
|
||||
],
|
||||
[
|
||||
'label' => __('localization.review.pii_state'),
|
||||
'value' => (bool) ($outputReadiness['contains_pii'] ?? false)
|
||||
? __('localization.review.contains_pii')
|
||||
: __('localization.review.pii_excluded'),
|
||||
'color' => (bool) ($outputReadiness['contains_pii'] ?? false) ? 'warning' : 'success',
|
||||
],
|
||||
[
|
||||
'label' => __('localization.review.protected_values'),
|
||||
'value' => (bool) ($outputReadiness['protected_values_hidden'] ?? true)
|
||||
? __('localization.review.protected_values_hidden')
|
||||
: __('localization.review.unavailable'),
|
||||
'color' => (bool) ($outputReadiness['protected_values_hidden'] ?? true) ? 'success' : 'warning',
|
||||
],
|
||||
[
|
||||
'label' => __('localization.review.disclosure'),
|
||||
'value' => (bool) ($outputReadiness['disclosure_present'] ?? false)
|
||||
? __('localization.review.disclosure_present')
|
||||
: __('localization.review.unavailable'),
|
||||
'color' => (bool) ($outputReadiness['disclosure_present'] ?? false) ? 'success' : 'warning',
|
||||
],
|
||||
],
|
||||
],
|
||||
],
|
||||
'download_url' => $downloadUrl,
|
||||
@ -1677,26 +1795,20 @@ private function workspaceEmptyStateDescription(): string
|
||||
|
||||
private function filteredViewHasNoReleasedReviewsButWorkspaceHasMatches(): bool
|
||||
{
|
||||
$tenantFilterId = $this->currentTenantFilterId();
|
||||
$user = auth()->user();
|
||||
$workspace = $this->workspace();
|
||||
$tenant = $this->filteredTenant();
|
||||
|
||||
if ($tenantFilterId === null || ! $user instanceof User || ! $workspace instanceof Workspace) {
|
||||
if (! $tenant instanceof ManagedEnvironment || ! $user instanceof User || ! $workspace instanceof Workspace) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$selectedTenantHasReleasedReview = EnvironmentReview::query()
|
||||
->forWorkspace((int) $workspace->getKey())
|
||||
->where('managed_environment_id', $tenantFilterId)
|
||||
->published()
|
||||
->exists();
|
||||
|
||||
if ($selectedTenantHasReleasedReview) {
|
||||
if ($this->latestPublishedReview($tenant) instanceof EnvironmentReview) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return app(EnvironmentReviewRegisterService::class)
|
||||
->latestPublishedQuery($user, $workspace)
|
||||
->customerWorkspaceLifecycleReviewQuery($user, $workspace)
|
||||
->exists();
|
||||
}
|
||||
|
||||
@ -1746,7 +1858,7 @@ private function latestReleasedTenant(): ?ManagedEnvironment
|
||||
return null;
|
||||
}
|
||||
|
||||
$query = app(EnvironmentReviewRegisterService::class)->latestPublishedQuery($user, $workspace);
|
||||
$query = app(EnvironmentReviewRegisterService::class)->customerWorkspaceLifecycleReviewQuery($user, $workspace);
|
||||
$tenantFilterId = $this->currentTenantFilterId();
|
||||
|
||||
if ($tenantFilterId !== null) {
|
||||
@ -1767,9 +1879,37 @@ private function latestReleasedTenant(): ?ManagedEnvironment
|
||||
|
||||
private function latestPublishedReview(ManagedEnvironment $tenant): ?EnvironmentReview
|
||||
{
|
||||
$review = $tenant->environmentReviews->first();
|
||||
if ($tenant->relationLoaded('environmentReviews')) {
|
||||
$review = $tenant->environmentReviews->first();
|
||||
|
||||
return $review instanceof EnvironmentReview ? $review : null;
|
||||
return $review instanceof EnvironmentReview ? $review : null;
|
||||
}
|
||||
|
||||
return $tenant->environmentReviews()
|
||||
->with(['tenant', 'evidenceSnapshot', 'currentExportReviewPack', 'supersededByReview'])
|
||||
->where(function (Builder $query): void {
|
||||
$query->published()
|
||||
->orWhere(function (Builder $query): void {
|
||||
$query
|
||||
->where('status', EnvironmentReviewStatus::Superseded->value)
|
||||
->whereNotNull('superseded_by_review_id');
|
||||
});
|
||||
})
|
||||
->orderByDesc('published_at')
|
||||
->orderByDesc('generated_at')
|
||||
->orderByDesc('id')
|
||||
->first();
|
||||
}
|
||||
|
||||
private function workspaceLifecycleReview(): ?EnvironmentReview
|
||||
{
|
||||
$tenant = $this->latestReleasedTenant();
|
||||
|
||||
if (! $tenant instanceof ManagedEnvironment) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return $this->latestPublishedReview($tenant);
|
||||
}
|
||||
|
||||
private function latestReviewUrl(ManagedEnvironment $tenant): ?string
|
||||
@ -1795,6 +1935,54 @@ private function latestReviewUrl(ManagedEnvironment $tenant): ?string
|
||||
return $this->appendQuery(EnvironmentReviewResource::environmentScopedUrl('view', ['record' => $review], $tenant), $query);
|
||||
}
|
||||
|
||||
private function successorReviewUrlForReview(EnvironmentReview $review, ManagedEnvironment $tenant): ?string
|
||||
{
|
||||
if (! is_numeric($review->superseded_by_review_id)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$successorReviewId = (int) $review->superseded_by_review_id;
|
||||
|
||||
if (! EnvironmentReview::query()
|
||||
->whereKey($successorReviewId)
|
||||
->where('workspace_id', (int) $review->workspace_id)
|
||||
->where('managed_environment_id', (int) $review->managed_environment_id)
|
||||
->exists()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return $this->appendQuery(
|
||||
EnvironmentReviewResource::environmentScopedUrl('view', ['record' => $successorReviewId], $tenant),
|
||||
array_filter([
|
||||
'source_surface' => self::SOURCE_SURFACE,
|
||||
'tenant_filter_id' => $this->currentTenantFilterId(),
|
||||
], static fn (mixed $value): bool => $value !== null && $value !== ''),
|
||||
);
|
||||
}
|
||||
|
||||
private function operationUrlForReview(EnvironmentReview $review): ?string
|
||||
{
|
||||
$operationRun = $review->currentExportReviewPack?->operationRun ?? $review->operationRun;
|
||||
|
||||
if (! $operationRun instanceof OperationRun) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return OperationRunLinks::tenantlessView((int) $operationRun->getKey());
|
||||
}
|
||||
|
||||
private function canManageReview(EnvironmentReview $review): bool
|
||||
{
|
||||
$user = auth()->user();
|
||||
$tenant = $review->tenant;
|
||||
|
||||
if (! $user instanceof User || ! $tenant instanceof ManagedEnvironment) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return app(CapabilityResolver::class)->can($user, $tenant, Capabilities::ENVIRONMENT_REVIEW_MANAGE);
|
||||
}
|
||||
|
||||
private function reviewPackDownloadUrl(EnvironmentReview $review, ManagedEnvironment $tenant): ?string
|
||||
{
|
||||
$pack = $review->currentExportReviewPack;
|
||||
@ -2320,10 +2508,31 @@ private function reviewOutputGuidanceForReview(
|
||||
*/
|
||||
private function reviewOutputResolutionCaseForReview(EnvironmentReview $review, array $outputGuidance): array
|
||||
{
|
||||
$tenant = $review->tenant;
|
||||
|
||||
return ReviewPackOutputResolutionAdapter::fromGuidance(
|
||||
review: $review,
|
||||
guidance: $outputGuidance,
|
||||
sourceSurface: self::SOURCE_SURFACE,
|
||||
context: [
|
||||
'urls' => [
|
||||
'review' => $tenant instanceof ManagedEnvironment ? $this->latestReviewUrl($tenant) : null,
|
||||
'evidence' => $tenant instanceof ManagedEnvironment
|
||||
? $this->evidenceSnapshotUrlForReview($review, $tenant)
|
||||
: null,
|
||||
'operation' => $this->operationUrlForReview($review),
|
||||
'download' => $tenant instanceof ManagedEnvironment
|
||||
? $this->reviewPackDownloadUrl($review, $tenant)
|
||||
: null,
|
||||
'successor_review' => $tenant instanceof ManagedEnvironment
|
||||
? $this->successorReviewUrlForReview($review, $tenant)
|
||||
: null,
|
||||
],
|
||||
'execution' => [
|
||||
'can_manage_review' => $this->canManageReview($review),
|
||||
'successor_review_status' => $this->successorReviewStatusForReview($review),
|
||||
],
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
@ -2584,6 +2793,81 @@ private function workspaceReadinessActions(
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $resolutionCase
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
private function decorateSuccessorResolutionCase(array $resolutionCase, EnvironmentReview $review): array
|
||||
{
|
||||
if (data_get($resolutionCase, 'primary_action.key') !== 'open_successor_review') {
|
||||
return $resolutionCase;
|
||||
}
|
||||
|
||||
$successor = $this->successorReviewForReview($review);
|
||||
|
||||
if (! $successor instanceof EnvironmentReview || ! $successor->isMutable()) {
|
||||
return $resolutionCase;
|
||||
}
|
||||
|
||||
$canPublishSuccessor = app(\App\Services\EnvironmentReviews\EnvironmentReviewReadinessGate::class)->canPublish($successor);
|
||||
|
||||
return array_replace($resolutionCase, [
|
||||
'title' => __('localization.review.draft_review_exists'),
|
||||
'reason' => $canPublishSuccessor
|
||||
? __('localization.review.draft_review_exists_ready_reason')
|
||||
: __('localization.review.draft_review_exists_blocked_reason'),
|
||||
'impact' => $canPublishSuccessor
|
||||
? __('localization.review.draft_review_exists_ready_impact')
|
||||
: __('localization.review.draft_review_exists_blocked_impact'),
|
||||
]);
|
||||
}
|
||||
|
||||
private function successorReviewForReview(EnvironmentReview $review): ?EnvironmentReview
|
||||
{
|
||||
if ($review->relationLoaded('supersededByReview')) {
|
||||
return $review->supersededByReview instanceof EnvironmentReview
|
||||
? $review->supersededByReview
|
||||
: null;
|
||||
}
|
||||
|
||||
if (! is_numeric($review->superseded_by_review_id)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return EnvironmentReview::query()
|
||||
->with(['tenant', 'sections', 'evidenceSnapshot', 'currentExportReviewPack'])
|
||||
->whereKey((int) $review->superseded_by_review_id)
|
||||
->where('workspace_id', (int) $review->workspace_id)
|
||||
->where('managed_environment_id', (int) $review->managed_environment_id)
|
||||
->first();
|
||||
}
|
||||
|
||||
private function successorReviewStatusForReview(EnvironmentReview $review): ?string
|
||||
{
|
||||
return $this->successorReviewForReview($review)?->status;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $resolutionCase
|
||||
* @param array<string, mixed> $outputGuidance
|
||||
*/
|
||||
private function workspaceActionHelpForResolutionCase(array $resolutionCase, array $outputGuidance): ?string
|
||||
{
|
||||
$primaryActionKey = is_string(data_get($resolutionCase, 'primary_action.key'))
|
||||
? (string) data_get($resolutionCase, 'primary_action.key')
|
||||
: null;
|
||||
|
||||
return match ($primaryActionKey) {
|
||||
'create_next_review' => __('localization.review.output_action_help_create_next_review'),
|
||||
'refresh_review' => __('localization.review.output_action_help_refresh_review'),
|
||||
'publish_review' => __('localization.review.output_action_help_publish_review'),
|
||||
'open_successor_review' => data_get($resolutionCase, 'title') === __('localization.review.draft_review_exists')
|
||||
? __('localization.review.output_action_help_open_draft_review')
|
||||
: __('localization.review.output_action_help_open_successor_review'),
|
||||
default => is_string($outputGuidance['action_help'] ?? null) ? (string) $outputGuidance['action_help'] : null,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array{state:string,label:string,description:string} $packageAvailability
|
||||
* @param array<string, mixed> $outputReadiness
|
||||
@ -2602,6 +2886,43 @@ private function reviewPackPanelDescription(array $packageAvailability, array $o
|
||||
};
|
||||
}
|
||||
|
||||
private function reviewPackPackageExistsDescription(string $state): string
|
||||
{
|
||||
return match ($state) {
|
||||
'available' => __('localization.review.package_exists_available_description'),
|
||||
'preparing' => __('localization.review.package_exists_preparing_description'),
|
||||
default => __('localization.review.package_exists_unavailable_description'),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array{state:string,label:string,description:string} $packageAvailability
|
||||
*/
|
||||
private function reviewPackInternalExportDescription(array $packageAvailability, ?string $downloadUrl): string
|
||||
{
|
||||
if ($downloadUrl !== null) {
|
||||
return __('localization.review.internal_export_ready_description');
|
||||
}
|
||||
|
||||
return match ($packageAvailability['state']) {
|
||||
'preparing' => __('localization.review.internal_export_preparing_description'),
|
||||
'available' => __('localization.review.internal_export_not_ready_description'),
|
||||
default => __('localization.review.internal_export_unavailable_description'),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $outputReadiness
|
||||
*/
|
||||
private function reviewPackCustomerSharingDescription(array $outputReadiness): string
|
||||
{
|
||||
return match ((string) ($outputReadiness['customer_safe_state'] ?? 'requires_review')) {
|
||||
ReviewPackOutputReadiness::STATE_CUSTOMER_SAFE_READY => __('localization.review.customer_sharing_ready_description'),
|
||||
'internal_only' => __('localization.review.customer_sharing_internal_only_description'),
|
||||
default => __('localization.review.customer_sharing_requires_review_description'),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $sectionSummary
|
||||
*/
|
||||
@ -2983,12 +3304,7 @@ private function currentTenantFilterInterpretationVersion(): ?string
|
||||
return null;
|
||||
}
|
||||
|
||||
return $tenant->environmentReviews()->published()
|
||||
->latest('published_at')
|
||||
->latest('generated_at')
|
||||
->latest('id')
|
||||
->first()
|
||||
?->controlInterpretationVersion();
|
||||
return $this->latestPublishedReview($tenant)?->controlInterpretationVersion();
|
||||
}
|
||||
|
||||
private function acceptedRiskAccountability(ManagedEnvironment $tenant): ?string
|
||||
|
||||
@ -14,6 +14,7 @@
|
||||
use App\Support\ManagedEnvironmentLinks;
|
||||
use App\Support\Workspaces\WorkspaceContext;
|
||||
use Filament\Pages\Page;
|
||||
use Filament\Support\Enums\Width;
|
||||
use Illuminate\Database\Eloquent\Collection;
|
||||
|
||||
class ManagedEnvironmentsLanding extends Page
|
||||
@ -24,6 +25,8 @@ class ManagedEnvironmentsLanding extends Page
|
||||
|
||||
protected static string $layout = 'filament-panels::components.layout.simple';
|
||||
|
||||
protected Width|string|null $maxContentWidth = Width::ScreenLarge;
|
||||
|
||||
protected string $view = 'filament.pages.workspaces.managed-environments-landing';
|
||||
|
||||
public Workspace $workspace;
|
||||
|
||||
@ -16,6 +16,7 @@
|
||||
use App\Models\ManagedEnvironment;
|
||||
use App\Models\ReviewPack;
|
||||
use App\Models\User;
|
||||
use App\Services\Auth\CapabilityResolver;
|
||||
use App\Services\EnvironmentReviews\EnvironmentReviewService;
|
||||
use App\Services\ReviewPackService;
|
||||
use App\Support\Auth\Capabilities;
|
||||
@ -1052,11 +1053,15 @@ public static function outputGuidanceState(EnvironmentReview $record): array
|
||||
{
|
||||
$tenant = $record->tenant;
|
||||
$user = auth()->user();
|
||||
$downloadUrl = static::currentReviewPackDownloadUrlFor($record);
|
||||
$reviewUrl = $tenant instanceof ManagedEnvironment
|
||||
? static::environmentScopedUrl('view', ['record' => $record], $tenant)
|
||||
: null;
|
||||
$evidenceUrl = null;
|
||||
$operationUrl = null;
|
||||
$successorReviewUrl = $tenant instanceof ManagedEnvironment
|
||||
? static::successorReviewUrlFor($record, $tenant)
|
||||
: null;
|
||||
|
||||
if (static::isCustomerWorkspaceMode() && $reviewUrl !== null) {
|
||||
$reviewUrl = static::appendQuery($reviewUrl, [
|
||||
@ -1079,7 +1084,7 @@ public static function outputGuidanceState(EnvironmentReview $record): array
|
||||
}
|
||||
|
||||
$guidance = ReviewPackOutputResolutionGuidance::fromReview($record, [
|
||||
'download' => static::currentReviewPackDownloadUrlFor($record),
|
||||
'download' => $downloadUrl,
|
||||
'review' => $reviewUrl,
|
||||
'evidence' => $evidenceUrl,
|
||||
'operation' => $operationUrl,
|
||||
@ -1090,9 +1095,26 @@ public static function outputGuidanceState(EnvironmentReview $record): array
|
||||
sourceSurface: static::isCustomerWorkspaceMode()
|
||||
? 'environment_review_detail.customer_workspace'
|
||||
: 'environment_review_detail',
|
||||
context: [
|
||||
'urls' => [
|
||||
'review' => $reviewUrl,
|
||||
'evidence' => $evidenceUrl,
|
||||
'operation' => $operationUrl,
|
||||
'download' => $downloadUrl,
|
||||
'successor_review' => $successorReviewUrl,
|
||||
],
|
||||
'execution' => [
|
||||
'can_manage_review' => static::canManageReview($record),
|
||||
'successor_review_status' => static::successorReviewStatusFor($record),
|
||||
],
|
||||
],
|
||||
);
|
||||
|
||||
if (! static::isCustomerWorkspaceMode()) {
|
||||
if (filled(data_get($guidance, 'resolution_case.primary_action.action_name'))) {
|
||||
$guidance['suppress_primary_action_button'] = true;
|
||||
}
|
||||
|
||||
return $guidance;
|
||||
}
|
||||
|
||||
@ -1105,6 +1127,56 @@ public static function outputGuidanceState(EnvironmentReview $record): array
|
||||
return $guidance;
|
||||
}
|
||||
|
||||
private static function successorReviewUrlFor(EnvironmentReview $record, ManagedEnvironment $tenant): ?string
|
||||
{
|
||||
if (! is_numeric($record->superseded_by_review_id)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$successorReviewId = (int) $record->superseded_by_review_id;
|
||||
|
||||
if (! EnvironmentReview::query()
|
||||
->whereKey($successorReviewId)
|
||||
->where('workspace_id', (int) $record->workspace_id)
|
||||
->where('managed_environment_id', (int) $record->managed_environment_id)
|
||||
->exists()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return static::environmentScopedUrl('view', ['record' => $successorReviewId], $tenant);
|
||||
}
|
||||
|
||||
private static function successorReviewStatusFor(EnvironmentReview $record): ?string
|
||||
{
|
||||
if ($record->relationLoaded('supersededByReview')) {
|
||||
return $record->supersededByReview instanceof EnvironmentReview
|
||||
? (string) $record->supersededByReview->status
|
||||
: null;
|
||||
}
|
||||
|
||||
if (! is_numeric($record->superseded_by_review_id)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return EnvironmentReview::query()
|
||||
->whereKey((int) $record->superseded_by_review_id)
|
||||
->where('workspace_id', (int) $record->workspace_id)
|
||||
->where('managed_environment_id', (int) $record->managed_environment_id)
|
||||
->value('status');
|
||||
}
|
||||
|
||||
private static function canManageReview(EnvironmentReview $record): bool
|
||||
{
|
||||
$user = auth()->user();
|
||||
$tenant = $record->tenant;
|
||||
|
||||
if (! $user instanceof User || ! $tenant instanceof ManagedEnvironment) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return app(CapabilityResolver::class)->can($user, $tenant, Capabilities::ENVIRONMENT_REVIEW_MANAGE);
|
||||
}
|
||||
|
||||
public static function currentReviewPackDownloadUrlFor(EnvironmentReview $record): ?string
|
||||
{
|
||||
$pack = $record->currentExportReviewPack;
|
||||
|
||||
@ -11,6 +11,7 @@
|
||||
use App\Models\ReviewPack;
|
||||
use App\Models\User;
|
||||
use App\Services\Audit\WorkspaceAuditLogger;
|
||||
use App\Services\EnvironmentReviews\EnvironmentReviewReadinessGate;
|
||||
use App\Services\EnvironmentReviews\EnvironmentReviewLifecycleService;
|
||||
use App\Services\EnvironmentReviews\EnvironmentReviewService;
|
||||
use App\Services\ReviewPackService;
|
||||
@ -30,6 +31,11 @@ class ViewEnvironmentReview extends ViewRecord
|
||||
{
|
||||
protected static string $resource = EnvironmentReviewResource::class;
|
||||
|
||||
/**
|
||||
* @var array<string, mixed>|null
|
||||
*/
|
||||
private ?array $outputGuidanceStateCache = null;
|
||||
|
||||
public function mount(int|string $record): void
|
||||
{
|
||||
parent::mount($record);
|
||||
@ -97,7 +103,9 @@ private function primaryLifecycleAction(): ?Actions\Action
|
||||
return match ($this->primaryLifecycleActionName()) {
|
||||
'refresh_review' => $this->refreshReviewAction(),
|
||||
'publish_review' => $this->publishReviewAction(),
|
||||
'create_next_review' => $this->createNextReviewAction(),
|
||||
'export_executive_pack' => $this->exportExecutivePackAction(),
|
||||
'open_successor_review' => $this->openSuccessorReviewAction(),
|
||||
default => null,
|
||||
};
|
||||
}
|
||||
@ -108,6 +116,12 @@ private function primaryLifecycleActionName(): ?string
|
||||
return null;
|
||||
}
|
||||
|
||||
$mappedPrimaryActionName = $this->mappedPrimaryLifecycleActionName();
|
||||
|
||||
if ($mappedPrimaryActionName !== null) {
|
||||
return $mappedPrimaryActionName;
|
||||
}
|
||||
|
||||
if ((string) $this->record->status === EnvironmentReviewStatus::Published->value) {
|
||||
return 'export_executive_pack';
|
||||
}
|
||||
@ -134,6 +148,7 @@ private function secondaryLifecycleActions(): array
|
||||
'publish_review' => $this->publishReviewAction(),
|
||||
'export_executive_pack' => $this->exportExecutivePackAction(),
|
||||
'create_next_review' => $this->createNextReviewAction(),
|
||||
'open_successor_review' => $this->openSuccessorReviewAction(),
|
||||
default => null,
|
||||
},
|
||||
$this->secondaryLifecycleActionNames(),
|
||||
@ -201,7 +216,14 @@ private function refreshReviewAction(): Actions\Action
|
||||
return;
|
||||
}
|
||||
|
||||
Notification::make()->success()->title($rule->successTitle)->send();
|
||||
$this->record->refresh();
|
||||
$this->record->loadMissing(['tenant', 'sections', 'evidenceSnapshot', 'currentExportReviewPack', 'operationRun']);
|
||||
$this->outputGuidanceStateCache = null;
|
||||
Notification::make()
|
||||
->success()
|
||||
->title($rule->successTitle)
|
||||
->body($this->refreshReviewFeedbackBody())
|
||||
->send();
|
||||
}),
|
||||
)
|
||||
->requireCapability(Capabilities::ENVIRONMENT_REVIEW_MANAGE)
|
||||
@ -218,6 +240,10 @@ private function publishReviewAction(): Actions\Action
|
||||
->icon('heroicon-o-check-badge')
|
||||
->color('primary')
|
||||
->hidden(fn (): bool => ! $this->record->isMutable())
|
||||
->disabled(fn (): bool => ! $this->canPublishCurrentReview())
|
||||
->tooltip(fn (): ?string => ! $this->canPublishCurrentReview()
|
||||
? __('localization.review.resolve_review_blockers_before_publishing')
|
||||
: null)
|
||||
->requiresConfirmation()
|
||||
->modalHeading($rule->modalHeading)
|
||||
->modalDescription($rule->modalDescription)
|
||||
@ -248,11 +274,13 @@ private function publishReviewAction(): Actions\Action
|
||||
}
|
||||
|
||||
$this->refreshFormData(['status', 'published_at', 'published_by_user_id', 'summary']);
|
||||
$this->outputGuidanceStateCache = null;
|
||||
Notification::make()->success()->title($rule->successTitle)->send();
|
||||
}),
|
||||
)
|
||||
->requireCapability(Capabilities::ENVIRONMENT_REVIEW_MANAGE)
|
||||
->preserveVisibility()
|
||||
->preserveDisabled()
|
||||
->apply();
|
||||
}
|
||||
|
||||
@ -284,9 +312,14 @@ private function createNextReviewAction(): Actions\Action
|
||||
{
|
||||
return UiEnforcement::forAction(
|
||||
Actions\Action::make('create_next_review')
|
||||
->label('Create next review')
|
||||
->label(__('localization.review.create_next_review'))
|
||||
->icon('heroicon-o-document-duplicate')
|
||||
->color('primary')
|
||||
->hidden(fn (): bool => ! $this->record->isPublished())
|
||||
->requiresConfirmation()
|
||||
->modalHeading(__('localization.review.create_next_review_heading'))
|
||||
->modalDescription(__('localization.review.create_next_review_description'))
|
||||
->modalSubmitActionLabel(__('localization.review.create_next_review_confirm'))
|
||||
->action(function (): void {
|
||||
$user = auth()->user();
|
||||
|
||||
@ -302,6 +335,7 @@ private function createNextReviewAction(): Actions\Action
|
||||
return;
|
||||
}
|
||||
|
||||
Notification::make()->success()->title(__('localization.review.create_next_review_success'))->send();
|
||||
$this->redirect(EnvironmentReviewResource::environmentScopedUrl('view', ['record' => $nextReview], $nextReview->tenant));
|
||||
}),
|
||||
)
|
||||
@ -310,6 +344,16 @@ private function createNextReviewAction(): Actions\Action
|
||||
->apply();
|
||||
}
|
||||
|
||||
private function openSuccessorReviewAction(): Actions\Action
|
||||
{
|
||||
return Actions\Action::make('open_successor_review')
|
||||
->label(__('localization.review.open_successor_review'))
|
||||
->icon('heroicon-o-arrow-top-right-on-square')
|
||||
->color('primary')
|
||||
->visible(fn (): bool => $this->successorReviewUrl() !== null)
|
||||
->url(fn (): ?string => $this->successorReviewUrl());
|
||||
}
|
||||
|
||||
private function archiveReviewAction(): Actions\Action
|
||||
{
|
||||
$rule = GovernanceActionCatalog::rule('archive_review');
|
||||
@ -343,6 +387,7 @@ private function archiveReviewAction(): Actions\Action
|
||||
(string) ($data['archive_reason'] ?? ''),
|
||||
);
|
||||
$this->refreshFormData(['status', 'archived_at']);
|
||||
$this->outputGuidanceStateCache = null;
|
||||
|
||||
Notification::make()->success()->title($rule->successTitle)->send();
|
||||
}),
|
||||
@ -402,6 +447,52 @@ private function currentReviewPackUnavailableReason(): ?string
|
||||
return __('localization.review.customer_review_pack_unavailable');
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
private function outputGuidanceState(): array
|
||||
{
|
||||
return $this->outputGuidanceStateCache ??= EnvironmentReviewResource::outputGuidanceState($this->record);
|
||||
}
|
||||
|
||||
private function canPublishCurrentReview(): bool
|
||||
{
|
||||
return app(EnvironmentReviewReadinessGate::class)->canPublish($this->record);
|
||||
}
|
||||
|
||||
private function refreshReviewFeedbackBody(): string
|
||||
{
|
||||
return data_get($this->outputGuidanceState(), 'resolution_case.primary_action.key') === 'publish_review'
|
||||
? __('localization.review.refresh_review_feedback_ready')
|
||||
: __('localization.review.refresh_review_feedback_blocked');
|
||||
}
|
||||
|
||||
private function mappedPrimaryLifecycleActionName(): ?string
|
||||
{
|
||||
$actionName = data_get($this->outputGuidanceState(), 'resolution_case.primary_action.action_name');
|
||||
|
||||
return is_string($actionName) && in_array($actionName, [
|
||||
'refresh_review',
|
||||
'publish_review',
|
||||
'create_next_review',
|
||||
'open_successor_review',
|
||||
], true)
|
||||
? $actionName
|
||||
: null;
|
||||
}
|
||||
|
||||
private function successorReviewUrl(): ?string
|
||||
{
|
||||
$url = data_get($this->outputGuidanceState(), 'resolution_case.primary_action.url');
|
||||
$actionName = data_get($this->outputGuidanceState(), 'resolution_case.primary_action.action_name');
|
||||
|
||||
if ($actionName !== 'open_successor_review' || ! is_string($url) || trim($url) === '') {
|
||||
return null;
|
||||
}
|
||||
|
||||
return trim($url);
|
||||
}
|
||||
|
||||
private function isCustomerWorkspaceView(): bool
|
||||
{
|
||||
return request()->boolean(CustomerReviewWorkspace::DETAIL_CONTEXT_QUERY_KEY);
|
||||
|
||||
@ -12,6 +12,7 @@
|
||||
use App\Services\Auth\CapabilityResolver;
|
||||
use App\Services\Auth\ManagedEnvironmentAccessScopeResolver;
|
||||
use App\Support\Auth\Capabilities;
|
||||
use App\Support\EnvironmentReviewStatus;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
@ -91,6 +92,17 @@ public function latestPublishedQuery(User $user, Workspace $workspace): Builder
|
||||
->orderByDesc('id');
|
||||
}
|
||||
|
||||
public function customerWorkspaceLifecycleReviewQuery(User $user, Workspace $workspace): Builder
|
||||
{
|
||||
return EnvironmentReview::query()
|
||||
->with(['tenant', 'evidenceSnapshot', 'currentExportReviewPack', 'supersededByReview'])
|
||||
->forWorkspace((int) $workspace->getKey())
|
||||
->whereIn('environment_reviews.id', $this->customerWorkspaceLifecycleReviewIdsQuery($user, $workspace))
|
||||
->orderByDesc('published_at')
|
||||
->orderByDesc('generated_at')
|
||||
->orderByDesc('id');
|
||||
}
|
||||
|
||||
public function customerWorkspaceTenantQuery(User $user, Workspace $workspace): Builder
|
||||
{
|
||||
$tenantIds = array_keys($this->authorizedTenants($user, $workspace));
|
||||
@ -98,15 +110,17 @@ public function customerWorkspaceTenantQuery(User $user, Workspace $workspace):
|
||||
return ManagedEnvironment::query()
|
||||
->where('workspace_id', (int) $workspace->getKey())
|
||||
->whereIn('id', $tenantIds === [] ? [-1] : $tenantIds)
|
||||
->whereHas('environmentReviews', fn ($query) => $query->published())
|
||||
->whereHas('environmentReviews', fn ($query) => $query->whereIn(
|
||||
'environment_reviews.id',
|
||||
$this->customerWorkspaceLifecycleReviewIdsQuery($user, $workspace),
|
||||
))
|
||||
->with([
|
||||
'environmentReviews' => fn ($query) => $query
|
||||
->with(['tenant', 'evidenceSnapshot', 'currentExportReviewPack'])
|
||||
->published()
|
||||
->with(['tenant', 'evidenceSnapshot', 'currentExportReviewPack', 'supersededByReview'])
|
||||
->whereIn('environment_reviews.id', $this->customerWorkspaceLifecycleReviewIdsQuery($user, $workspace))
|
||||
->orderByDesc('published_at')
|
||||
->orderByDesc('generated_at')
|
||||
->orderByDesc('id')
|
||||
->limit(1),
|
||||
->orderByDesc('id'),
|
||||
])
|
||||
->orderBy('name');
|
||||
}
|
||||
@ -118,4 +132,33 @@ public function canAccessWorkspace(User $user, Workspace $workspace): bool
|
||||
->where('user_id', (int) $user->getKey())
|
||||
->exists();
|
||||
}
|
||||
|
||||
private function customerWorkspaceLifecycleReviewIdsQuery(User $user, Workspace $workspace): \Illuminate\Database\Query\Builder
|
||||
{
|
||||
$tenantIds = array_keys($this->authorizedTenants($user, $workspace));
|
||||
|
||||
$rankedReviews = EnvironmentReview::query()
|
||||
->select([
|
||||
'environment_reviews.id',
|
||||
'environment_reviews.managed_environment_id',
|
||||
'environment_reviews.published_at',
|
||||
'environment_reviews.generated_at',
|
||||
])
|
||||
->selectRaw('ROW_NUMBER() OVER (PARTITION BY managed_environment_id ORDER BY COALESCE(published_at, generated_at) DESC, generated_at DESC, id DESC) as rn')
|
||||
->forWorkspace((int) $workspace->getKey())
|
||||
->whereIn('managed_environment_id', $tenantIds === [] ? [-1] : $tenantIds)
|
||||
->where(function (Builder $query): void {
|
||||
$query->published()
|
||||
->orWhere(function (Builder $query): void {
|
||||
$query
|
||||
->where('status', EnvironmentReviewStatus::Superseded->value)
|
||||
->whereNotNull('superseded_by_review_id');
|
||||
});
|
||||
});
|
||||
|
||||
return DB::query()
|
||||
->fromSub($rankedReviews, 'ranked_customer_workspace_reviews')
|
||||
->where('rn', 1)
|
||||
->select('id');
|
||||
}
|
||||
}
|
||||
|
||||
@ -7,6 +7,7 @@
|
||||
use App\Models\EnvironmentReview;
|
||||
use App\Models\EvidenceSnapshot;
|
||||
use App\Models\ReviewPack;
|
||||
use App\Support\ResolutionGuidance\ReviewOutputResolveActionMapper;
|
||||
use App\Support\ResolutionGuidance\ResolutionAction;
|
||||
use App\Support\ResolutionGuidance\ResolutionCase;
|
||||
use App\Support\ReviewPacks\ReviewPackOutputResolutionGuidance;
|
||||
@ -30,6 +31,7 @@ final class ReviewPackOutputResolutionAdapter
|
||||
* url:?string,
|
||||
* icon:string,
|
||||
* kind:string,
|
||||
* action_name:?string,
|
||||
* capability:?string,
|
||||
* requires_confirmation:bool,
|
||||
* audit_event:?string,
|
||||
@ -43,6 +45,7 @@ final class ReviewPackOutputResolutionAdapter
|
||||
* url:?string,
|
||||
* icon:string,
|
||||
* kind:string,
|
||||
* action_name:?string,
|
||||
* capability:?string,
|
||||
* requires_confirmation:bool,
|
||||
* audit_event:?string,
|
||||
@ -54,7 +57,12 @@ final class ReviewPackOutputResolutionAdapter
|
||||
* technical_details:array<string, string>
|
||||
* }
|
||||
*/
|
||||
public static function fromGuidance(EnvironmentReview $review, array $guidance, string $sourceSurface): array
|
||||
public static function fromGuidance(
|
||||
EnvironmentReview $review,
|
||||
array $guidance,
|
||||
string $sourceSurface,
|
||||
array $context = [],
|
||||
): array
|
||||
{
|
||||
$scope = array_filter([
|
||||
'type' => 'review_pack',
|
||||
@ -67,12 +75,13 @@ public static function fromGuidance(EnvironmentReview $review, array $guidance,
|
||||
'source_surface' => $sourceSurface,
|
||||
], static fn (mixed $value): bool => $value !== null && $value !== '');
|
||||
|
||||
$primaryAction = ResolutionAction::fromArray(
|
||||
is_array($guidance['primary_action'] ?? null) ? $guidance['primary_action'] : null,
|
||||
self::caseKey((string) ($guidance['state'] ?? ReviewPackOutputResolutionGuidance::STATE_UNKNOWN)).'.primary_action',
|
||||
__('localization.review.review_output_limitations'),
|
||||
$mappedActions = ReviewOutputResolveActionMapper::map(
|
||||
review: $review,
|
||||
guidance: $guidance,
|
||||
sourceSurface: $sourceSurface,
|
||||
urls: is_array($context['urls'] ?? null) ? $context['urls'] : [],
|
||||
execution: is_array($context['execution'] ?? null) ? $context['execution'] : [],
|
||||
);
|
||||
|
||||
return ResolutionCase::make(
|
||||
key: self::caseKey((string) ($guidance['state'] ?? ReviewPackOutputResolutionGuidance::STATE_UNKNOWN)),
|
||||
scope: $scope,
|
||||
@ -81,8 +90,8 @@ public static function fromGuidance(EnvironmentReview $review, array $guidance,
|
||||
title: (string) ($guidance['label'] ?? __('localization.review.requires_review')),
|
||||
reason: (string) ($guidance['primary_reason'] ?? __('localization.review.review_pack_with_limitations_description')),
|
||||
impact: (string) ($guidance['impact'] ?? __('localization.review.published_with_limitations_impact')),
|
||||
primaryAction: $primaryAction,
|
||||
secondaryActions: self::secondaryActions($guidance),
|
||||
primaryAction: $mappedActions['primary_action'],
|
||||
secondaryActions: $mappedActions['secondary_actions'],
|
||||
sourceRefs: self::sourceRefs($review),
|
||||
evidenceRefs: self::evidenceRefs($review),
|
||||
technicalDetails: self::technicalDetails($guidance),
|
||||
@ -98,6 +107,7 @@ public static function fromGuidance(EnvironmentReview $review, array $guidance,
|
||||
* url:?string,
|
||||
* icon:string,
|
||||
* kind:string,
|
||||
* action_name:?string,
|
||||
* capability:?string,
|
||||
* requires_confirmation:bool,
|
||||
* audit_event:?string,
|
||||
|
||||
@ -26,6 +26,7 @@ final class ResolutionAction
|
||||
* url?:mixed,
|
||||
* icon?:mixed,
|
||||
* kind?:mixed,
|
||||
* action_name?:mixed,
|
||||
* capability?:mixed,
|
||||
* requires_confirmation?:mixed,
|
||||
* audit_event?:mixed,
|
||||
@ -39,6 +40,7 @@ final class ResolutionAction
|
||||
* url:?string,
|
||||
* icon:string,
|
||||
* kind:string,
|
||||
* action_name:?string,
|
||||
* capability:?string,
|
||||
* requires_confirmation:bool,
|
||||
* audit_event:?string,
|
||||
@ -64,6 +66,9 @@ public static function fromArray(?array $action, string $fallbackKey, string $fa
|
||||
? trim((string) $action['key'])
|
||||
: $fallbackKey;
|
||||
$type = $rawType ?? self::typeFromKind($rawKind, $url);
|
||||
$actionName = is_string($action['action_name'] ?? null) && trim((string) $action['action_name']) !== ''
|
||||
? trim((string) $action['action_name'])
|
||||
: null;
|
||||
$capability = is_string($action['capability'] ?? null) && trim((string) $action['capability']) !== ''
|
||||
? trim((string) $action['capability'])
|
||||
: null;
|
||||
@ -77,6 +82,7 @@ public static function fromArray(?array $action, string $fallbackKey, string $fa
|
||||
|
||||
if (self::isUnsafeExecutable($type, $capability, $auditEvent, $requiresConfirmation, $operationRunType)) {
|
||||
$type = self::fallbackType($rawKind, $url);
|
||||
$actionName = null;
|
||||
$capability = null;
|
||||
$requiresConfirmation = false;
|
||||
$auditEvent = null;
|
||||
@ -98,6 +104,7 @@ public static function fromArray(?array $action, string $fallbackKey, string $fa
|
||||
'url' => $url,
|
||||
'icon' => $icon,
|
||||
'kind' => $kind,
|
||||
'action_name' => $actionName,
|
||||
'capability' => $capability,
|
||||
'requires_confirmation' => $requiresConfirmation,
|
||||
'audit_event' => $auditEvent,
|
||||
@ -114,6 +121,7 @@ public static function fromArray(?array $action, string $fallbackKey, string $fa
|
||||
* url:null,
|
||||
* icon:string,
|
||||
* kind:string,
|
||||
* action_name:null,
|
||||
* capability:null,
|
||||
* requires_confirmation:false,
|
||||
* audit_event:null,
|
||||
@ -130,6 +138,7 @@ public static function none(string $key, string $label, ?string $disabledReason
|
||||
'url' => null,
|
||||
'icon' => self::iconForType(self::TYPE_NONE),
|
||||
'kind' => 'none',
|
||||
'action_name' => null,
|
||||
'capability' => null,
|
||||
'requires_confirmation' => false,
|
||||
'audit_event' => null,
|
||||
|
||||
@ -15,6 +15,7 @@ final class ResolutionCase
|
||||
* url:?string,
|
||||
* icon:string,
|
||||
* kind:string,
|
||||
* action_name:?string,
|
||||
* capability:?string,
|
||||
* requires_confirmation:bool,
|
||||
* audit_event:?string,
|
||||
@ -28,6 +29,7 @@ final class ResolutionCase
|
||||
* url:?string,
|
||||
* icon:string,
|
||||
* kind:string,
|
||||
* action_name:?string,
|
||||
* capability:?string,
|
||||
* requires_confirmation:bool,
|
||||
* audit_event:?string,
|
||||
@ -52,6 +54,7 @@ final class ResolutionCase
|
||||
* url:?string,
|
||||
* icon:string,
|
||||
* kind:string,
|
||||
* action_name:?string,
|
||||
* capability:?string,
|
||||
* requires_confirmation:bool,
|
||||
* audit_event:?string,
|
||||
@ -65,6 +68,7 @@ final class ResolutionCase
|
||||
* url:?string,
|
||||
* icon:string,
|
||||
* kind:string,
|
||||
* action_name:?string,
|
||||
* capability:?string,
|
||||
* requires_confirmation:bool,
|
||||
* audit_event:?string,
|
||||
|
||||
@ -0,0 +1,377 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Support\ResolutionGuidance;
|
||||
|
||||
use App\Models\EnvironmentReview;
|
||||
use App\Support\Audit\AuditActionId;
|
||||
use App\Support\Auth\Capabilities;
|
||||
use App\Support\EnvironmentReviewStatus;
|
||||
use App\Support\OperationRunType;
|
||||
use App\Support\ReviewPacks\ReviewPackOutputResolutionGuidance;
|
||||
|
||||
final class ReviewOutputResolveActionMapper
|
||||
{
|
||||
/**
|
||||
* @param array<string, mixed> $guidance
|
||||
* @param array{
|
||||
* review:?string,
|
||||
* evidence:?string,
|
||||
* operation:?string,
|
||||
* download:?string,
|
||||
* successor_review:?string
|
||||
* } $urls
|
||||
* @param array{
|
||||
* can_manage_review?:bool,
|
||||
* successor_review_status?:?string
|
||||
* } $execution
|
||||
* @return array{
|
||||
* primary_action:array{
|
||||
* key:string,
|
||||
* label:string,
|
||||
* type:string,
|
||||
* url:?string,
|
||||
* icon:string,
|
||||
* kind:string,
|
||||
* action_name:?string,
|
||||
* capability:?string,
|
||||
* requires_confirmation:bool,
|
||||
* audit_event:?string,
|
||||
* operation_run_type:?string,
|
||||
* disabled_reason:?string
|
||||
* },
|
||||
* secondary_actions:list<array{
|
||||
* key:string,
|
||||
* label:string,
|
||||
* type:string,
|
||||
* url:?string,
|
||||
* icon:string,
|
||||
* kind:string,
|
||||
* action_name:?string,
|
||||
* capability:?string,
|
||||
* requires_confirmation:bool,
|
||||
* audit_event:?string,
|
||||
* operation_run_type:?string,
|
||||
* disabled_reason:?string
|
||||
* }>
|
||||
* }
|
||||
*/
|
||||
public static function map(
|
||||
EnvironmentReview $review,
|
||||
array $guidance,
|
||||
string $sourceSurface,
|
||||
array $urls,
|
||||
array $execution = [],
|
||||
): array {
|
||||
$fallbackPrimary = ResolutionAction::fromArray(
|
||||
is_array($guidance['primary_action'] ?? null) ? $guidance['primary_action'] : null,
|
||||
self::caseKey((string) ($guidance['state'] ?? ReviewPackOutputResolutionGuidance::STATE_UNKNOWN)).'.primary_action',
|
||||
__('localization.review.review_output_limitations'),
|
||||
);
|
||||
$fallbackSecondary = self::secondaryActions($guidance);
|
||||
$candidatePrimary = self::candidatePrimaryAction($review, $guidance, $sourceSurface, $urls, $execution);
|
||||
|
||||
if ($candidatePrimary === null) {
|
||||
return [
|
||||
'primary_action' => $fallbackPrimary,
|
||||
'secondary_actions' => $fallbackSecondary,
|
||||
];
|
||||
}
|
||||
|
||||
$primaryAction = ResolutionAction::fromArray(
|
||||
$candidatePrimary,
|
||||
self::caseKey((string) ($guidance['state'] ?? ReviewPackOutputResolutionGuidance::STATE_UNKNOWN)).'.primary_action',
|
||||
__('localization.review.review_output_limitations'),
|
||||
);
|
||||
|
||||
$secondaryActions = self::deduplicateActions(array_merge(
|
||||
self::shouldPromoteFallbackPrimary($fallbackPrimary, $primaryAction) ? [$fallbackPrimary] : [],
|
||||
$fallbackSecondary,
|
||||
), $primaryAction['key']);
|
||||
|
||||
return [
|
||||
'primary_action' => $primaryAction,
|
||||
'secondary_actions' => $secondaryActions,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $guidance
|
||||
* @param array{
|
||||
* review:?string,
|
||||
* evidence:?string,
|
||||
* operation:?string,
|
||||
* download:?string,
|
||||
* successor_review:?string
|
||||
* } $urls
|
||||
* @param array{
|
||||
* can_manage_review?:bool,
|
||||
* successor_review_status?:?string
|
||||
* } $execution
|
||||
* @return array{
|
||||
* key:string,
|
||||
* label:string,
|
||||
* type:string,
|
||||
* url:?string,
|
||||
* icon:string,
|
||||
* kind:string,
|
||||
* action_name:?string,
|
||||
* capability:?string,
|
||||
* requires_confirmation:bool,
|
||||
* audit_event:?string,
|
||||
* operation_run_type:?string
|
||||
* }|null
|
||||
*/
|
||||
private static function candidatePrimaryAction(
|
||||
EnvironmentReview $review,
|
||||
array $guidance,
|
||||
string $sourceSurface,
|
||||
array $urls,
|
||||
array $execution,
|
||||
): ?array {
|
||||
$state = (string) ($guidance['state'] ?? ReviewPackOutputResolutionGuidance::STATE_UNKNOWN);
|
||||
$canManageReview = (bool) ($execution['can_manage_review'] ?? false);
|
||||
$successorReviewUrl = self::normalizedUrl($urls['successor_review'] ?? null);
|
||||
$successorReviewStatus = EnvironmentReviewStatus::tryFrom((string) ($execution['successor_review_status'] ?? ''));
|
||||
|
||||
if ($successorReviewUrl !== null) {
|
||||
return [
|
||||
'key' => 'open_successor_review',
|
||||
'label' => $successorReviewStatus?->isMutable()
|
||||
? __('localization.review.open_draft_review')
|
||||
: __('localization.review.open_successor_review'),
|
||||
'type' => ResolutionAction::TYPE_NAVIGATION,
|
||||
'url' => $successorReviewUrl,
|
||||
'icon' => 'heroicon-o-arrow-top-right-on-square',
|
||||
'kind' => 'environment_link',
|
||||
'action_name' => $sourceSurface === 'environment_review_detail'
|
||||
? 'open_successor_review'
|
||||
: null,
|
||||
'capability' => null,
|
||||
'requires_confirmation' => false,
|
||||
'audit_event' => null,
|
||||
'operation_run_type' => null,
|
||||
];
|
||||
}
|
||||
|
||||
if ($review->statusEnum() === EnvironmentReviewStatus::Ready) {
|
||||
if (! $canManageReview || ! self::supportsExecutableAction($sourceSurface, 'publish_review')) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return [
|
||||
'key' => 'publish_review',
|
||||
'label' => __('localization.review.publish_review'),
|
||||
'type' => ResolutionAction::TYPE_DOMAIN_ACTION,
|
||||
'url' => null,
|
||||
'icon' => 'heroicon-o-check-badge',
|
||||
'kind' => 'environment_action',
|
||||
'action_name' => 'publish_review',
|
||||
'capability' => Capabilities::ENVIRONMENT_REVIEW_MANAGE,
|
||||
'requires_confirmation' => true,
|
||||
'audit_event' => AuditActionId::EnvironmentReviewPublished->value,
|
||||
'operation_run_type' => null,
|
||||
];
|
||||
}
|
||||
|
||||
if ($review->isMutable() && $state !== ReviewPackOutputResolutionGuidance::STATE_CUSTOMER_SAFE_READY) {
|
||||
if (! $canManageReview || ! self::supportsExecutableAction($sourceSurface, 'refresh_review')) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return [
|
||||
'key' => 'refresh_review',
|
||||
'label' => __('localization.review.refresh_review'),
|
||||
'type' => ResolutionAction::TYPE_OPERATION_ACTION,
|
||||
'url' => null,
|
||||
'icon' => 'heroicon-o-arrow-path',
|
||||
'kind' => 'environment_action',
|
||||
'action_name' => 'refresh_review',
|
||||
'capability' => Capabilities::ENVIRONMENT_REVIEW_MANAGE,
|
||||
'requires_confirmation' => true,
|
||||
'audit_event' => AuditActionId::EnvironmentReviewRefreshed->value,
|
||||
'operation_run_type' => OperationRunType::EnvironmentReviewCompose->value,
|
||||
];
|
||||
}
|
||||
|
||||
if (! $review->isPublished() || ! in_array($state, [
|
||||
ReviewPackOutputResolutionGuidance::STATE_PUBLICATION_BLOCKED,
|
||||
ReviewPackOutputResolutionGuidance::STATE_PUBLISHED_WITH_LIMITATIONS,
|
||||
ReviewPackOutputResolutionGuidance::STATE_EXPORT_NOT_READY,
|
||||
ReviewPackOutputResolutionGuidance::STATE_INTERNAL_ONLY,
|
||||
], true)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (! $canManageReview || ! self::supportsExecutableAction($sourceSurface, 'create_next_review')) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return [
|
||||
'key' => 'create_next_review',
|
||||
'label' => __('localization.review.create_next_review'),
|
||||
'type' => ResolutionAction::TYPE_OPERATION_ACTION,
|
||||
'url' => null,
|
||||
'icon' => 'heroicon-o-document-duplicate',
|
||||
'kind' => 'environment_action',
|
||||
'action_name' => $sourceSurface === 'customer_review_workspace'
|
||||
? 'createNextReview'
|
||||
: 'create_next_review',
|
||||
'capability' => Capabilities::ENVIRONMENT_REVIEW_MANAGE,
|
||||
'requires_confirmation' => true,
|
||||
'audit_event' => AuditActionId::EnvironmentReviewSuccessorCreated->value,
|
||||
'operation_run_type' => OperationRunType::EnvironmentReviewCompose->value,
|
||||
];
|
||||
}
|
||||
|
||||
private static function supportsExecutableAction(string $sourceSurface, string $actionName): bool
|
||||
{
|
||||
return match ($sourceSurface) {
|
||||
'customer_review_workspace' => $actionName === 'create_next_review',
|
||||
'environment_review_detail' => in_array($actionName, [
|
||||
'refresh_review',
|
||||
'publish_review',
|
||||
'create_next_review',
|
||||
], true),
|
||||
default => false,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $guidance
|
||||
* @return list<array{
|
||||
* key:string,
|
||||
* label:string,
|
||||
* type:string,
|
||||
* url:?string,
|
||||
* icon:string,
|
||||
* kind:string,
|
||||
* action_name:?string,
|
||||
* capability:?string,
|
||||
* requires_confirmation:bool,
|
||||
* audit_event:?string,
|
||||
* operation_run_type:?string,
|
||||
* disabled_reason:?string
|
||||
* }>
|
||||
*/
|
||||
private static function secondaryActions(array $guidance): array
|
||||
{
|
||||
$secondaryActions = is_array($guidance['secondary_actions'] ?? null) ? $guidance['secondary_actions'] : [];
|
||||
|
||||
return array_values(array_map(
|
||||
static fn (array $action, int $index): array => ResolutionAction::fromArray(
|
||||
$action,
|
||||
self::caseKey((string) ($guidance['state'] ?? ReviewPackOutputResolutionGuidance::STATE_UNKNOWN)).'.secondary_action_'.$index,
|
||||
__('localization.review.review_output_limitations'),
|
||||
),
|
||||
array_values(array_filter($secondaryActions, static fn (mixed $action): bool => is_array($action))),
|
||||
array_keys(array_values(array_filter($secondaryActions, static fn (mixed $action): bool => is_array($action)))),
|
||||
));
|
||||
}
|
||||
|
||||
/**
|
||||
* @param list<array{
|
||||
* key:string,
|
||||
* label:string,
|
||||
* type:string,
|
||||
* url:?string,
|
||||
* icon:string,
|
||||
* kind:string,
|
||||
* action_name:?string,
|
||||
* capability:?string,
|
||||
* requires_confirmation:bool,
|
||||
* audit_event:?string,
|
||||
* operation_run_type:?string,
|
||||
* disabled_reason:?string
|
||||
* }> $actions
|
||||
* @return list<array{
|
||||
* key:string,
|
||||
* label:string,
|
||||
* type:string,
|
||||
* url:?string,
|
||||
* icon:string,
|
||||
* kind:string,
|
||||
* action_name:?string,
|
||||
* capability:?string,
|
||||
* requires_confirmation:bool,
|
||||
* audit_event:?string,
|
||||
* operation_run_type:?string,
|
||||
* disabled_reason:?string
|
||||
* }>
|
||||
*/
|
||||
private static function deduplicateActions(array $actions, string $primaryKey): array
|
||||
{
|
||||
$seen = [$primaryKey => true];
|
||||
$deduplicated = [];
|
||||
|
||||
foreach ($actions as $action) {
|
||||
if (($action['type'] ?? ResolutionAction::TYPE_NONE) === ResolutionAction::TYPE_NONE) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$key = (string) ($action['key'] ?? '');
|
||||
|
||||
if ($key === '' || isset($seen[$key])) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$seen[$key] = true;
|
||||
$deduplicated[] = $action;
|
||||
}
|
||||
|
||||
return $deduplicated;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array{
|
||||
* key:string,
|
||||
* label:string,
|
||||
* type:string,
|
||||
* url:?string,
|
||||
* icon:string,
|
||||
* kind:string,
|
||||
* action_name:?string,
|
||||
* capability:?string,
|
||||
* requires_confirmation:bool,
|
||||
* audit_event:?string,
|
||||
* operation_run_type:?string,
|
||||
* disabled_reason:?string
|
||||
* } $fallbackPrimary
|
||||
* @param array{
|
||||
* key:string,
|
||||
* label:string,
|
||||
* type:string,
|
||||
* url:?string,
|
||||
* icon:string,
|
||||
* kind:string,
|
||||
* action_name:?string,
|
||||
* capability:?string,
|
||||
* requires_confirmation:bool,
|
||||
* audit_event:?string,
|
||||
* operation_run_type:?string,
|
||||
* disabled_reason:?string
|
||||
* } $primaryAction
|
||||
*/
|
||||
private static function shouldPromoteFallbackPrimary(array $fallbackPrimary, array $primaryAction): bool
|
||||
{
|
||||
if ($fallbackPrimary['key'] === $primaryAction['key']) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return $fallbackPrimary['type'] !== ResolutionAction::TYPE_NONE
|
||||
&& ($fallbackPrimary['url'] !== null || $fallbackPrimary['action_name'] !== null);
|
||||
}
|
||||
|
||||
private static function normalizedUrl(mixed $url): ?string
|
||||
{
|
||||
return is_string($url) && trim($url) !== ''
|
||||
? trim($url)
|
||||
: null;
|
||||
}
|
||||
|
||||
private static function caseKey(string $state): string
|
||||
{
|
||||
return 'review_output.'.$state;
|
||||
}
|
||||
}
|
||||
@ -161,6 +161,29 @@
|
||||
],
|
||||
],
|
||||
],
|
||||
|
||||
'review_output' => [
|
||||
'browser_smoke_fixture' => [
|
||||
'workspace' => [
|
||||
'name' => 'Spec 351 Review Output Smoke',
|
||||
'slug' => 'spec-351-review-output-smoke',
|
||||
],
|
||||
'user' => [
|
||||
'name' => 'Spec 351 Requester',
|
||||
'email' => 'smoke-requester+351@tenantpilot.local',
|
||||
'password' => 'password',
|
||||
],
|
||||
'ready_draft' => [
|
||||
'managed_environment_name' => 'Spec 351 Browser Ready Draft',
|
||||
'managed_environment_slug' => 'spec-351-browser-ready-draft',
|
||||
'operation_initiator_name' => 'Spec 351 Browser Operator',
|
||||
'publish_reason' => 'Seed published predecessor for Spec 351 browser verification.',
|
||||
'published_review_pack_path' => 'review-packs/spec351-browser-ready-published.zip',
|
||||
'published_review_pack_contents' => 'PK-spec351-browser-ready-published',
|
||||
],
|
||||
],
|
||||
],
|
||||
|
||||
'supported_policy_types' => [
|
||||
[
|
||||
'type' => 'deviceConfiguration',
|
||||
|
||||
@ -389,7 +389,9 @@
|
||||
'readiness' => 'Bereitschaft',
|
||||
'evidence' => 'Evidence',
|
||||
'review_consumption_flow' => 'Review-Consumption-Flow',
|
||||
'review_consumption_flow_description' => 'Prüfen Sie die abgeleiteten Review-, Evidence-, Findings-, Accepted-Risk-, Pack- und Kundenausgabe-Status vor der Weitergabe.',
|
||||
'review_consumption_flow_description' => 'Unterstützende Referenz dafür, wie veröffentlichtes Review, Evidence, Findings, Accepted Risks, Pack und Kundenausgabe nach der Entscheidung oben zusammenpassen.',
|
||||
'supporting_reference' => 'Unterstützende Referenz',
|
||||
'supporting_actions' => 'Unterstützende Aktionen',
|
||||
'review_data' => 'Review-Daten',
|
||||
'review_data_available_description' => 'Ein veröffentlichtes Review ist für diesen kundensicheren Workspace verfügbar.',
|
||||
'findings_triaged' => 'Findings triagiert',
|
||||
@ -433,6 +435,19 @@
|
||||
'export_availability' => 'Export-Verfügbarkeit',
|
||||
'export_ready' => 'Export bereit',
|
||||
'export_not_ready' => 'Export nicht bereit',
|
||||
'package_exists' => 'Paket vorhanden',
|
||||
'package_exists_available_description' => 'Für diesen Review-Pfad existiert ein veröffentlichtes Review-Pack-Artefakt.',
|
||||
'package_exists_preparing_description' => 'Das Review-Pack-Artefakt wird für diesen veröffentlichten Review-Pfad noch vorbereitet.',
|
||||
'package_exists_unavailable_description' => 'Für diesen Review-Pfad existiert noch kein veröffentlichtes Review-Pack-Artefakt.',
|
||||
'internal_export' => 'Interner Export',
|
||||
'internal_export_ready_description' => 'Ein interner Export kann aus dem aktuellen Review-Pack geöffnet oder heruntergeladen werden.',
|
||||
'internal_export_preparing_description' => 'Der interne Export wird aus dem aktuellen Review-Pack noch vorbereitet.',
|
||||
'internal_export_not_ready_description' => 'Ein Review-Pack existiert, aber der interne Export ist noch nicht zum Download bereit.',
|
||||
'internal_export_unavailable_description' => 'Aus diesem Review-Pack-Pfad ist aktuell kein interner Export verfügbar.',
|
||||
'customer_sharing' => 'Kundenfreigabe',
|
||||
'customer_sharing_ready_description' => 'Das aktuelle Paket kann gemäß dem Output-Readiness-Vertrag als kundensicher behandelt werden.',
|
||||
'customer_sharing_internal_only_description' => 'Das aktuelle Paket bleibt intern und ist nicht für die Weitergabe an Kunden bereit.',
|
||||
'customer_sharing_requires_review_description' => 'Die Weitergabe an Kunden hängt weiterhin von den im Hauptstatus gezeigten Blockern und Einschränkungen ab.',
|
||||
'evidence_basis_state' => 'Evidence-Basis',
|
||||
'section_completeness' => 'Abschnittsvollständigkeit',
|
||||
'sharing_boundary' => 'Freigabebereich',
|
||||
@ -454,7 +469,7 @@
|
||||
'acknowledge_review' => 'Review bestätigen',
|
||||
're_acknowledge_review' => 'Review erneut bestätigen',
|
||||
'acknowledge_review_heading' => 'Dieses Review bestätigen?',
|
||||
'acknowledge_review_description' => 'Erfasst eine kundensichere Bestätigung für das aktuell veröffentlichte Review-Paket. Dies ist keine rechtliche Attestierung.',
|
||||
'acknowledge_review_description' => 'Erfasst, dass das aktuell veröffentlichte Review-Paket geprüft wurde. Dadurch wird das Paket nicht kundenbereit und es ist keine rechtliche Attestierung.',
|
||||
'acknowledge_review_confirm' => 'Bestätigen',
|
||||
'acknowledge_review_comment' => 'Kommentar (optional)',
|
||||
'acknowledge_review_unavailable' => 'Review kann nicht bestätigt werden',
|
||||
@ -466,13 +481,13 @@
|
||||
'review_accepted_risks' => 'Akzeptierte Risiken prüfen',
|
||||
'acknowledgement_required' => 'Bestätigung erforderlich',
|
||||
'acknowledgement_required_reason' => 'Dieses veröffentlichte Review wurde noch nicht bestätigt.',
|
||||
'acknowledgement_required_impact' => 'Erfassen Sie eine Bestätigung, bevor Sie dieses Review als gemeinsames kundensicheres Artefakt verwenden.',
|
||||
'acknowledgement_required_impact' => 'Die Bestätigung dokumentiert die Review-Nutzung dieses veröffentlichten Pakets. Der Kundenbereit-Status ergibt sich weiterhin aus dem Output-Status oben.',
|
||||
'acknowledgement_requires_permission' => 'Ihnen fehlt die Berechtigung, Reviews zu bestätigen.',
|
||||
'acknowledgement_re_ack_required' => 'Erneute Bestätigung erforderlich',
|
||||
'acknowledgement_re_ack_required_reason' => 'Das Review-Pack oder die Evidence-Basis hat sich seit der letzten Bestätigung geändert.',
|
||||
'acknowledgement_re_ack_required_impact' => 'Bestätigen Sie erneut, um das aktuelle kundensichere Paket zu prüfen.',
|
||||
'acknowledgement_recorded_reason' => 'Eine kundensichere Bestätigung ist für dieses veröffentlichte Review-Paket erfasst.',
|
||||
'acknowledgement_recorded_impact' => 'Dieses Review-Paket kann nun mit einer expliziten Bestätigungskette referenziert werden.',
|
||||
'acknowledgement_re_ack_required_impact' => 'Bestätigen Sie erneut, um zu dokumentieren, dass Sie das aktuelle veröffentlichte Paket geprüft haben. Dadurch ändert sich der Kundenbereit-Status nicht.',
|
||||
'acknowledgement_recorded_reason' => 'Für dieses veröffentlichte Review-Paket ist eine Bestätigung erfasst.',
|
||||
'acknowledgement_recorded_impact' => 'Dies bestätigt die Review-Nutzung des aktuellen Pakets. Der Kundenbereit-Status ändert sich dadurch nicht.',
|
||||
'basis' => 'Basis',
|
||||
'acknowledged_by' => 'Bestätigt von',
|
||||
'acknowledged_at' => 'Bestätigt am',
|
||||
@ -558,6 +573,8 @@
|
||||
'open_latest_review' => 'Letztes Review öffnen',
|
||||
'open' => 'Öffnen',
|
||||
'open_review' => 'Review öffnen',
|
||||
'open_draft_review' => 'Draft-Review öffnen',
|
||||
'open_successor_review' => 'Nachfolge-Review öffnen',
|
||||
'last_review' => 'Letztes Review',
|
||||
'primary_action' => 'Primäre Aktion',
|
||||
'download_review_pack' => 'Review-Pack herunterladen',
|
||||
@ -572,14 +589,36 @@
|
||||
'review_section_limitations' => 'Abschnittseinschränkungen prüfen',
|
||||
'review_pii_redaction_state' => 'PII-/Redaktionsstatus prüfen',
|
||||
'resolve_review_blockers' => 'Review-Blocker prüfen',
|
||||
'refresh_review' => 'Review aktualisieren',
|
||||
'publish_review' => 'Review veröffentlichen',
|
||||
'create_next_review' => 'Nächstes Review erstellen',
|
||||
'create_next_review_heading' => 'Nächstes Review erstellen?',
|
||||
'create_next_review_description' => 'TenantPilot startet den nächsten Review-Zyklus aus der neuesten geeigneten Evidence-Basis und markiert dieses veröffentlichte Review als ersetzt.',
|
||||
'create_next_review_confirm' => 'Nächstes Review erstellen',
|
||||
'create_next_review_success' => 'Nächstes Review erstellt',
|
||||
'create_next_review_unavailable' => 'Die Erstellung des nächsten Reviews ist derzeit nicht verfügbar.',
|
||||
'create_next_review_failed' => 'Nächstes Review konnte nicht erstellt werden',
|
||||
'draft_review_exists' => 'Draft-Review vorhanden',
|
||||
'draft_review_exists_blocked_reason' => 'Für diesen veröffentlichten Output existiert bereits ein Nachfolge-Draft-Review. Öffnen Sie das Draft-Review, um Inputs zu aktualisieren und die verbleibenden Blocker aufzulösen.',
|
||||
'draft_review_exists_blocked_impact' => 'Der Operator-Loop soll im vorhandenen Draft-Review weiterlaufen. Aktualisieren Sie den Draft bei geänderten Inputs und veröffentlichen Sie erst nach dem Auflösen der Blocker.',
|
||||
'draft_review_exists_ready_reason' => 'Für diesen veröffentlichten Output existiert bereits ein Nachfolge-Draft-Review und ist zur Veröffentlichung bereit. Öffnen Sie das Draft-Review, um das nächste governed Ergebnis zu veröffentlichen.',
|
||||
'draft_review_exists_ready_impact' => 'Der nächste Review-Zyklus läuft bereits. Öffnen Sie das Draft-Review und veröffentlichen Sie es, sobald es das bisherige veröffentlichte Review ersetzen soll.',
|
||||
'resolve_review_blockers_before_publishing' => 'Lösen Sie die Review-Blocker vor der Veröffentlichung auf.',
|
||||
'refresh_review_feedback_blocked' => 'Review-Inputs aktualisiert. Blocker bestehen weiterhin.',
|
||||
'refresh_review_feedback_ready' => 'Review-Inputs aktualisiert. Bereit zur Veröffentlichung.',
|
||||
'open_operation_proof' => 'Operationsnachweis öffnen',
|
||||
'open_evidence_basis' => 'Evidence-Basis öffnen',
|
||||
'output_guidance_detail_mode_note' => 'Sie befinden sich bereits auf der Review-Detailseite für diesen Output. Nutzen Sie die Einschränkungen und technischen Details unten, um Blocker, Evidence-Status und den aktuellen Export zu prüfen.',
|
||||
'output_action_help_publication_blocked' => 'Die primäre Aktion öffnet die Review-Detailseite mit Blockern, Evidence-Status und nächsten Schritten. Die sekundären Aktionen laden das aktuelle Paket herunter oder springen zum Evidence-Snapshot und Operationsnachweis.',
|
||||
'output_action_help_published_with_limitations' => 'Die primäre Aktion öffnet die Review-Detailseite zur aktuellen Einschränkung. Die sekundären Aktionen laden das aktuelle Paket herunter oder springen zur Evidence-Basis.',
|
||||
'output_action_help_internal_only' => 'Die primäre Aktion öffnet die Review-Detailseite für PII- und Redaktionsprüfungen. Die sekundären Aktionen erlauben den Download des internen Pakets oder öffnen den veröffentlichten Datensatz.',
|
||||
'output_action_help_export_not_ready' => 'Die primäre Aktion öffnet die Review-Detailseite mit den aktuellen Output-Einschränkungen. Die sekundären Aktionen springen zur Evidence-Basis oder zum veröffentlichten Review.',
|
||||
'output_action_help_customer_safe_ready' => 'Die primäre Aktion lädt das kundensichere Paket herunter. Über die sekundäre Aktion öffnen Sie die Detailseite des veröffentlichten Reviews.',
|
||||
'output_action_help_publication_blocked' => 'Die primäre Aktion öffnet die Review-Detailseite mit Blockern, Evidence-Status und nächsten Schritten. Die unterstützenden Aktionen laden das aktuelle Paket herunter oder springen zum Evidence-Snapshot und Operationsnachweis.',
|
||||
'output_action_help_published_with_limitations' => 'Die primäre Aktion öffnet die Review-Detailseite zur aktuellen Einschränkung. Die unterstützenden Aktionen laden das aktuelle Paket herunter oder springen zur Evidence-Basis.',
|
||||
'output_action_help_internal_only' => 'Die primäre Aktion öffnet die Review-Detailseite für PII- und Redaktionsprüfungen. Die unterstützenden Aktionen erlauben den Download des internen Pakets oder öffnen den veröffentlichten Datensatz.',
|
||||
'output_action_help_export_not_ready' => 'Die primäre Aktion öffnet die Review-Detailseite mit den aktuellen Output-Einschränkungen. Die unterstützenden Aktionen springen zur Evidence-Basis oder zum veröffentlichten Review.',
|
||||
'output_action_help_customer_safe_ready' => 'Die primäre Aktion lädt das kundensichere Paket herunter. Über die unterstützende Aktion öffnen Sie die Detailseite des veröffentlichten Reviews.',
|
||||
'output_action_help_create_next_review' => 'Starten Sie den nächsten Review-Zyklus aus der neuesten geeigneten Evidence-Basis. Über die unterstützenden Aktionen prüfen Sie zuvor Blocker, Evidence oder das aktuelle Paket.',
|
||||
'output_action_help_refresh_review' => 'Aktualisieren Sie dieses mutable Review aus der neuesten geeigneten Evidence-Basis. Über die unterstützenden Aktionen prüfen Sie zuvor die aktuelle Evidence-Basis oder den Review-Kontext.',
|
||||
'output_action_help_publish_review' => 'Veröffentlichen Sie dieses mutable Review als aktuellen Governance-Status, sobald der Output bereit ist. Über die unterstützenden Aktionen prüfen Sie zuvor Evidence-Basis oder Review-Kontext.',
|
||||
'output_action_help_open_draft_review' => 'Öffnen Sie das vorhandene Draft-Review, um den nächsten Review-Zyklus fortzusetzen. Aktualisieren Sie den Draft bei verbleibenden Blockern oder veröffentlichen Sie ihn, sobald der Output bereit ist.',
|
||||
'output_action_help_open_successor_review' => 'Öffnen Sie das bekannte Nachfolge-Review, um den nächsten Review-Zyklus fortzusetzen. Die unterstützenden Aktionen bleiben für Evidence, Nachweise oder den bisherigen Veröffentlichungsstand verfügbar.',
|
||||
'evidence_basis_incomplete_guidance' => 'Evidence-Basis unvollständig',
|
||||
'evidence_basis_incomplete_guidance_reason' => 'Das Review-Paket ist an einen Evidence-Snapshot mit fehlenden oder unvollständigen Nachweisen gebunden.',
|
||||
'evidence_basis_incomplete_short_reason' => 'Die Evidence-Basis ist unvollständig.',
|
||||
|
||||
@ -389,7 +389,9 @@
|
||||
'readiness' => 'Readiness',
|
||||
'evidence' => 'Evidence',
|
||||
'review_consumption_flow' => 'Review consumption flow',
|
||||
'review_consumption_flow_description' => 'Follow the derived review, evidence, findings, accepted-risk, pack, and customer output states before sharing.',
|
||||
'review_consumption_flow_description' => 'Supporting reference for how the released review, evidence, findings, accepted risks, pack, and customer output line up after the decision above.',
|
||||
'supporting_reference' => 'Supporting reference',
|
||||
'supporting_actions' => 'Supporting actions',
|
||||
'review_data' => 'Review data',
|
||||
'review_data_available_description' => 'A released review is available for this customer-safe workspace.',
|
||||
'findings_triaged' => 'Findings triaged',
|
||||
@ -433,6 +435,19 @@
|
||||
'export_availability' => 'Export availability',
|
||||
'export_ready' => 'Export ready',
|
||||
'export_not_ready' => 'Export not ready',
|
||||
'package_exists' => 'Package exists',
|
||||
'package_exists_available_description' => 'A released review pack artifact exists for this review path.',
|
||||
'package_exists_preparing_description' => 'The review pack artifact is still being prepared for this released review path.',
|
||||
'package_exists_unavailable_description' => 'No released review pack artifact exists yet for this review path.',
|
||||
'internal_export' => 'Internal export',
|
||||
'internal_export_ready_description' => 'An internal export can be opened or downloaded from the current review pack.',
|
||||
'internal_export_preparing_description' => 'The internal export is still being prepared from the current review pack.',
|
||||
'internal_export_not_ready_description' => 'A review pack exists, but the internal export is not ready for download yet.',
|
||||
'internal_export_unavailable_description' => 'No current internal export is available from this review pack path.',
|
||||
'customer_sharing' => 'Customer sharing',
|
||||
'customer_sharing_ready_description' => 'The current package can be treated as customer-safe according to the output readiness contract.',
|
||||
'customer_sharing_internal_only_description' => 'The current package remains internal-only and is not ready for customer sharing.',
|
||||
'customer_sharing_requires_review_description' => 'Customer sharing still depends on the readiness blockers and limitations shown in the main decision state.',
|
||||
'evidence_basis_state' => 'Evidence basis',
|
||||
'section_completeness' => 'Section completeness',
|
||||
'sharing_boundary' => 'Sharing boundary',
|
||||
@ -454,7 +469,7 @@
|
||||
'acknowledge_review' => 'Acknowledge review',
|
||||
're_acknowledge_review' => 'Re-acknowledge review',
|
||||
'acknowledge_review_heading' => 'Acknowledge this review?',
|
||||
'acknowledge_review_description' => 'Records a customer-safe acknowledgement for the current published review package. This is not a legal attestation.',
|
||||
'acknowledge_review_description' => 'Records that the current published review package was reviewed. It does not make the package customer-ready and is not a legal attestation.',
|
||||
'acknowledge_review_confirm' => 'Acknowledge',
|
||||
'acknowledge_review_comment' => 'Comment (optional)',
|
||||
'acknowledge_review_unavailable' => 'Unable to acknowledge review',
|
||||
@ -466,13 +481,13 @@
|
||||
'review_accepted_risks' => 'Review accepted risks',
|
||||
'acknowledgement_required' => 'Acknowledgement required',
|
||||
'acknowledgement_required_reason' => 'This published review has not been acknowledged yet.',
|
||||
'acknowledgement_required_impact' => 'Record acknowledgement before relying on this review as a shared customer-safe artifact.',
|
||||
'acknowledgement_required_impact' => 'Acknowledgement records review consumption for this published package. Customer-ready status is still determined by the output state above.',
|
||||
'acknowledgement_requires_permission' => 'You do not have permission to acknowledge reviews.',
|
||||
'acknowledgement_re_ack_required' => 'Re-acknowledgement required',
|
||||
'acknowledgement_re_ack_required_reason' => 'The review pack or evidence basis changed after the last acknowledgement.',
|
||||
'acknowledgement_re_ack_required_impact' => 'Re-acknowledge to confirm you reviewed the current customer-safe package.',
|
||||
'acknowledgement_recorded_reason' => 'A customer-safe acknowledgement is recorded for this published review package.',
|
||||
'acknowledgement_recorded_impact' => 'This review package can now be referenced with an explicit acknowledgement trail.',
|
||||
'acknowledgement_re_ack_required_impact' => 'Re-acknowledge to confirm you reviewed the current published package. This does not change customer-ready status.',
|
||||
'acknowledgement_recorded_reason' => 'An acknowledgement is recorded for this published review package.',
|
||||
'acknowledgement_recorded_impact' => 'This confirms review consumption for the current package. It does not change customer-ready status.',
|
||||
'basis' => 'Basis',
|
||||
'acknowledged_by' => 'Acknowledged by',
|
||||
'acknowledged_at' => 'Acknowledged at',
|
||||
@ -558,6 +573,8 @@
|
||||
'open_latest_review' => 'Open latest review',
|
||||
'open' => 'Open',
|
||||
'open_review' => 'Open review',
|
||||
'open_draft_review' => 'Open draft review',
|
||||
'open_successor_review' => 'Open successor review',
|
||||
'last_review' => 'Last review',
|
||||
'primary_action' => 'Primary action',
|
||||
'download_review_pack' => 'Download review pack',
|
||||
@ -572,14 +589,36 @@
|
||||
'review_section_limitations' => 'Review section limitations',
|
||||
'review_pii_redaction_state' => 'Review PII/redaction state',
|
||||
'resolve_review_blockers' => 'Inspect review blockers',
|
||||
'refresh_review' => 'Refresh review',
|
||||
'publish_review' => 'Publish review',
|
||||
'create_next_review' => 'Create next review',
|
||||
'create_next_review_heading' => 'Create next review?',
|
||||
'create_next_review_description' => 'TenantPilot creates the next review cycle from the latest eligible evidence basis and supersedes this published review.',
|
||||
'create_next_review_confirm' => 'Create next review',
|
||||
'create_next_review_success' => 'Next review created',
|
||||
'create_next_review_unavailable' => 'Next review creation is unavailable right now.',
|
||||
'create_next_review_failed' => 'Unable to create next review',
|
||||
'draft_review_exists' => 'Draft review exists',
|
||||
'draft_review_exists_blocked_reason' => 'A successor draft review already exists for this released output. Open the draft review to refresh inputs and resolve the remaining blockers.',
|
||||
'draft_review_exists_blocked_impact' => 'The operator loop should continue in the existing draft review. Refresh the draft when inputs change, then publish only after the blockers are cleared.',
|
||||
'draft_review_exists_ready_reason' => 'A successor draft review already exists for this released output and is ready for publication. Open the draft review to publish the next governed outcome.',
|
||||
'draft_review_exists_ready_impact' => 'The next review cycle is already in progress. Open the draft review and publish it when you are ready to replace the prior released review.',
|
||||
'resolve_review_blockers_before_publishing' => 'Resolve review blockers before publishing.',
|
||||
'refresh_review_feedback_blocked' => 'Review inputs refreshed. Blockers remain.',
|
||||
'refresh_review_feedback_ready' => 'Review inputs refreshed. Ready to publish.',
|
||||
'open_operation_proof' => 'Open operation proof',
|
||||
'open_evidence_basis' => 'Open evidence basis',
|
||||
'output_guidance_detail_mode_note' => 'You are already on the review detail for this output. Use the limitations and technical details below to inspect blockers, evidence state, and the current export.',
|
||||
'output_action_help_publication_blocked' => 'The primary action opens the review detail with blockers, evidence status, and next steps. The secondary actions download the current package or jump to the evidence snapshot and operation proof.',
|
||||
'output_action_help_published_with_limitations' => 'The primary action opens the review detail for the current limitation. The secondary actions download the current package or jump to the evidence basis.',
|
||||
'output_action_help_internal_only' => 'The primary action opens the review detail for PII and redaction checks. The secondary actions let you download the internal package or review the released record.',
|
||||
'output_action_help_export_not_ready' => 'The primary action opens the review detail with the current output limitations. The secondary actions jump to the evidence basis or the released review.',
|
||||
'output_action_help_customer_safe_ready' => 'The primary action downloads the customer-safe package. Use the secondary action to open the released review detail.',
|
||||
'output_action_help_publication_blocked' => 'The primary action opens the review detail with blockers, evidence status, and next steps. The supporting actions download the current package or jump to the evidence snapshot and operation proof.',
|
||||
'output_action_help_published_with_limitations' => 'The primary action opens the review detail for the current limitation. The supporting actions download the current package or jump to the evidence basis.',
|
||||
'output_action_help_internal_only' => 'The primary action opens the review detail for PII and redaction checks. The supporting actions let you download the internal package or review the released record.',
|
||||
'output_action_help_export_not_ready' => 'The primary action opens the review detail with the current output limitations. The supporting actions jump to the evidence basis or the released review.',
|
||||
'output_action_help_customer_safe_ready' => 'The primary action downloads the customer-safe package. Use the supporting action to open the released review detail.',
|
||||
'output_action_help_create_next_review' => 'Create the next review cycle from the latest eligible evidence basis. Use the supporting actions to inspect blockers, evidence, or the current package before moving forward.',
|
||||
'output_action_help_refresh_review' => 'Refresh this mutable review from the latest eligible evidence basis. Use the supporting actions to inspect the current evidence basis or review context first.',
|
||||
'output_action_help_publish_review' => 'Publish this mutable review as the current governed outcome when the output is ready. Use the supporting actions to inspect the evidence basis or current review context first.',
|
||||
'output_action_help_open_draft_review' => 'Open the existing draft review to continue the next review cycle. Refresh the draft if blockers remain, or publish it when the output is ready.',
|
||||
'output_action_help_open_successor_review' => 'Open the known successor review to continue the next review cycle. Use the supporting actions for evidence, proof, or the prior released review context.',
|
||||
'evidence_basis_incomplete_guidance' => 'Evidence basis incomplete',
|
||||
'evidence_basis_incomplete_guidance_reason' => 'The review pack is anchored to an evidence snapshot with missing or incomplete evidence.',
|
||||
'evidence_basis_incomplete_short_reason' => 'Evidence basis is incomplete.',
|
||||
|
||||
@ -8,6 +8,7 @@
|
||||
? $resolutionCase['secondary_actions']
|
||||
: (is_array($state['secondary_actions'] ?? null) ? $state['secondary_actions'] : []);
|
||||
$detailMode = (bool) ($state['detail_mode'] ?? false);
|
||||
$suppressPrimaryActionButton = (bool) ($state['suppress_primary_action_button'] ?? false);
|
||||
$contextNote = is_string($state['context_note'] ?? null) ? $state['context_note'] : null;
|
||||
$nextStepLabel = is_string($state['next_step_label'] ?? null)
|
||||
? $state['next_step_label']
|
||||
@ -73,7 +74,7 @@
|
||||
|
||||
@unless ($detailMode)
|
||||
<div class="mt-4 flex flex-wrap gap-2">
|
||||
@if (filled(data_get($resolutionCase, 'primary_action.url', data_get($state, 'primary_action.url'))))
|
||||
@if (! $suppressPrimaryActionButton && filled(data_get($resolutionCase, 'primary_action.url', data_get($state, 'primary_action.url'))))
|
||||
<x-filament::button
|
||||
tag="a"
|
||||
:href="data_get($resolutionCase, 'primary_action.url', data_get($state, 'primary_action.url'))"
|
||||
|
||||
@ -109,39 +109,86 @@ class="rounded-xl border border-gray-200 bg-white p-4 shadow-sm dark:border-whit
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-wrap gap-2">
|
||||
@if (filled(data_get($resolutionCase, 'primary_action.url', $readiness['primary_action_url'])))
|
||||
<x-filament::button
|
||||
tag="a"
|
||||
:href="data_get($resolutionCase, 'primary_action.url', $readiness['primary_action_url'])"
|
||||
:icon="data_get($resolutionCase, 'primary_action.icon', $readiness['primary_action_icon'])"
|
||||
target="_blank"
|
||||
data-testid="customer-review-primary-action"
|
||||
>
|
||||
{{ data_get($resolutionCase, 'primary_action.label', $readiness['primary_action_label']) }}
|
||||
</x-filament::button>
|
||||
@endif
|
||||
|
||||
@foreach ((is_array($resolutionCase['secondary_actions'] ?? null) ? $resolutionCase['secondary_actions'] : $readiness['secondary_actions']) as $secondaryAction)
|
||||
<x-filament::button
|
||||
tag="a"
|
||||
:href="$secondaryAction['url']"
|
||||
color="gray"
|
||||
:icon="$secondaryAction['icon']"
|
||||
data-testid="customer-review-secondary-action"
|
||||
>
|
||||
{{ $secondaryAction['label'] }}
|
||||
</x-filament::button>
|
||||
@endforeach
|
||||
</div>
|
||||
|
||||
@php
|
||||
$outputGuidance = $readiness['output_guidance'] ?? [];
|
||||
$actionHelp = is_string($outputGuidance['action_help'] ?? null) ? $outputGuidance['action_help'] : null;
|
||||
$outputLimitations = is_array($outputGuidance['limitations'] ?? null) ? $outputGuidance['limitations'] : [];
|
||||
$technicalDetails = is_array($outputGuidance['technical_details'] ?? null) ? $outputGuidance['technical_details'] : [];
|
||||
$primaryActionName = is_string(data_get($resolutionCase, 'primary_action.action_name')) ? data_get($resolutionCase, 'primary_action.action_name') : null;
|
||||
$primaryActionUrl = data_get($resolutionCase, 'primary_action.url', $readiness['primary_action_url']);
|
||||
$primaryActionIcon = data_get($resolutionCase, 'primary_action.icon', $readiness['primary_action_icon']);
|
||||
$primaryActionLabel = data_get($resolutionCase, 'primary_action.label', $readiness['primary_action_label']);
|
||||
$secondaryActions = collect(is_array($resolutionCase['secondary_actions'] ?? null) ? $resolutionCase['secondary_actions'] : $readiness['secondary_actions'])
|
||||
->filter(fn (array $secondaryAction): bool => filled($secondaryAction['url'] ?? null) || filled($secondaryAction['action_name'] ?? null))
|
||||
->values()
|
||||
->all();
|
||||
@endphp
|
||||
|
||||
<div class="space-y-3">
|
||||
<div class="flex flex-wrap gap-2">
|
||||
@if (filled($primaryActionName))
|
||||
<x-filament::button
|
||||
type="button"
|
||||
:icon="$primaryActionIcon"
|
||||
wire:click="mountAction('{{ $primaryActionName }}')"
|
||||
class="sm:min-w-56 sm:justify-center"
|
||||
data-testid="customer-review-primary-action"
|
||||
>
|
||||
{{ $primaryActionLabel }}
|
||||
</x-filament::button>
|
||||
@elseif (filled($primaryActionUrl))
|
||||
<x-filament::button
|
||||
tag="a"
|
||||
:href="$primaryActionUrl"
|
||||
:icon="$primaryActionIcon"
|
||||
target="_blank"
|
||||
class="sm:min-w-56 sm:justify-center"
|
||||
data-testid="customer-review-primary-action"
|
||||
>
|
||||
{{ $primaryActionLabel }}
|
||||
</x-filament::button>
|
||||
@endif
|
||||
</div>
|
||||
|
||||
@if ($secondaryActions !== [])
|
||||
<div
|
||||
class="rounded-lg border border-dashed border-gray-200 bg-gray-50/80 p-3 dark:border-white/10 dark:bg-white/5"
|
||||
data-testid="customer-review-secondary-actions"
|
||||
>
|
||||
<div class="text-[0.7rem] font-semibold uppercase leading-4 tracking-wide text-gray-500 dark:text-gray-400">
|
||||
{{ __('localization.review.supporting_actions') }}
|
||||
</div>
|
||||
|
||||
<div class="mt-2 flex flex-wrap gap-x-4 gap-y-2">
|
||||
@foreach ($secondaryActions as $secondaryAction)
|
||||
@if (filled($secondaryAction['action_name'] ?? null))
|
||||
<x-filament::button
|
||||
type="button"
|
||||
color="gray"
|
||||
size="sm"
|
||||
:icon="$secondaryAction['icon']"
|
||||
wire:click="mountAction('{{ $secondaryAction['action_name'] }}')"
|
||||
data-testid="customer-review-secondary-action"
|
||||
>
|
||||
{{ $secondaryAction['label'] }}
|
||||
</x-filament::button>
|
||||
@elseif (filled($secondaryAction['url'] ?? null))
|
||||
<x-filament::link
|
||||
:href="$secondaryAction['url']"
|
||||
color="gray"
|
||||
size="sm"
|
||||
:icon="$secondaryAction['icon']"
|
||||
data-testid="customer-review-secondary-action"
|
||||
>
|
||||
{{ $secondaryAction['label'] }}
|
||||
</x-filament::link>
|
||||
@endif
|
||||
@endforeach
|
||||
</div>
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
|
||||
@if (filled($actionHelp))
|
||||
<p
|
||||
class="text-xs leading-5 text-gray-500 dark:text-gray-400"
|
||||
@ -371,75 +418,6 @@ class="rounded-xl border border-gray-200 bg-white p-4 shadow-sm dark:border-whit
|
||||
$currentReadinessStep = collect($readinessFlow)->firstWhere('is_current', true);
|
||||
@endphp
|
||||
|
||||
<details
|
||||
class="group rounded-xl border border-gray-200 bg-white p-4 shadow-sm dark:border-white/10 dark:bg-gray-900"
|
||||
data-testid="customer-review-readiness-flow"
|
||||
>
|
||||
<summary class="-m-2 cursor-pointer list-none rounded-lg p-2 marker:text-gray-400 transition hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-primary-500 focus:ring-offset-2 focus:ring-offset-white dark:hover:bg-white/5 dark:focus:ring-offset-gray-900">
|
||||
<div class="flex flex-wrap items-start justify-between gap-3">
|
||||
<div>
|
||||
<h2 class="text-sm font-semibold text-gray-950 dark:text-white">
|
||||
{{ __('localization.review.review_consumption_flow') }}
|
||||
</h2>
|
||||
<p class="mt-1 text-sm text-gray-600 dark:text-gray-300">
|
||||
{{ __('localization.review.review_consumption_flow_description') }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="flex items-start gap-3">
|
||||
@if ($currentReadinessStep !== null)
|
||||
<div class="shrink-0">
|
||||
<div class="text-[0.7rem] font-semibold uppercase leading-4 tracking-wide text-gray-500 dark:text-gray-400">
|
||||
{{ __('localization.review.current_attention_point') }}
|
||||
</div>
|
||||
<x-filament::badge :color="$currentReadinessStep['color']" size="sm" class="mt-1 max-w-full whitespace-normal text-left">
|
||||
{{ $currentReadinessStep['label'] }}
|
||||
</x-filament::badge>
|
||||
</div>
|
||||
@endif
|
||||
|
||||
<x-filament::icon
|
||||
icon="heroicon-m-chevron-down"
|
||||
class="mt-0.5 h-5 w-5 text-gray-400 transition-transform duration-150 group-open:rotate-180 dark:text-gray-500"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</summary>
|
||||
|
||||
<ul class="mt-4 divide-y divide-gray-100 text-sm dark:divide-white/10">
|
||||
@foreach ($readinessFlow as $step)
|
||||
<li
|
||||
class="py-3 first:pt-0 last:pb-0"
|
||||
data-testid="customer-review-readiness-step"
|
||||
data-step-label="{{ $step['title'] }}"
|
||||
data-step-state="{{ $step['label'] }}"
|
||||
data-step-current="{{ $step['is_current'] ? 'true' : 'false' }}"
|
||||
>
|
||||
<div class="flex flex-wrap items-start justify-between gap-3">
|
||||
<div class="min-w-0">
|
||||
<div class="font-medium text-gray-950 dark:text-white">
|
||||
{{ $step['title'] }}
|
||||
</div>
|
||||
<p class="mt-1 text-xs leading-5 text-gray-600 dark:text-gray-300">
|
||||
{{ $step['description'] }}
|
||||
</p>
|
||||
</div>
|
||||
<div class="shrink-0 text-right">
|
||||
<x-filament::badge :color="$step['color']" size="sm" class="max-w-full whitespace-normal text-left">
|
||||
{{ $step['label'] }}
|
||||
</x-filament::badge>
|
||||
@if ($step['is_current'])
|
||||
<div class="mt-1 text-xs font-medium text-gray-500 dark:text-gray-400">
|
||||
{{ __('localization.review.current_attention_point') }}
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
</div>
|
||||
</li>
|
||||
@endforeach
|
||||
</ul>
|
||||
</details>
|
||||
|
||||
<div class="rounded-xl border border-gray-200 bg-white p-4 shadow-sm dark:border-white/10 dark:bg-gray-900">
|
||||
<div class="flex flex-col gap-3">
|
||||
<div>
|
||||
@ -485,6 +463,71 @@ class="py-3 first:pt-0 last:pb-0"
|
||||
|
||||
{{ $this->table }}
|
||||
</div>
|
||||
|
||||
<details
|
||||
class="group rounded-xl border border-dashed border-gray-200 bg-gray-50/60 p-4 dark:border-white/10 dark:bg-white/5"
|
||||
data-testid="customer-review-readiness-flow"
|
||||
>
|
||||
<summary class="-m-2 cursor-pointer list-none rounded-lg p-2 marker:text-gray-400 transition hover:bg-gray-100/80 focus:outline-none focus:ring-2 focus:ring-primary-500 focus:ring-offset-2 focus:ring-offset-white dark:hover:bg-white/5 dark:focus:ring-offset-gray-900">
|
||||
<div class="flex items-start justify-between gap-3">
|
||||
<div>
|
||||
<div class="text-[0.7rem] font-semibold uppercase leading-4 tracking-wide text-gray-500 dark:text-gray-400">
|
||||
{{ __('localization.review.supporting_reference') }}
|
||||
</div>
|
||||
<h2 class="mt-1 text-sm font-semibold text-gray-950 dark:text-white">
|
||||
{{ __('localization.review.review_consumption_flow') }}
|
||||
</h2>
|
||||
<p class="mt-1 text-xs leading-5 text-gray-600 dark:text-gray-300">
|
||||
{{ __('localization.review.review_consumption_flow_description') }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<x-filament::icon
|
||||
icon="heroicon-m-chevron-down"
|
||||
class="mt-0.5 h-5 w-5 text-gray-400 transition-transform duration-150 group-open:rotate-180 dark:text-gray-500"
|
||||
/>
|
||||
</div>
|
||||
</summary>
|
||||
|
||||
@if ($currentReadinessStep !== null)
|
||||
<div class="mt-4 flex flex-wrap items-center gap-2 rounded-lg border border-gray-200 bg-white px-3 py-2 text-xs dark:border-white/10 dark:bg-gray-900">
|
||||
<span class="font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">
|
||||
{{ __('localization.review.current_attention_point') }}
|
||||
</span>
|
||||
<x-filament::badge :color="$currentReadinessStep['color']" size="sm" class="max-w-full whitespace-normal text-left">
|
||||
{{ $currentReadinessStep['label'] }}
|
||||
</x-filament::badge>
|
||||
</div>
|
||||
@endif
|
||||
|
||||
<ul class="mt-3 divide-y divide-gray-100 text-sm dark:divide-white/10">
|
||||
@foreach ($readinessFlow as $step)
|
||||
<li
|
||||
class="py-3 first:pt-0 last:pb-0"
|
||||
data-testid="customer-review-readiness-step"
|
||||
data-step-label="{{ $step['title'] }}"
|
||||
data-step-state="{{ $step['label'] }}"
|
||||
data-step-current="{{ $step['is_current'] ? 'true' : 'false' }}"
|
||||
>
|
||||
<div class="flex flex-wrap items-start justify-between gap-3">
|
||||
<div class="min-w-0">
|
||||
<div class="font-medium text-gray-950 dark:text-white">
|
||||
{{ $step['title'] }}
|
||||
</div>
|
||||
<p class="mt-1 text-xs leading-5 text-gray-600 dark:text-gray-300">
|
||||
{{ $step['description'] }}
|
||||
</p>
|
||||
</div>
|
||||
<div class="shrink-0 text-right">
|
||||
<x-filament::badge :color="$step['color']" size="sm" class="max-w-full whitespace-normal text-left">
|
||||
{{ $step['label'] }}
|
||||
</x-filament::badge>
|
||||
</div>
|
||||
</div>
|
||||
</li>
|
||||
@endforeach
|
||||
</ul>
|
||||
</details>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
@ -547,25 +590,46 @@ class="rounded-xl border border-gray-200 bg-white p-3 shadow-sm dark:border-whit
|
||||
{{ $reviewPackPanel['description'] }}
|
||||
</p>
|
||||
|
||||
<dl class="mt-2 space-y-1.5 text-xs">
|
||||
@foreach ($reviewPackPanel['detail_rows'] as $row)
|
||||
@php
|
||||
$valueToneClass = $reviewPackValueToneClasses[$row['color']] ?? $reviewPackValueToneClasses['gray'];
|
||||
@endphp
|
||||
<div class="mt-3 space-y-3">
|
||||
@foreach ($reviewPackPanel['sections'] as $section)
|
||||
<section class="rounded-lg border border-gray-200 bg-gray-50/80 p-3 dark:border-white/10 dark:bg-white/5">
|
||||
<div class="flex flex-wrap items-start justify-between gap-2">
|
||||
<div class="min-w-0">
|
||||
<h3 class="text-xs font-semibold uppercase tracking-wide text-gray-700 dark:text-gray-200">
|
||||
{{ $section['title'] }}
|
||||
</h3>
|
||||
<p class="mt-1 text-xs leading-5 text-gray-500 dark:text-gray-400">
|
||||
{{ $section['description'] }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-[minmax(0,7rem)_minmax(0,1fr)] items-start gap-2">
|
||||
<dt class="min-w-0 text-gray-500 dark:text-gray-400">{{ $row['label'] }}</dt>
|
||||
<dd class="flex min-w-0 justify-end text-right">
|
||||
<span @class([
|
||||
'inline-flex max-w-full rounded-md border px-2 py-0.5 text-left text-xs font-medium leading-5 whitespace-normal break-words',
|
||||
$valueToneClass,
|
||||
])>
|
||||
{{ $row['value'] }}
|
||||
</span>
|
||||
</dd>
|
||||
</div>
|
||||
<x-filament::badge :color="$section['color']" size="sm" class="max-w-full whitespace-normal text-left">
|
||||
{{ $section['label'] }}
|
||||
</x-filament::badge>
|
||||
</div>
|
||||
|
||||
<dl class="mt-3 space-y-1.5 text-xs">
|
||||
@foreach ($section['rows'] as $row)
|
||||
@php
|
||||
$valueToneClass = $reviewPackValueToneClasses[$row['color']] ?? $reviewPackValueToneClasses['gray'];
|
||||
@endphp
|
||||
|
||||
<div class="grid grid-cols-[minmax(0,7rem)_minmax(0,1fr)] items-start gap-2">
|
||||
<dt class="min-w-0 text-gray-500 dark:text-gray-400">{{ $row['label'] }}</dt>
|
||||
<dd class="flex min-w-0 justify-end text-right">
|
||||
<span @class([
|
||||
'inline-flex max-w-full rounded-md border px-2 py-0.5 text-left text-xs font-medium leading-5 whitespace-normal break-words',
|
||||
$valueToneClass,
|
||||
])>
|
||||
{{ $row['value'] }}
|
||||
</span>
|
||||
</dd>
|
||||
</div>
|
||||
@endforeach
|
||||
</dl>
|
||||
</section>
|
||||
@endforeach
|
||||
</dl>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
|
||||
@ -5,9 +5,9 @@
|
||||
@endphp
|
||||
|
||||
@if ($tenants->isEmpty())
|
||||
{{-- Empty state — enterprise-grade --}}
|
||||
{{-- Empty state --}}
|
||||
<div class="mx-auto max-w-md">
|
||||
<div class="rounded-xl border border-gray-200 bg-white p-8 text-center shadow-sm dark:border-white/10 dark:bg-white/5">
|
||||
<div class="rounded-lg border border-gray-200 bg-white p-8 text-center shadow-sm dark:border-white/10 dark:bg-white/5">
|
||||
{{-- Workspace context badge --}}
|
||||
<div class="mb-5 inline-flex items-center gap-1.5 rounded-full border border-gray-200 bg-gray-50 px-3 py-1 text-xs font-medium text-gray-600 dark:border-white/10 dark:bg-white/5 dark:text-gray-400">
|
||||
<x-filament::icon icon="heroicon-m-building-office-2" class="h-3.5 w-3.5" />
|
||||
@ -47,88 +47,104 @@ class="inline-flex items-center gap-1.5 text-sm text-gray-500 transition-colors
|
||||
</div>
|
||||
@else
|
||||
{{-- ManagedEnvironment list --}}
|
||||
<div class="mx-auto max-w-3xl">
|
||||
<div class="mx-auto w-full max-w-5xl">
|
||||
{{-- Header row --}}
|
||||
<div class="mb-6 flex items-center justify-between">
|
||||
<div class="flex items-center gap-2">
|
||||
<div class="mb-6 flex flex-col gap-4 sm:flex-row sm:items-end sm:justify-between">
|
||||
<div class="flex min-w-0 flex-wrap items-center gap-2">
|
||||
<div class="inline-flex items-center gap-1.5 rounded-full border border-gray-200 bg-gray-50 px-3 py-1 text-xs font-medium text-gray-600 dark:border-white/10 dark:bg-white/5 dark:text-gray-400">
|
||||
<x-filament::icon icon="heroicon-m-building-office-2" class="h-3.5 w-3.5" />
|
||||
{{ $this->workspace->name }}
|
||||
<span class="max-w-64 truncate sm:max-w-sm">{{ $this->workspace->name }}</span>
|
||||
</div>
|
||||
<span class="text-sm text-gray-500 dark:text-gray-400">
|
||||
· {{ trans_choice('localization.shell.environment_count', $environmentCount, ['count' => $environmentCount]) }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<x-filament::button
|
||||
tag="a"
|
||||
href="{{ route('admin.onboarding') }}"
|
||||
icon="heroicon-m-plus"
|
||||
color="gray"
|
||||
size="sm"
|
||||
>
|
||||
{{ __('localization.shell.add_environment') }}
|
||||
</x-filament::button>
|
||||
|
||||
<x-filament::button
|
||||
tag="a"
|
||||
href="{{ route('filament.admin.pages.choose-workspace') }}"
|
||||
icon="heroicon-m-arrows-right-left"
|
||||
color="gray"
|
||||
outlined
|
||||
size="sm"
|
||||
>
|
||||
Switch workspace
|
||||
</x-filament::button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{-- ManagedEnvironment cards --}}
|
||||
<div class="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-{{ min($tenants->count(), 3) }}">
|
||||
<ul role="list" class="grid grid-cols-1 gap-3 xl:grid-cols-2">
|
||||
@foreach ($tenants as $tenant)
|
||||
@php
|
||||
$statusSpec = \App\Support\Badges\BadgeCatalog::spec(\App\Support\Badges\BadgeDomain::TenantStatus, $tenant->status);
|
||||
@endphp
|
||||
<button
|
||||
type="button"
|
||||
wire:key="tenant-{{ $tenant->id }}"
|
||||
wire:click="openTenant({{ (int) $tenant->id }})"
|
||||
class="group relative flex flex-col rounded-xl border border-gray-200 bg-white p-5 text-left shadow-sm transition-all duration-150 hover:border-gray-300 hover:shadow-md focus:outline-none focus:ring-2 focus:ring-primary-500 focus:ring-offset-2 dark:border-white/10 dark:bg-white/5 dark:hover:border-white/20 dark:focus:ring-offset-gray-900"
|
||||
>
|
||||
{{-- Loading overlay --}}
|
||||
<div wire:loading wire:target="openTenant({{ (int) $tenant->id }})"
|
||||
class="absolute inset-0 z-10 flex items-center justify-center rounded-xl bg-white/80 dark:bg-gray-900/80">
|
||||
<x-filament::loading-indicator class="h-5 w-5 text-primary-500" />
|
||||
</div>
|
||||
|
||||
<div class="flex items-start gap-3">
|
||||
<div class="flex h-10 w-10 shrink-0 items-center justify-center rounded-lg bg-gray-100 group-hover:bg-gray-200 dark:bg-white/10 dark:group-hover:bg-white/15">
|
||||
<x-filament::icon
|
||||
icon="heroicon-o-server-stack"
|
||||
class="h-5 w-5 text-gray-500 group-hover:text-gray-600 dark:text-gray-400 dark:group-hover:text-gray-300"
|
||||
/>
|
||||
<li wire:key="tenant-{{ $tenant->id }}" class="min-w-0">
|
||||
<button
|
||||
type="button"
|
||||
wire:click="openTenant({{ (int) $tenant->id }})"
|
||||
aria-label="Open environment {{ $tenant->name }}"
|
||||
title="{{ $tenant->name }}"
|
||||
class="group relative flex w-full min-w-0 rounded-lg border border-gray-200 bg-white p-4 text-left shadow-sm transition-all duration-150 hover:border-gray-300 hover:shadow-md focus:outline-none focus:ring-2 focus:ring-primary-500 focus:ring-offset-2 dark:border-white/10 dark:bg-white/5 dark:hover:border-white/20 dark:focus:ring-offset-gray-900"
|
||||
>
|
||||
{{-- Loading overlay --}}
|
||||
<div wire:loading wire:target="openTenant({{ (int) $tenant->id }})"
|
||||
class="absolute inset-0 z-10 flex items-center justify-center rounded-lg bg-white/80 dark:bg-gray-900/80">
|
||||
<x-filament::loading-indicator class="h-5 w-5 text-primary-500" />
|
||||
</div>
|
||||
<div class="min-w-0 flex-1">
|
||||
<div class="flex items-start justify-between gap-3">
|
||||
<div class="min-w-0">
|
||||
<h3 class="truncate text-sm font-semibold text-gray-900 dark:text-white">
|
||||
|
||||
<div class="flex min-w-0 flex-1 items-start gap-3">
|
||||
<div class="flex h-10 w-10 shrink-0 items-center justify-center rounded-lg bg-gray-100 transition-colors group-hover:bg-gray-200 dark:bg-white/10 dark:group-hover:bg-white/15">
|
||||
<x-filament::icon
|
||||
icon="heroicon-o-server-stack"
|
||||
class="h-5 w-5 text-gray-500 group-hover:text-gray-600 dark:text-gray-400 dark:group-hover:text-gray-300"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="min-w-0 flex-1">
|
||||
<div class="flex min-w-0 flex-col gap-2 sm:flex-row sm:items-start sm:justify-between">
|
||||
<h3 class="min-w-0 text-sm font-semibold leading-5 text-gray-900 [overflow-wrap:anywhere] dark:text-white">
|
||||
{{ $tenant->name }}
|
||||
</h3>
|
||||
<p class="mt-0.5 truncate text-xs text-gray-500 dark:text-gray-400">
|
||||
{{ $tenant->external_id ?? 'No external ID' }}
|
||||
</p>
|
||||
|
||||
<div class="shrink-0">
|
||||
<x-filament::badge :color="$statusSpec->color" :icon="$statusSpec->icon" size="sm">
|
||||
{{ $statusSpec->label }}
|
||||
</x-filament::badge>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<x-filament::badge :color="$statusSpec->color" :icon="$statusSpec->icon" size="sm">
|
||||
{{ $statusSpec->label }}
|
||||
</x-filament::badge>
|
||||
<div class="mt-1.5 flex min-w-0 flex-wrap items-center gap-2">
|
||||
<span class="inline-flex max-w-full items-center font-mono text-[11px] leading-4 text-gray-500 dark:text-gray-400">
|
||||
<span class="min-w-0 break-all">
|
||||
{{ $tenant->external_id ?? 'No external ID' }}
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{-- Hover arrow --}}
|
||||
<div class="absolute right-4 top-5 opacity-0 transition-opacity group-hover:opacity-100">
|
||||
<x-filament::icon
|
||||
icon="heroicon-m-arrow-right"
|
||||
class="h-4 w-4 text-gray-400 dark:text-gray-500"
|
||||
/>
|
||||
</div>
|
||||
</button>
|
||||
<div class="ml-3 hidden shrink-0 items-center self-center text-gray-400 transition-colors group-hover:text-gray-600 sm:flex dark:text-gray-500 dark:group-hover:text-gray-300">
|
||||
<x-filament::icon
|
||||
icon="heroicon-m-arrow-right"
|
||||
class="h-4 w-4"
|
||||
/>
|
||||
</div>
|
||||
</button>
|
||||
</li>
|
||||
@endforeach
|
||||
</div>
|
||||
|
||||
{{-- Footer links --}}
|
||||
<div class="mt-6 flex items-center justify-center gap-6">
|
||||
<a href="{{ route('admin.onboarding') }}"
|
||||
class="inline-flex items-center gap-1.5 text-sm text-gray-500 transition-colors hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200">
|
||||
<x-filament::icon icon="heroicon-m-plus" class="h-4 w-4" />
|
||||
{{ __('localization.shell.add_environment') }}
|
||||
</a>
|
||||
<a href="{{ route('filament.admin.pages.choose-workspace') }}"
|
||||
class="inline-flex items-center gap-1.5 text-sm text-gray-500 transition-colors hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200">
|
||||
<x-filament::icon icon="heroicon-m-arrows-right-left" class="h-4 w-4" />
|
||||
Switch workspace
|
||||
</a>
|
||||
</div>
|
||||
</ul>
|
||||
</div>
|
||||
@endif
|
||||
</x-filament-panels::page>
|
||||
|
||||
@ -85,9 +85,10 @@
|
||||
$page = visit(CustomerReviewWorkspace::environmentFilterUrl($blockedEnvironment))
|
||||
->resize(1236, 900)
|
||||
->waitForText('Output not customer-ready')
|
||||
->assertSee('Create next review')
|
||||
->assertSee('Inspect review blockers')
|
||||
->assertSee('Download review pack with limitations')
|
||||
->assertSee('The primary action opens the review detail with blockers, evidence status, and next steps.')
|
||||
->assertSee('Create the next review cycle from the latest eligible evidence basis.')
|
||||
->assertScript('Array.from(document.querySelectorAll("[data-testid=\"customer-review-decision-card\"] [data-testid=\"customer-review-secondary-action\"]")).some((element) => element.innerText.includes("Open review")) === false', true)
|
||||
->assertSee('Requires review')
|
||||
->assertScript('document.querySelector("[data-testid=\"customer-review-output-limitations\"]")?.open === false', true)
|
||||
|
||||
@ -56,10 +56,12 @@
|
||||
->resize(1236, 900)
|
||||
->waitForText('What is the current review pack output state?')
|
||||
->assertSee('Output not customer-ready')
|
||||
->assertSee('Inspect review blockers')
|
||||
->assertSee('Create next review')
|
||||
->assertSee('Evidence basis incomplete')
|
||||
->assertSee('Technical details')
|
||||
->assertScript('document.querySelector("[data-testid=\"customer-review-primary-action\"]")?.getAttribute("href")', $detailUrl)
|
||||
->click('[data-testid="customer-review-primary-action"]')
|
||||
->waitForText('Create next review?')
|
||||
->assertSee('Create next review?')
|
||||
->assertNoJavaScriptErrors()
|
||||
->assertNoConsoleLogs();
|
||||
$page->screenshot(true, spec350BrowserScreenshotName('01-workspace-blocked'));
|
||||
|
||||
@ -0,0 +1,290 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Filament\Pages\Reviews\CustomerReviewWorkspace;
|
||||
use App\Filament\Resources\EnvironmentReviewResource;
|
||||
use App\Models\EnvironmentReview;
|
||||
use App\Models\EvidenceSnapshot;
|
||||
use App\Models\ManagedEnvironment;
|
||||
use App\Models\ReviewPack;
|
||||
use App\Models\User;
|
||||
use App\Support\EnvironmentReviewCompletenessState;
|
||||
use App\Support\EnvironmentReviewStatus;
|
||||
use App\Support\Governance\Controls\ComplianceEvidenceMappingV1;
|
||||
use App\Support\Workspaces\WorkspaceContext;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
pest()->browser()->timeout(60_000);
|
||||
|
||||
beforeEach(function (): void {
|
||||
Storage::fake('exports');
|
||||
});
|
||||
|
||||
it('Spec351 smokes blocked workspace execution guidance and ready detail execution guidance', function (): void {
|
||||
[$user, $publishedBlockedEnvironment] = createUserWithTenant(role: 'owner', workspaceRole: 'manager');
|
||||
$publishedBlockedEnvironment->forceFill(['name' => 'Spec351 Browser Published Blocked'])->save();
|
||||
$mutableBlockedEnvironment = spec351BrowserEnvironmentFor($user, $publishedBlockedEnvironment, 'Spec351 Browser Mutable Blocked');
|
||||
$readyDraftEnvironment = spec351BrowserEnvironmentFor($user, $publishedBlockedEnvironment, 'Spec351 Browser Ready Draft');
|
||||
$fallbackEnvironment = spec351BrowserEnvironmentFor($user, $publishedBlockedEnvironment, 'Spec351 Browser Fallback');
|
||||
[$readonlyUser] = createUserWithTenant(
|
||||
tenant: $fallbackEnvironment,
|
||||
user: User::factory()->create(),
|
||||
role: 'readonly',
|
||||
workspaceRole: 'readonly',
|
||||
);
|
||||
|
||||
[$publishedBlockedReview] = spec351BrowserCreatePublishedReviewWithPack(
|
||||
$publishedBlockedEnvironment,
|
||||
$user,
|
||||
seedPartialEnvironmentReviewEvidence($publishedBlockedEnvironment, findingCount: 0, driftCount: 0),
|
||||
[
|
||||
'publish_blockers' => ['Operator approval note is still missing.'],
|
||||
],
|
||||
[
|
||||
'include_pii' => false,
|
||||
'include_operations' => true,
|
||||
],
|
||||
'review-packs/spec351-browser-blocked.zip',
|
||||
);
|
||||
|
||||
$mutableBlockedReview = composeEnvironmentReviewForTest(
|
||||
$mutableBlockedEnvironment,
|
||||
$user,
|
||||
seedPartialEnvironmentReviewEvidence($mutableBlockedEnvironment, findingCount: 0, driftCount: 0),
|
||||
);
|
||||
$mutableBlockedReview->forceFill([
|
||||
'status' => EnvironmentReviewStatus::Draft->value,
|
||||
'published_at' => null,
|
||||
'published_by_user_id' => null,
|
||||
])->save();
|
||||
|
||||
$readyReview = markEnvironmentReviewCustomerSafeReady(composeEnvironmentReviewForTest(
|
||||
$readyDraftEnvironment,
|
||||
$user,
|
||||
seedEnvironmentReviewEvidence($readyDraftEnvironment, findingCount: 0, driftCount: 0),
|
||||
));
|
||||
$readyReview->forceFill([
|
||||
'status' => EnvironmentReviewStatus::Ready->value,
|
||||
'published_at' => null,
|
||||
'published_by_user_id' => null,
|
||||
])->save();
|
||||
|
||||
spec351BrowserCreatePublishedReviewWithPack(
|
||||
$fallbackEnvironment,
|
||||
$user,
|
||||
seedPartialEnvironmentReviewEvidence($fallbackEnvironment, findingCount: 0, driftCount: 0),
|
||||
[
|
||||
'publish_blockers' => ['Operator approval note is still missing.'],
|
||||
],
|
||||
[
|
||||
'include_pii' => false,
|
||||
'include_operations' => true,
|
||||
],
|
||||
'review-packs/spec351-browser-fallback.zip',
|
||||
);
|
||||
|
||||
spec351AuthenticateBrowser($this, $user, $publishedBlockedEnvironment);
|
||||
|
||||
$workspacePage = visit(CustomerReviewWorkspace::environmentFilterUrl($publishedBlockedEnvironment))
|
||||
->resize(1236, 900)
|
||||
->waitForText('Output not customer-ready')
|
||||
->assertSee('Create next review')
|
||||
->assertSee('Supporting actions')
|
||||
->assertSee('Package exists')
|
||||
->assertSee('Customer sharing')
|
||||
->click('[data-testid="customer-review-primary-action"]')
|
||||
->waitForText('Create next review?')
|
||||
->assertSee('Create next review?')
|
||||
->click('button[wire\\:target="callMountedAction"]')
|
||||
->waitForText('Refresh review')
|
||||
->assertSee('Refresh review')
|
||||
->assertNoJavaScriptErrors()
|
||||
->assertNoConsoleLogs();
|
||||
$workspacePage->screenshot(true, spec351BrowserScreenshotName('01-published-blocked'));
|
||||
spec351CopyBrowserScreenshot('01-published-blocked');
|
||||
|
||||
$returnWorkspacePage = visit(CustomerReviewWorkspace::environmentFilterUrl($publishedBlockedEnvironment))
|
||||
->waitForText('Draft review exists')
|
||||
->assertSee('Open draft review')
|
||||
->assertDontSee('No released customer reviews match the active environment filter.')
|
||||
->assertNoJavaScriptErrors()
|
||||
->assertNoConsoleLogs();
|
||||
$returnWorkspacePage->screenshot(true, spec351BrowserScreenshotName('01b-workspace-successor'));
|
||||
spec351CopyBrowserScreenshot('01b-workspace-successor');
|
||||
|
||||
$mutableBlockedPage = visit(EnvironmentReviewResource::environmentScopedUrl('view', ['record' => $mutableBlockedReview], $mutableBlockedEnvironment))
|
||||
->waitForText('Refresh review')
|
||||
->click('Refresh review')
|
||||
->waitForText('Refresh review')
|
||||
->assertSee('Refresh this environment review from the latest eligible evidence basis.')
|
||||
->assertNoJavaScriptErrors()
|
||||
->assertNoConsoleLogs();
|
||||
$mutableBlockedPage->screenshot(true, spec351BrowserScreenshotName('02-mutable-blocked'));
|
||||
spec351CopyBrowserScreenshot('02-mutable-blocked');
|
||||
|
||||
$detailPage = visit(EnvironmentReviewResource::environmentScopedUrl('view', ['record' => $readyReview], $readyDraftEnvironment))
|
||||
->waitForText('Publish review')
|
||||
->click('Publish review')
|
||||
->waitForText('Publication reason')
|
||||
->assertSee('Publication reason')
|
||||
->assertNoJavaScriptErrors()
|
||||
->assertNoConsoleLogs();
|
||||
$detailPage->screenshot(true, spec351BrowserScreenshotName('03-ready-draft'));
|
||||
spec351CopyBrowserScreenshot('03-ready-draft');
|
||||
|
||||
spec351AuthenticateBrowser($this, $readonlyUser, $fallbackEnvironment);
|
||||
|
||||
$fallbackPage = visit(CustomerReviewWorkspace::environmentFilterUrl($fallbackEnvironment))
|
||||
->waitForText('Output not customer-ready')
|
||||
->assertSee('Inspect review blockers')
|
||||
->assertDontSee('Create next review')
|
||||
->assertNoJavaScriptErrors()
|
||||
->assertNoConsoleLogs();
|
||||
$fallbackPage->screenshot(true, spec351BrowserScreenshotName('04-fallback'));
|
||||
spec351CopyBrowserScreenshot('04-fallback');
|
||||
|
||||
expect($publishedBlockedReview)->toBeInstanceOf(EnvironmentReview::class)
|
||||
->and($mutableBlockedReview)->toBeInstanceOf(EnvironmentReview::class)
|
||||
->and($readyReview)->toBeInstanceOf(EnvironmentReview::class);
|
||||
});
|
||||
|
||||
function spec351BrowserScreenshotName(string $name): string
|
||||
{
|
||||
return 'spec351-review-output-resolve-actions-'.$name;
|
||||
}
|
||||
|
||||
function spec351CopyBrowserScreenshot(string $name): void
|
||||
{
|
||||
$filename = spec351BrowserScreenshotName($name).'.png';
|
||||
$primarySource = base_path('tests/Browser/Screenshots/'.$filename);
|
||||
$fallbackSource = \Pest\Browser\Support\Screenshot::path($filename);
|
||||
$targetDirectory = repo_path('specs/351-review-output-resolve-actions-v1/artifacts/screenshots');
|
||||
|
||||
if (! is_dir($targetDirectory)) {
|
||||
@mkdir($targetDirectory, 0755, true);
|
||||
}
|
||||
|
||||
$source = null;
|
||||
|
||||
for ($attempt = 0; $attempt < 50 && $source === null; $attempt++) {
|
||||
foreach ([$primarySource, $fallbackSource] as $candidate) {
|
||||
if (is_file($candidate)) {
|
||||
$source = $candidate;
|
||||
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if ($source !== null) {
|
||||
break;
|
||||
}
|
||||
|
||||
usleep(100_000);
|
||||
clearstatcache(true, $primarySource);
|
||||
clearstatcache(true, $fallbackSource);
|
||||
}
|
||||
|
||||
if (is_string($source) && is_file($source) && is_dir($targetDirectory) && is_writable($targetDirectory)) {
|
||||
@copy($source, $targetDirectory.DIRECTORY_SEPARATOR.$name.'.png');
|
||||
}
|
||||
}
|
||||
|
||||
function spec351AuthenticateBrowser(mixed $test, User $user, ManagedEnvironment $environment): void
|
||||
{
|
||||
$workspaceId = (int) $environment->workspace_id;
|
||||
|
||||
$test->actingAs($user)->withSession([
|
||||
WorkspaceContext::SESSION_KEY => $workspaceId,
|
||||
WorkspaceContext::LAST_ENVIRONMENT_IDS_SESSION_KEY => [
|
||||
(string) $workspaceId => (int) $environment->getKey(),
|
||||
],
|
||||
]);
|
||||
|
||||
session()->put(WorkspaceContext::SESSION_KEY, $workspaceId);
|
||||
session()->put(WorkspaceContext::LAST_ENVIRONMENT_IDS_SESSION_KEY, [
|
||||
(string) $workspaceId => (int) $environment->getKey(),
|
||||
]);
|
||||
|
||||
setAdminPanelContext($environment);
|
||||
}
|
||||
|
||||
function spec351BrowserEnvironmentFor(User $user, ManagedEnvironment $baseEnvironment, string $name): ManagedEnvironment
|
||||
{
|
||||
$environment = ManagedEnvironment::factory()->active()->create([
|
||||
'workspace_id' => (int) $baseEnvironment->workspace_id,
|
||||
'name' => $name,
|
||||
]);
|
||||
|
||||
createUserWithTenant(tenant: $environment, user: $user, role: 'owner', workspaceRole: 'manager');
|
||||
|
||||
return $environment;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $summaryOverrides
|
||||
* @param array<string, mixed> $packOptions
|
||||
* @return array{0: EnvironmentReview, 1: ReviewPack}
|
||||
*/
|
||||
function spec351BrowserCreatePublishedReviewWithPack(
|
||||
ManagedEnvironment $environment,
|
||||
User $user,
|
||||
EvidenceSnapshot $snapshot,
|
||||
array $summaryOverrides = [],
|
||||
array $packOptions = [],
|
||||
string $filePath = 'review-packs/spec351-browser-review-pack.zip',
|
||||
): array {
|
||||
$review = composeEnvironmentReviewForTest($environment, $user, $snapshot);
|
||||
$summary = array_replace_recursive(
|
||||
is_array($review->summary) ? $review->summary : [],
|
||||
[
|
||||
'control_interpretation' => [
|
||||
'version_key' => ComplianceEvidenceMappingV1::VERSION_KEY,
|
||||
'controls' => [
|
||||
[
|
||||
'control_key' => 'customer-output',
|
||||
'title' => 'Customer output',
|
||||
'readiness_bucket' => 'review_recommended',
|
||||
'readiness_label' => 'Review recommended',
|
||||
'primary_reason' => 'Evidence basis needs review.',
|
||||
'recommended_next_action' => 'Create the next review cycle before customer sharing.',
|
||||
],
|
||||
],
|
||||
],
|
||||
],
|
||||
$summaryOverrides,
|
||||
);
|
||||
|
||||
Storage::disk('exports')->put($filePath, 'PK-spec351-browser-test');
|
||||
|
||||
$review->forceFill([
|
||||
'status' => EnvironmentReviewStatus::Published->value,
|
||||
'completeness_state' => (string) $review->completeness_state,
|
||||
'summary' => $summary,
|
||||
'generated_at' => now()->subMinutes(5),
|
||||
'published_at' => now()->subMinutes(3),
|
||||
'published_by_user_id' => (int) $user->getKey(),
|
||||
])->save();
|
||||
|
||||
$pack = ReviewPack::factory()->ready()->create([
|
||||
'managed_environment_id' => (int) $environment->getKey(),
|
||||
'workspace_id' => (int) $environment->workspace_id,
|
||||
'environment_review_id' => (int) $review->getKey(),
|
||||
'evidence_snapshot_id' => (int) $snapshot->getKey(),
|
||||
'initiated_by_user_id' => (int) $user->getKey(),
|
||||
'options' => array_replace([
|
||||
'include_pii' => false,
|
||||
'include_operations' => true,
|
||||
], $packOptions),
|
||||
'file_path' => $filePath,
|
||||
'file_disk' => 'exports',
|
||||
'generated_at' => now()->subMinutes(4),
|
||||
]);
|
||||
|
||||
$review->forceFill(['current_export_review_pack_id' => (int) $pack->getKey()])->save();
|
||||
|
||||
return [$review->fresh(['tenant', 'evidenceSnapshot', 'currentExportReviewPack.operationRun', 'operationRun']), $pack];
|
||||
}
|
||||
@ -0,0 +1,112 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Filament\Pages\Reviews\CustomerReviewWorkspace;
|
||||
use App\Filament\Resources\EnvironmentReviewResource;
|
||||
use App\Filament\Resources\EnvironmentReviewResource\Pages\ViewEnvironmentReview;
|
||||
use App\Models\EnvironmentReview;
|
||||
use App\Models\ManagedEnvironment;
|
||||
use App\Models\ReviewPack;
|
||||
use App\Models\User;
|
||||
use App\Models\Workspace;
|
||||
use App\Support\EnvironmentReviewStatus;
|
||||
use App\Support\Workspaces\WorkspaceContext;
|
||||
use Filament\Actions\Action;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
use Livewire\Livewire;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
beforeEach(function (): void {
|
||||
Storage::fake('exports');
|
||||
});
|
||||
|
||||
it('seeds a deterministic published-to-ready browser fixture for review output', function (): void {
|
||||
$this->artisan('tenantpilot:review-output:seed-browser-fixture', ['--no-interaction' => true])
|
||||
->assertSuccessful()
|
||||
->expectsOutputToContain('Fixture login URL')
|
||||
->expectsOutputToContain('Ready review detail URL')
|
||||
->expectsOutputToContain('Workspace URL');
|
||||
|
||||
$workspaceConfig = config('tenantpilot.review_output.browser_smoke_fixture.workspace');
|
||||
$userConfig = config('tenantpilot.review_output.browser_smoke_fixture.user');
|
||||
$scenarioConfig = config('tenantpilot.review_output.browser_smoke_fixture.ready_draft');
|
||||
|
||||
$workspace = Workspace::query()->where('slug', $workspaceConfig['slug'])->first();
|
||||
$user = User::query()->where('email', $userConfig['email'])->first();
|
||||
$environment = ManagedEnvironment::query()->where('slug', $scenarioConfig['managed_environment_slug'])->first();
|
||||
|
||||
expect($workspace)->not->toBeNull();
|
||||
expect($user)->not->toBeNull();
|
||||
expect($environment)->not->toBeNull();
|
||||
|
||||
$publishedReview = EnvironmentReview::query()
|
||||
->where('managed_environment_id', (int) $environment->getKey())
|
||||
->where('status', EnvironmentReviewStatus::Superseded->value)
|
||||
->latest('id')
|
||||
->first();
|
||||
|
||||
expect($publishedReview)->not->toBeNull()
|
||||
->and($publishedReview?->superseded_by_review_id)->not->toBeNull();
|
||||
|
||||
$readyReview = EnvironmentReview::query()->find($publishedReview->superseded_by_review_id);
|
||||
|
||||
expect($readyReview)->not->toBeNull()
|
||||
->and($readyReview?->status)->toBe(EnvironmentReviewStatus::Ready->value)
|
||||
->and($readyReview?->publishBlockers())->toBe([]);
|
||||
|
||||
$reviewPack = ReviewPack::query()->find($publishedReview->current_export_review_pack_id);
|
||||
|
||||
expect($reviewPack)->not->toBeNull()
|
||||
->and($reviewPack?->status)->toBe(ReviewPack::STATUS_READY)
|
||||
->and($reviewPack?->environment_review_id)->toBe((int) $publishedReview->getKey());
|
||||
|
||||
Storage::disk('exports')->assertExists((string) $reviewPack->file_path);
|
||||
|
||||
$this->actingAs($user)->withSession([
|
||||
WorkspaceContext::SESSION_KEY => (int) $workspace->getKey(),
|
||||
WorkspaceContext::LAST_ENVIRONMENT_IDS_SESSION_KEY => [
|
||||
(string) $workspace->getKey() => (int) $environment->getKey(),
|
||||
],
|
||||
]);
|
||||
|
||||
$this->get(CustomerReviewWorkspace::environmentFilterUrl($environment))
|
||||
->assertSuccessful()
|
||||
->assertSee('Draft review exists')
|
||||
->assertSee('Open draft review')
|
||||
->assertDontSee('No released customer reviews match the active environment filter.');
|
||||
|
||||
$detailUrl = EnvironmentReviewResource::environmentScopedUrl('view', ['record' => $readyReview], $environment);
|
||||
$detailRedirect = tenantpilotReviewOutputFixtureRelativeAdminRedirect($detailUrl);
|
||||
|
||||
$this->get(route('admin.local.smoke-login', [
|
||||
'email' => $user->email,
|
||||
'tenant' => $environment->slug,
|
||||
'workspace' => $workspace->slug,
|
||||
'redirect' => $detailRedirect,
|
||||
]))
|
||||
->assertRedirect($detailRedirect)
|
||||
->assertSessionHas(WorkspaceContext::SESSION_KEY, (int) $workspace->getKey());
|
||||
|
||||
$this->assertAuthenticatedAs($user);
|
||||
|
||||
setAdminEnvironmentContext($environment);
|
||||
|
||||
Livewire::actingAs($user)
|
||||
->test(ViewEnvironmentReview::class, ['record' => $readyReview->getKey()])
|
||||
->assertActionVisible('publish_review')
|
||||
->assertActionExists('publish_review', fn (Action $action): bool => $action->isConfirmationRequired());
|
||||
});
|
||||
|
||||
function tenantpilotReviewOutputFixtureRelativeAdminRedirect(string $url): string
|
||||
{
|
||||
$parsed = parse_url($url);
|
||||
|
||||
$path = '/'.ltrim((string) ($parsed['path'] ?? ''), '/');
|
||||
$query = isset($parsed['query']) ? '?'.$parsed['query'] : '';
|
||||
$fragment = isset($parsed['fragment']) ? '#'.$parsed['fragment'] : '';
|
||||
|
||||
return $path.$query.$fragment;
|
||||
}
|
||||
@ -130,6 +130,12 @@ function environmentReviewContractHeaderActions(Testable $component): array
|
||||
|
||||
$published = app(EnvironmentReviewLifecycleService::class)->publish($review, $owner, 'Ready for publication.');
|
||||
|
||||
Livewire::actingAs($owner)
|
||||
->test(ViewEnvironmentReview::class, ['record' => $published->getKey()])
|
||||
->assertActionExists('create_next_review', fn (Action $action): bool => $action->isConfirmationRequired())
|
||||
->mountAction('create_next_review')
|
||||
->assertActionMounted('create_next_review');
|
||||
|
||||
Livewire::actingAs($owner)
|
||||
->test(ViewEnvironmentReview::class, ['record' => $published->getKey()])
|
||||
->mountAction('archive_review')
|
||||
|
||||
@ -63,7 +63,7 @@
|
||||
->assertSee('Output readiness')
|
||||
->assertSee('Publication/sharing state')
|
||||
->assertSee('Publication blocked')
|
||||
->assertSee('Inspect review blockers')
|
||||
->assertSee('Create next review')
|
||||
->assertSee('Technical details')
|
||||
->assertDontSee('Ready to share');
|
||||
});
|
||||
|
||||
@ -52,11 +52,13 @@
|
||||
$review->forceFill(['current_export_review_pack_id' => (int) $pack->getKey()])->save();
|
||||
|
||||
setAdminEnvironmentContext($tenant);
|
||||
$this->actingAs($owner);
|
||||
|
||||
$state = EnvironmentReviewResource::outputGuidanceState($review->fresh(['tenant', 'evidenceSnapshot', 'currentExportReviewPack.operationRun', 'operationRun']));
|
||||
|
||||
expect(data_get($state, 'resolution_case.key'))->toBe('review_output.publication_blocked')
|
||||
->and(data_get($state, 'resolution_case.primary_action.key'))->toBe('resolve_review_blockers')
|
||||
->and(data_get($state, 'resolution_case.primary_action.key'))->toBe('create_next_review')
|
||||
->and(data_get($state, 'resolution_case.primary_action.action_name'))->toBe('create_next_review')
|
||||
->and(data_get($state, 'resolution_case.source_refs'))->toContainEqual(['type' => 'environment_review', 'id' => (int) $review->getKey()])
|
||||
->and(data_get($state, 'resolution_case.source_refs'))->toContainEqual(['type' => 'review_pack', 'id' => (int) $pack->getKey()])
|
||||
->and(data_get($state, 'resolution_case.evidence_refs'))->toHaveCount(1);
|
||||
@ -65,7 +67,7 @@
|
||||
->get(EnvironmentReviewResource::environmentScopedUrl('view', ['record' => $review], $tenant))
|
||||
->assertOk()
|
||||
->assertSee('Output not customer-ready')
|
||||
->assertSee('Inspect review blockers');
|
||||
->assertSee('Create next review');
|
||||
});
|
||||
|
||||
it('keeps the customer-workspace detail mode action suppression while retaining the shared case payload', function (): void {
|
||||
|
||||
@ -0,0 +1,128 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Filament\Resources\EnvironmentReviewResource;
|
||||
use App\Filament\Resources\EnvironmentReviewResource\Pages\ViewEnvironmentReview;
|
||||
use App\Models\ManagedEnvironment;
|
||||
use App\Models\ReviewPack;
|
||||
use App\Support\Ui\GovernanceActions\GovernanceActionCatalog;
|
||||
use Filament\Notifications\Notification;
|
||||
use App\Support\EnvironmentReviewStatus;
|
||||
use Filament\Actions\Action;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
use Livewire\Livewire;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
beforeEach(function (): void {
|
||||
Storage::fake('exports');
|
||||
});
|
||||
|
||||
it('aligns published blocked detail guidance to the create next review header action without a duplicate card CTA', function (): void {
|
||||
$tenant = ManagedEnvironment::factory()->create(['name' => 'Spec351 Detail Blocked']);
|
||||
[$owner, $tenant] = createUserWithTenant(tenant: $tenant, role: 'owner');
|
||||
$snapshot = seedPartialEnvironmentReviewEvidence($tenant, findingCount: 0, driftCount: 0);
|
||||
$review = composeEnvironmentReviewForTest($tenant, $owner, $snapshot);
|
||||
$review->forceFill([
|
||||
'status' => EnvironmentReviewStatus::Published->value,
|
||||
'published_at' => now(),
|
||||
'published_by_user_id' => (int) $owner->getKey(),
|
||||
'summary' => array_replace_recursive(is_array($review->summary) ? $review->summary : [], [
|
||||
'publish_blockers' => ['Operator approval note is still missing.'],
|
||||
]),
|
||||
])->save();
|
||||
|
||||
Storage::disk('exports')->put('review-packs/spec351-detail-blocked.zip', 'PK-spec351-detail-blocked');
|
||||
|
||||
$pack = ReviewPack::factory()->ready()->create([
|
||||
'managed_environment_id' => (int) $tenant->getKey(),
|
||||
'workspace_id' => (int) $tenant->workspace_id,
|
||||
'environment_review_id' => (int) $review->getKey(),
|
||||
'evidence_snapshot_id' => (int) $snapshot->getKey(),
|
||||
'initiated_by_user_id' => (int) $owner->getKey(),
|
||||
'options' => [
|
||||
'include_pii' => false,
|
||||
'include_operations' => true,
|
||||
],
|
||||
'file_path' => 'review-packs/spec351-detail-blocked.zip',
|
||||
'file_disk' => 'exports',
|
||||
'generated_at' => now()->subMinutes(3),
|
||||
]);
|
||||
|
||||
$review->forceFill(['current_export_review_pack_id' => (int) $pack->getKey()])->save();
|
||||
|
||||
setAdminEnvironmentContext($tenant);
|
||||
$this->actingAs($owner);
|
||||
|
||||
$state = EnvironmentReviewResource::outputGuidanceState($review->fresh(['tenant', 'evidenceSnapshot', 'currentExportReviewPack.operationRun', 'operationRun']));
|
||||
|
||||
expect(data_get($state, 'resolution_case.primary_action.key'))->toBe('create_next_review')
|
||||
->and(data_get($state, 'resolution_case.primary_action.action_name'))->toBe('create_next_review')
|
||||
->and(data_get($state, 'suppress_primary_action_button'))->toBeTrue();
|
||||
|
||||
Livewire::actingAs($owner)
|
||||
->test(ViewEnvironmentReview::class, ['record' => $review->getKey()])
|
||||
->assertActionExists('create_next_review', fn (Action $action): bool => $action->isConfirmationRequired())
|
||||
->mountAction('create_next_review')
|
||||
->assertActionMounted('create_next_review');
|
||||
});
|
||||
|
||||
it('aligns ready mutable detail guidance to publish review', function (): void {
|
||||
$tenant = ManagedEnvironment::factory()->create(['name' => 'Spec351 Detail Ready']);
|
||||
[$owner, $tenant] = createUserWithTenant(tenant: $tenant, role: 'owner');
|
||||
$review = composeEnvironmentReviewForTest($tenant, $owner);
|
||||
$review->forceFill([
|
||||
'status' => EnvironmentReviewStatus::Ready->value,
|
||||
'published_at' => null,
|
||||
'published_by_user_id' => null,
|
||||
])->save();
|
||||
|
||||
setAdminEnvironmentContext($tenant);
|
||||
$this->actingAs($owner);
|
||||
|
||||
$state = EnvironmentReviewResource::outputGuidanceState($review->fresh(['tenant', 'evidenceSnapshot', 'currentExportReviewPack.operationRun', 'operationRun']));
|
||||
|
||||
expect(data_get($state, 'resolution_case.primary_action.key'))->toBe('publish_review')
|
||||
->and(data_get($state, 'resolution_case.primary_action.action_name'))->toBe('publish_review')
|
||||
->and(data_get($state, 'suppress_primary_action_button'))->toBeTrue();
|
||||
|
||||
Livewire::actingAs($owner)
|
||||
->test(ViewEnvironmentReview::class, ['record' => $review->getKey()])
|
||||
->assertActionExists('publish_review', fn (Action $action): bool => $action->isConfirmationRequired())
|
||||
->mountAction('publish_review')
|
||||
->assertActionMounted('publish_review');
|
||||
});
|
||||
|
||||
it('keeps publish review disabled when the draft is still blocked and refresh feedback stays explicit', function (): void {
|
||||
$tenant = ManagedEnvironment::factory()->create(['name' => 'Spec351 Detail Draft Blocked']);
|
||||
[$owner, $tenant] = createUserWithTenant(tenant: $tenant, role: 'owner');
|
||||
$snapshot = seedPartialEnvironmentReviewEvidence($tenant, findingCount: 0, driftCount: 0);
|
||||
$review = composeEnvironmentReviewForTest($tenant, $owner, $snapshot);
|
||||
$review->forceFill([
|
||||
'status' => EnvironmentReviewStatus::Draft->value,
|
||||
'published_at' => null,
|
||||
'published_by_user_id' => null,
|
||||
])->save();
|
||||
|
||||
setAdminEnvironmentContext($tenant);
|
||||
$this->actingAs($owner);
|
||||
|
||||
$state = EnvironmentReviewResource::outputGuidanceState($review->fresh(['tenant', 'sections', 'evidenceSnapshot', 'currentExportReviewPack.operationRun', 'operationRun']));
|
||||
|
||||
expect(data_get($state, 'resolution_case.primary_action.key'))->toBe('refresh_review');
|
||||
|
||||
Livewire::actingAs($owner)
|
||||
->test(ViewEnvironmentReview::class, ['record' => $review->getKey()])
|
||||
->assertActionVisible('refresh_review')
|
||||
->assertActionVisible('publish_review')
|
||||
->assertActionDisabled('publish_review')
|
||||
->callAction('refresh_review')
|
||||
->assertNotified(
|
||||
Notification::make()
|
||||
->success()
|
||||
->title(GovernanceActionCatalog::rule('refresh_review')->successTitle)
|
||||
->body('Review inputs refreshed. Blockers remain.'),
|
||||
);
|
||||
});
|
||||
@ -55,11 +55,12 @@
|
||||
|
||||
$component = spec349WorkspaceComponent($user, $environment)
|
||||
->assertSee('Output not customer-ready')
|
||||
->assertSee('Create next review')
|
||||
->assertSee('Inspect review blockers')
|
||||
->assertSee('Evidence basis incomplete')
|
||||
->assertSee('Required review sections missing')
|
||||
->assertSee('Internal package includes PII')
|
||||
->assertSee('The primary action opens the review detail with blockers, evidence status, and next steps.')
|
||||
->assertSee('Create the next review cycle from the latest eligible evidence basis.')
|
||||
->assertSee('Technical details');
|
||||
|
||||
$html = $component->html();
|
||||
|
||||
@ -57,14 +57,16 @@
|
||||
$component = spec350WorkspaceComponent($user, $environment)
|
||||
->assertSee('What is the current review pack output state?')
|
||||
->assertSee('Output not customer-ready')
|
||||
->assertSee('Inspect review blockers')
|
||||
->assertSee('Review blockers are still recorded for this output.');
|
||||
->assertSee('Create next review')
|
||||
->assertSee('Review blockers are still recorded for this output.')
|
||||
->assertSee('Inspect review blockers');
|
||||
|
||||
$payload = $component->instance()->latestReviewConsumptionPayload();
|
||||
|
||||
expect(data_get($payload, 'readiness.resolution_case.key'))->toBe('review_output.publication_blocked')
|
||||
->and(data_get($payload, 'readiness.resolution_case.scope.source_surface'))->toBe(CustomerReviewWorkspace::SOURCE_SURFACE)
|
||||
->and(data_get($payload, 'readiness.resolution_case.primary_action.key'))->toBe('resolve_review_blockers')
|
||||
->and(data_get($payload, 'readiness.resolution_case.primary_action.key'))->toBe('create_next_review')
|
||||
->and(data_get($payload, 'readiness.resolution_case.primary_action.action_name'))->toBe('createNextReview')
|
||||
->and(data_get($payload, 'readiness.resolution_case.source_refs'))->toContainEqual(['type' => 'environment_review', 'id' => (int) $review->getKey()])
|
||||
->and(data_get($payload, 'readiness.resolution_case.source_refs'))->toContainEqual(['type' => 'review_pack', 'id' => (int) $pack->getKey()])
|
||||
->and(data_get($payload, 'readiness.resolution_case.evidence_refs'))->toHaveCount(1);
|
||||
|
||||
@ -0,0 +1,189 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Filament\Pages\Reviews\CustomerReviewWorkspace;
|
||||
use App\Models\EnvironmentReview;
|
||||
use App\Models\ManagedEnvironment;
|
||||
use App\Models\ReviewPack;
|
||||
use App\Models\User;
|
||||
use App\Services\EnvironmentReviews\EnvironmentReviewLifecycleService;
|
||||
use App\Support\EnvironmentReviewStatus;
|
||||
use App\Support\Workspaces\WorkspaceContext;
|
||||
use Filament\Actions\Action;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
use Livewire\Livewire;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
beforeEach(function (): void {
|
||||
Storage::fake('exports');
|
||||
});
|
||||
|
||||
it('mounts create next review as the dominant workspace CTA for published blocked output', function (): void {
|
||||
$environment = ManagedEnvironment::factory()->create(['name' => 'Spec351 Workspace Blocked']);
|
||||
[$user, $environment] = createUserWithTenant(tenant: $environment, role: 'owner', workspaceRole: 'manager');
|
||||
$snapshot = seedPartialEnvironmentReviewEvidence($environment, findingCount: 0, driftCount: 0);
|
||||
$review = composeEnvironmentReviewForTest($environment, $user, $snapshot);
|
||||
$review->forceFill([
|
||||
'status' => EnvironmentReviewStatus::Published->value,
|
||||
'published_at' => now(),
|
||||
'published_by_user_id' => (int) $user->getKey(),
|
||||
'summary' => array_replace_recursive(is_array($review->summary) ? $review->summary : [], [
|
||||
'publish_blockers' => ['Operator approval note is still missing.'],
|
||||
]),
|
||||
])->save();
|
||||
|
||||
Storage::disk('exports')->put('review-packs/spec351-workspace-blocked.zip', 'PK-spec351-workspace-blocked');
|
||||
|
||||
$pack = ReviewPack::factory()->ready()->create([
|
||||
'managed_environment_id' => (int) $environment->getKey(),
|
||||
'workspace_id' => (int) $environment->workspace_id,
|
||||
'environment_review_id' => (int) $review->getKey(),
|
||||
'evidence_snapshot_id' => (int) $snapshot->getKey(),
|
||||
'initiated_by_user_id' => (int) $user->getKey(),
|
||||
'options' => [
|
||||
'include_pii' => false,
|
||||
'include_operations' => true,
|
||||
],
|
||||
'file_path' => 'review-packs/spec351-workspace-blocked.zip',
|
||||
'file_disk' => 'exports',
|
||||
'generated_at' => now()->subMinutes(3),
|
||||
]);
|
||||
|
||||
$review->forceFill(['current_export_review_pack_id' => (int) $pack->getKey()])->save();
|
||||
|
||||
$component = spec351WorkspaceComponent($user, $environment)
|
||||
->assertSee('Create next review')
|
||||
->assertSee('Inspect review blockers')
|
||||
->assertSee('Supporting actions')
|
||||
->assertSee('Package exists')
|
||||
->assertSee('Internal export')
|
||||
->assertSee('Customer sharing')
|
||||
->assertSee('Customer-ready status is still determined by the output state above.');
|
||||
|
||||
$payload = $component->instance()->latestReviewConsumptionPayload();
|
||||
|
||||
expect(data_get($payload, 'readiness.resolution_case.primary_action.key'))->toBe('create_next_review')
|
||||
->and(data_get($payload, 'readiness.resolution_case.primary_action.action_name'))->toBe('createNextReview')
|
||||
->and(data_get($payload, 'readiness.resolution_case.secondary_actions.0.key'))->toBe('resolve_review_blockers')
|
||||
->and(collect(data_get($payload, 'review_pack_panel.sections', []))->pluck('key')->all())->toBe([
|
||||
'package_exists',
|
||||
'internal_export',
|
||||
'customer_sharing',
|
||||
])
|
||||
->and(data_get($payload, 'acknowledgement.impact'))->toBe('Acknowledgement records review consumption for this published package. Customer-ready status is still determined by the output state above.');
|
||||
|
||||
$component
|
||||
->assertActionExists('createNextReview', fn (Action $action): bool => $action->isConfirmationRequired())
|
||||
->mountAction('createNextReview')
|
||||
->assertActionMounted('createNextReview');
|
||||
});
|
||||
|
||||
it('falls back to review navigation on the workspace when the actor cannot execute the mutation', function (): void {
|
||||
$environment = ManagedEnvironment::factory()->create(['name' => 'Spec351 Workspace Readonly']);
|
||||
[$owner, $environment] = createUserWithTenant(tenant: $environment, role: 'owner', workspaceRole: 'manager');
|
||||
[$readonly] = createUserWithTenant(tenant: $environment, user: User::factory()->create(), role: 'readonly', workspaceRole: 'readonly');
|
||||
$snapshot = seedPartialEnvironmentReviewEvidence($environment, findingCount: 0, driftCount: 0);
|
||||
$review = composeEnvironmentReviewForTest($environment, $owner, $snapshot);
|
||||
$review->forceFill([
|
||||
'status' => EnvironmentReviewStatus::Published->value,
|
||||
'published_at' => now(),
|
||||
'published_by_user_id' => (int) $owner->getKey(),
|
||||
'summary' => array_replace_recursive(is_array($review->summary) ? $review->summary : [], [
|
||||
'publish_blockers' => ['Operator approval note is still missing.'],
|
||||
]),
|
||||
])->save();
|
||||
|
||||
$component = spec351WorkspaceComponent($readonly, $environment)
|
||||
->assertSee('Inspect review blockers');
|
||||
|
||||
$html = $component->html();
|
||||
$dom = new DOMDocument;
|
||||
@$dom->loadHTML($html);
|
||||
$xpath = new DOMXPath($dom);
|
||||
$primaryActionNode = $xpath->query('//*[@data-testid="customer-review-primary-action"]')->item(0);
|
||||
$primaryActionLabel = $primaryActionNode?->textContent !== null
|
||||
? trim($primaryActionNode->textContent)
|
||||
: null;
|
||||
|
||||
expect($primaryActionLabel)->toBe('Inspect review blockers');
|
||||
});
|
||||
|
||||
it('keeps the workspace guidance on an existing draft instead of falling into an empty state', function (): void {
|
||||
$environment = ManagedEnvironment::factory()->create(['name' => 'Spec351 Workspace Existing Draft']);
|
||||
[$user, $environment] = createUserWithTenant(tenant: $environment, role: 'owner', workspaceRole: 'manager');
|
||||
$publishedSnapshot = seedPartialEnvironmentReviewEvidence($environment, findingCount: 0, driftCount: 0);
|
||||
$published = composeEnvironmentReviewForTest($environment, $user, $publishedSnapshot);
|
||||
$published->forceFill([
|
||||
'status' => EnvironmentReviewStatus::Published->value,
|
||||
'published_at' => now()->subDay(),
|
||||
'published_by_user_id' => (int) $user->getKey(),
|
||||
'summary' => array_replace_recursive(is_array($published->summary) ? $published->summary : [], [
|
||||
'publish_blockers' => ['Operator approval note is still missing.'],
|
||||
]),
|
||||
])->save();
|
||||
|
||||
$draft = app(EnvironmentReviewLifecycleService::class)->createNextReview($published->fresh(), $user, $publishedSnapshot);
|
||||
|
||||
expect($published->fresh()->status)->toBe(EnvironmentReviewStatus::Superseded->value)
|
||||
->and($draft->status)->toBe(EnvironmentReviewStatus::Draft->value);
|
||||
|
||||
$component = spec351WorkspaceComponent($user, $environment)
|
||||
->assertDontSee('No released customer reviews match the active environment filter.')
|
||||
->assertSee('Draft review exists')
|
||||
->assertSee('Open draft review')
|
||||
->assertSee('Supporting actions');
|
||||
|
||||
$payload = $component->instance()->latestReviewConsumptionPayload();
|
||||
|
||||
expect(data_get($payload, 'readiness.resolution_case.primary_action.key'))->toBe('open_successor_review')
|
||||
->and(data_get($payload, 'readiness.resolution_case.primary_action.label'))->toBe('Open draft review')
|
||||
->and(data_get($payload, 'readiness.resolution_case.title'))->toBe('Draft review exists')
|
||||
->and(data_get($payload, 'readiness.resolution_case.reason'))->toContain('Open the draft review to refresh inputs');
|
||||
});
|
||||
|
||||
it('recognizes the newly created successor when the operator returns to the workspace', function (): void {
|
||||
$environment = ManagedEnvironment::factory()->create(['name' => 'Spec351 Workspace Return Loop']);
|
||||
[$user, $environment] = createUserWithTenant(tenant: $environment, role: 'owner', workspaceRole: 'manager');
|
||||
$snapshot = seedPartialEnvironmentReviewEvidence($environment, findingCount: 0, driftCount: 0);
|
||||
$review = composeEnvironmentReviewForTest($environment, $user, $snapshot);
|
||||
$review->forceFill([
|
||||
'status' => EnvironmentReviewStatus::Published->value,
|
||||
'published_at' => now(),
|
||||
'published_by_user_id' => (int) $user->getKey(),
|
||||
'summary' => array_replace_recursive(is_array($review->summary) ? $review->summary : [], [
|
||||
'publish_blockers' => ['Operator approval note is still missing.'],
|
||||
]),
|
||||
])->save();
|
||||
|
||||
spec351WorkspaceComponent($user, $environment)
|
||||
->mountAction('createNextReview')
|
||||
->callMountedAction();
|
||||
|
||||
$published = $review->fresh();
|
||||
$successor = EnvironmentReview::query()->findOrFail((int) $published->superseded_by_review_id);
|
||||
|
||||
expect($published->status)->toBe(EnvironmentReviewStatus::Superseded->value)
|
||||
->and($successor->status)->toBe(EnvironmentReviewStatus::Draft->value);
|
||||
|
||||
$component = spec351WorkspaceComponent($user, $environment)
|
||||
->assertDontSee('No released customer reviews match the active environment filter.')
|
||||
->assertSee('Draft review exists')
|
||||
->assertSee('Open draft review');
|
||||
|
||||
$payload = $component->instance()->latestReviewConsumptionPayload();
|
||||
|
||||
expect(data_get($payload, 'readiness.resolution_case.primary_action.url'))->toContain('/environment-reviews/'.$successor->getKey())
|
||||
->and(data_get($payload, 'readiness.output_guidance.action_help'))->toContain('Open the existing draft review');
|
||||
});
|
||||
|
||||
function spec351WorkspaceComponent(User $user, ManagedEnvironment $environment): mixed
|
||||
{
|
||||
session()->put(WorkspaceContext::SESSION_KEY, (int) $environment->workspace_id);
|
||||
setAdminPanelContext();
|
||||
|
||||
return Livewire::actingAs($user)
|
||||
->test(CustomerReviewWorkspace::class);
|
||||
}
|
||||
@ -73,6 +73,48 @@
|
||||
->assertRedirect(EnvironmentDashboard::getUrl(tenant: $tenant));
|
||||
});
|
||||
|
||||
it('keeps the managed-environments landing readable for long identities and larger portfolios', function (): void {
|
||||
$workspace = Workspace::factory()->create([
|
||||
'name' => 'Spec195 Enterprise Workspace With A Long Portfolio Name',
|
||||
'slug' => 'spec195-enterprise-portfolio',
|
||||
]);
|
||||
$user = User::factory()->create();
|
||||
|
||||
WorkspaceMembership::factory()->create([
|
||||
'workspace_id' => (int) $workspace->getKey(),
|
||||
'user_id' => (int) $user->getKey(),
|
||||
'role' => 'owner',
|
||||
]);
|
||||
|
||||
$longName = 'Spec195 Enterprise North America Production Environment With A Very Long Legal Business Unit Name';
|
||||
$longExternalId = 'spec195-enterprise-north-america-production-environment-01-7e76a2da-7a86-4a16-9e36-4069b7d3de10';
|
||||
$environmentIds = [];
|
||||
|
||||
for ($index = 1; $index <= 10; $index++) {
|
||||
$environment = ManagedEnvironment::factory()->active()->create([
|
||||
'workspace_id' => (int) $workspace->getKey(),
|
||||
'name' => $index === 1 ? $longName : "Spec195 Portfolio Environment {$index}",
|
||||
'slug' => $index === 1 ? $longExternalId : "spec195-portfolio-environment-{$index}",
|
||||
]);
|
||||
|
||||
$environmentIds[$environment->getKey()] = ['role' => 'owner'];
|
||||
}
|
||||
|
||||
$user->tenants()->syncWithoutDetaching($environmentIds);
|
||||
|
||||
$this->actingAs($user)
|
||||
->withSession([WorkspaceContext::SESSION_KEY => (int) $workspace->getKey()])
|
||||
->get(route('admin.workspace.managed-environments.index', ['workspace' => $workspace]))
|
||||
->assertSuccessful()
|
||||
->assertSee($longName)
|
||||
->assertSee($longExternalId)
|
||||
->assertSee('grid grid-cols-1 gap-3 xl:grid-cols-2', false)
|
||||
->assertSee('[overflow-wrap:anywhere]', false)
|
||||
->assertSee('break-all', false)
|
||||
->assertSee('role="list"', false)
|
||||
->assertSee('Open environment '.$longName, false);
|
||||
});
|
||||
|
||||
it('allows workspace-scoped access to open an environment from the landing without explicit membership', function (): void {
|
||||
$workspace = Workspace::factory()->create(['slug' => 'spec195-managed-guard']);
|
||||
$user = User::factory()->create();
|
||||
|
||||
@ -43,6 +43,7 @@
|
||||
'id' => 6,
|
||||
'workspace_id' => 1,
|
||||
'managed_environment_id' => 41,
|
||||
'status' => 'published',
|
||||
]);
|
||||
$review->setRelation('evidenceSnapshot', tap(new EvidenceSnapshot, function (EvidenceSnapshot $snapshot): void {
|
||||
$snapshot->forceFill(['id' => 8]);
|
||||
@ -97,6 +98,7 @@
|
||||
'id' => 6,
|
||||
'workspace_id' => 1,
|
||||
'managed_environment_id' => 41,
|
||||
'status' => 'published',
|
||||
]);
|
||||
|
||||
$case = ReviewPackOutputResolutionAdapter::fromGuidance($review, $guidance, 'customer_review_workspace');
|
||||
|
||||
@ -0,0 +1,245 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Models\EnvironmentReview;
|
||||
use App\Support\EnvironmentReviewCompletenessState;
|
||||
use App\Support\ResolutionGuidance\ResolutionAction;
|
||||
use App\Support\ResolutionGuidance\ReviewOutputResolveActionMapper;
|
||||
use App\Support\ReviewPacks\ReviewPackOutputReadiness;
|
||||
use App\Support\ReviewPacks\ReviewPackOutputResolutionGuidance;
|
||||
|
||||
it('prefers a known successor review when one is concretely available', function (): void {
|
||||
$review = spec351ReviewModel('published');
|
||||
$guidance = spec351Guidance(
|
||||
reviewStatus: 'published',
|
||||
publishBlockers: ['Operator approval note is still missing.'],
|
||||
hasReadyExport: true,
|
||||
);
|
||||
|
||||
$mapped = ReviewOutputResolveActionMapper::map(
|
||||
review: $review,
|
||||
guidance: $guidance,
|
||||
sourceSurface: 'environment_review_detail',
|
||||
urls: [
|
||||
'review' => '/admin/reviews/41',
|
||||
'evidence' => '/admin/evidence/8',
|
||||
'successor_review' => '/admin/reviews/42',
|
||||
],
|
||||
execution: [
|
||||
'can_manage_review' => true,
|
||||
],
|
||||
);
|
||||
|
||||
expect($mapped['primary_action']['key'])->toBe('open_successor_review')
|
||||
->and($mapped['primary_action']['type'])->toBe(ResolutionAction::TYPE_NAVIGATION)
|
||||
->and($mapped['primary_action']['action_name'])->toBe('open_successor_review')
|
||||
->and($mapped['primary_action']['url'])->toBe('/admin/reviews/42');
|
||||
});
|
||||
|
||||
it('labels a known mutable successor as open draft review on the workspace', function (): void {
|
||||
$review = spec351ReviewModel('published');
|
||||
$guidance = spec351Guidance(
|
||||
reviewStatus: 'published',
|
||||
publishBlockers: ['Operator approval note is still missing.'],
|
||||
hasReadyExport: true,
|
||||
);
|
||||
|
||||
$mapped = ReviewOutputResolveActionMapper::map(
|
||||
review: $review,
|
||||
guidance: $guidance,
|
||||
sourceSurface: 'customer_review_workspace',
|
||||
urls: [
|
||||
'review' => '/admin/reviews/41',
|
||||
'evidence' => '/admin/evidence/8',
|
||||
'successor_review' => '/admin/reviews/42',
|
||||
],
|
||||
execution: [
|
||||
'can_manage_review' => true,
|
||||
'successor_review_status' => 'draft',
|
||||
],
|
||||
);
|
||||
|
||||
expect($mapped['primary_action']['key'])->toBe('open_successor_review')
|
||||
->and($mapped['primary_action']['label'])->toBe('Open draft review')
|
||||
->and($mapped['primary_action']['action_name'])->toBeNull()
|
||||
->and($mapped['primary_action']['url'])->toBe('/admin/reviews/42');
|
||||
});
|
||||
|
||||
it('prefers create next review for published blocked workspace output when execution is safe', function (): void {
|
||||
$review = spec351ReviewModel('published');
|
||||
$guidance = spec351Guidance(
|
||||
reviewStatus: 'published',
|
||||
publishBlockers: ['Operator approval note is still missing.'],
|
||||
hasReadyExport: true,
|
||||
);
|
||||
|
||||
$mapped = ReviewOutputResolveActionMapper::map(
|
||||
review: $review,
|
||||
guidance: $guidance,
|
||||
sourceSurface: 'customer_review_workspace',
|
||||
urls: [
|
||||
'review' => '/admin/reviews/41',
|
||||
'evidence' => '/admin/evidence/8',
|
||||
],
|
||||
execution: [
|
||||
'can_manage_review' => true,
|
||||
],
|
||||
);
|
||||
|
||||
expect($mapped['primary_action']['key'])->toBe('create_next_review')
|
||||
->and($mapped['primary_action']['type'])->toBe(ResolutionAction::TYPE_OPERATION_ACTION)
|
||||
->and($mapped['primary_action']['action_name'])->toBe('createNextReview')
|
||||
->and($mapped['secondary_actions'][0]['key'])->toBe('resolve_review_blockers');
|
||||
});
|
||||
|
||||
it('falls back honestly when published blocked output cannot execute on the current surface', function (): void {
|
||||
$review = spec351ReviewModel('published');
|
||||
$guidance = spec351Guidance(
|
||||
reviewStatus: 'published',
|
||||
publishBlockers: ['Operator approval note is still missing.'],
|
||||
hasReadyExport: true,
|
||||
);
|
||||
|
||||
$mapped = ReviewOutputResolveActionMapper::map(
|
||||
review: $review,
|
||||
guidance: $guidance,
|
||||
sourceSurface: 'environment_review_detail.customer_workspace',
|
||||
urls: [
|
||||
'review' => '/admin/reviews/41',
|
||||
'evidence' => '/admin/evidence/8',
|
||||
],
|
||||
execution: [
|
||||
'can_manage_review' => true,
|
||||
],
|
||||
);
|
||||
|
||||
expect($mapped['primary_action']['key'])->toBe('resolve_review_blockers')
|
||||
->and($mapped['primary_action']['type'])->toBe(ResolutionAction::TYPE_NAVIGATION)
|
||||
->and($mapped['primary_action']['action_name'])->toBeNull();
|
||||
});
|
||||
|
||||
it('prefers refresh review for mutable blocked detail when execution is allowed', function (): void {
|
||||
$review = spec351ReviewModel('draft');
|
||||
$guidance = spec351Guidance(
|
||||
reviewStatus: 'draft',
|
||||
evidenceState: EnvironmentReviewCompletenessState::Partial->value,
|
||||
hasReadyExport: false,
|
||||
);
|
||||
|
||||
$mapped = ReviewOutputResolveActionMapper::map(
|
||||
review: $review,
|
||||
guidance: $guidance,
|
||||
sourceSurface: 'environment_review_detail',
|
||||
urls: [
|
||||
'review' => '/admin/reviews/41',
|
||||
'evidence' => '/admin/evidence/8',
|
||||
],
|
||||
execution: [
|
||||
'can_manage_review' => true,
|
||||
],
|
||||
);
|
||||
|
||||
expect($mapped['primary_action']['key'])->toBe('refresh_review')
|
||||
->and($mapped['primary_action']['type'])->toBe(ResolutionAction::TYPE_OPERATION_ACTION)
|
||||
->and($mapped['primary_action']['action_name'])->toBe('refresh_review');
|
||||
});
|
||||
|
||||
it('prefers publish review for ready mutable detail when execution is allowed', function (): void {
|
||||
$review = spec351ReviewModel('ready');
|
||||
$guidance = spec351Guidance(
|
||||
reviewStatus: 'ready',
|
||||
hasReadyExport: false,
|
||||
);
|
||||
|
||||
$mapped = ReviewOutputResolveActionMapper::map(
|
||||
review: $review,
|
||||
guidance: $guidance,
|
||||
sourceSurface: 'environment_review_detail',
|
||||
urls: [
|
||||
'review' => '/admin/reviews/41',
|
||||
'evidence' => '/admin/evidence/8',
|
||||
],
|
||||
execution: [
|
||||
'can_manage_review' => true,
|
||||
],
|
||||
);
|
||||
|
||||
expect($mapped['primary_action']['key'])->toBe('publish_review')
|
||||
->and($mapped['primary_action']['type'])->toBe(ResolutionAction::TYPE_DOMAIN_ACTION)
|
||||
->and($mapped['primary_action']['action_name'])->toBe('publish_review');
|
||||
});
|
||||
|
||||
it('keeps a none fallback for unsupported no-link states', function (): void {
|
||||
$review = spec351ReviewModel('archived');
|
||||
$guidance = ReviewPackOutputResolutionGuidance::fromReadiness([
|
||||
'review_status' => 'archived',
|
||||
'readiness_state' => 'unknown',
|
||||
'primary_reason' => 'review_output_limitations',
|
||||
'has_ready_export' => false,
|
||||
'contains_pii' => false,
|
||||
'limitations' => [],
|
||||
'section_summary' => [],
|
||||
'section_state_counts' => [],
|
||||
'evidence_completeness_state' => EnvironmentReviewCompletenessState::Missing->value,
|
||||
'required_section_state_counts' => [],
|
||||
], []);
|
||||
|
||||
$mapped = ReviewOutputResolveActionMapper::map(
|
||||
review: $review,
|
||||
guidance: $guidance,
|
||||
sourceSurface: 'environment_review_detail',
|
||||
urls: [],
|
||||
execution: [
|
||||
'can_manage_review' => false,
|
||||
],
|
||||
);
|
||||
|
||||
expect($mapped['primary_action']['type'])->toBe(ResolutionAction::TYPE_NONE)
|
||||
->and($mapped['secondary_actions'])->toBeEmpty();
|
||||
});
|
||||
|
||||
function spec351ReviewModel(string $status): EnvironmentReview
|
||||
{
|
||||
$review = new EnvironmentReview;
|
||||
$review->forceFill([
|
||||
'id' => 41,
|
||||
'workspace_id' => 1,
|
||||
'managed_environment_id' => 7,
|
||||
'status' => $status,
|
||||
]);
|
||||
|
||||
return $review;
|
||||
}
|
||||
|
||||
function spec351Guidance(
|
||||
string $reviewStatus,
|
||||
array $publishBlockers = [],
|
||||
bool $hasReadyExport = false,
|
||||
string $evidenceState = EnvironmentReviewCompletenessState::Complete->value,
|
||||
): array {
|
||||
$readiness = ReviewPackOutputReadiness::derive(
|
||||
reviewStatus: $reviewStatus,
|
||||
reviewCompletenessState: EnvironmentReviewCompletenessState::Complete->value,
|
||||
evidenceCompletenessState: $evidenceState,
|
||||
sectionStateCounts: [
|
||||
EnvironmentReviewCompletenessState::Complete->value => 5,
|
||||
],
|
||||
requiredSectionCount: 5,
|
||||
requiredSectionStateCounts: [
|
||||
EnvironmentReviewCompletenessState::Complete->value => 5,
|
||||
],
|
||||
publishBlockers: $publishBlockers,
|
||||
hasReadyExport: $hasReadyExport,
|
||||
includePii: false,
|
||||
protectedValuesHidden: true,
|
||||
disclosurePresent: true,
|
||||
);
|
||||
|
||||
return ReviewPackOutputResolutionGuidance::fromReadiness($readiness, [
|
||||
'review' => '/admin/reviews/41',
|
||||
'evidence' => '/admin/evidence/8',
|
||||
'operation' => '/admin/operations/9',
|
||||
'download' => $hasReadyExport ? '/admin/review-packs/8/download' : null,
|
||||
]);
|
||||
}
|
||||
@ -7,7 +7,7 @@ ## Summary
|
||||
| Metric | Count | Notes |
|
||||
| --- | ---: | --- |
|
||||
| UI route/page inventory rows | 98 | Includes dynamic route families and utility/auth endpoints. |
|
||||
| Unique page reports | 15 | `page-reports/*.md`; two inventory rows share existing reports where routes resolve to the same surface. |
|
||||
| Unique page reports | 16 | `page-reports/*.md`; two inventory rows share existing reports where routes resolve to the same surface. |
|
||||
| Desktop screenshots | 15 | 12 rendered product pages and 3 blocker evidence screenshots. |
|
||||
| Tablet screenshots | 0 | Deferred to later strategic mockup/implementation specs. |
|
||||
| Mobile screenshots | 0 | Deferred to later strategic mockup/implementation specs. |
|
||||
@ -16,7 +16,7 @@ ## Summary
|
||||
| Design-System Cleanup Surface rows | 7 | Tables/forms/states/copy cleanup, no individual target mockup expected by default. |
|
||||
| Internal / Deprecated / Hidden rows | 1 | Local-only smoke login routes. |
|
||||
| Manual Review Required rows | 1 | File-discovered break-glass page without confirmed route. |
|
||||
| High-priority unresolved/manual-review entries | 32 | Recorded in `unresolved-pages.md`. |
|
||||
| High-priority unresolved/manual-review entries | 30 | Recorded in `unresolved-pages.md`. |
|
||||
|
||||
## Spec 325 Target Image Coverage
|
||||
|
||||
@ -60,7 +60,7 @@ ## Coverage By Area
|
||||
| Findings | 5 | Queue/inbox patterns captured; finding detail needs individual triage target. |
|
||||
| Auth/access | 4 | Mostly flow/guard surfaces; copy and denial states should be pattern-reviewed. |
|
||||
| App shell | 4 | Workspace overview captured; chooser/context routes need domain pattern pass. |
|
||||
| Workspace / environment | 2 | Environment dashboard captured; managed-environments landing remains unresolved. |
|
||||
| Workspace / environment | 2 | Environment dashboard captured; managed-environments landing now has a runtime report. |
|
||||
| Utility | 2 | Non-product endpoints; design-system cleanup only. |
|
||||
| Support | 2 | Diagnostics/support surfaces should stay secondary to operator workflows. |
|
||||
| Provider / onboarding | 2 | Wizard and draft states require later target treatment. |
|
||||
@ -101,7 +101,7 @@ ## Coverage By Design Depth
|
||||
|
||||
## Missing Or Unclear Coverage
|
||||
|
||||
The largest open gaps are strategic detail/workflow surfaces, system-plane routes, and high-risk restore/backup flows that need seeded capability states. `unresolved-pages.md` records 32 high-priority entries.
|
||||
The largest open gaps are strategic detail/workflow surfaces, system-plane routes, and high-risk restore/backup flows that need seeded capability states. `unresolved-pages.md` records 30 high-priority entries.
|
||||
|
||||
Tablet and mobile coverage is intentionally absent from this baseline. Later target specs should add responsive evidence for the app shell, workspace overview, environment dashboard, customer review workspace, governance inbox, operations, evidence, backup/restore, and critical forms.
|
||||
|
||||
|
||||
@ -8,8 +8,8 @@ # UI-006 Customer Review Workspace
|
||||
| Archetype | Customer Workspace |
|
||||
| Design depth | Strategic Surface |
|
||||
| Repo truth | repo-verified |
|
||||
| Screenshot | `../screenshots/desktop/ui-006-customer-review-workspace.png` |
|
||||
| Browser status | Reached through local workspace route. |
|
||||
| Screenshot | `Spec 351 browser proof: specs/351-review-output-resolve-actions-v1/artifacts/screenshots/01-published-blocked.png` |
|
||||
| Browser status | Reached through the local workspace route for executable and readonly-fallback states. |
|
||||
|
||||
## First Five Seconds
|
||||
|
||||
@ -116,3 +116,23 @@ ## Spec 350 Follow-up
|
||||
- review-output truth still comes from `ReviewPackOutputResolutionGuidance`
|
||||
- findings-follow-up and accepted-risk follow-up remain local workspace overrides and are not flattened into the shared adapter
|
||||
- the primary action handoff now matches the review-detail contract without changing workspace/environment isolation or customer-safe disclosure rules
|
||||
|
||||
## Spec 351 Follow-up
|
||||
|
||||
Spec 351 turns the workspace decision card into a resolve-action-first handoff only where the repo already owns a safe execution path.
|
||||
|
||||
- published blocked output now mounts the source-owned `create_next_review` Filament action with confirmation instead of exposing a fake link/action split
|
||||
- `Create next review` remains the only dominant primary CTA; everything else is demoted into a secondary `Supporting actions` group
|
||||
- the right-hand review-pack state card is now split into `Package exists`, `Internal export`, and `Customer sharing`
|
||||
- acknowledgement copy now states explicitly that acknowledgement records review consumption and does not make the output customer-ready
|
||||
- the review-consumption flow stays available, but only as a subordinate supporting reference below the main decision and package-index surfaces
|
||||
- findings and accepted-risk follow-up overrides still sit above the base review-output mapper
|
||||
- when the actor cannot execute the lifecycle mutation, the dominant CTA degrades honestly to `Inspect review blockers`
|
||||
- the workspace still reuses repo-real lifecycle services, capability gates, notifications, and audit semantics rather than inventing a second action runtime
|
||||
|
||||
### Browser proof
|
||||
|
||||
- Spec351 screenshots: `specs/351-review-output-resolve-actions-v1/artifacts/screenshots/`
|
||||
- Verified states:
|
||||
- `01-published-blocked.png` shows the executable `Create next review` CTA with confirmation, the `Supporting actions` group, and the split review-pack state semantics
|
||||
- `04-fallback.png` shows the readonly/non-executable fallback to `Inspect review blockers`
|
||||
|
||||
@ -0,0 +1,48 @@
|
||||
# UI-010 Managed Environments
|
||||
|
||||
| Field | Value |
|
||||
| --- | --- |
|
||||
| Route | `/admin/workspaces/{workspace}/environments` |
|
||||
| Source | `ManagedEnvironmentsLanding`, `ManagedEnvironmentLinks` |
|
||||
| Area / scope | Workspace / environment portfolio |
|
||||
| Archetype | Workspace / Tenant Context |
|
||||
| Design depth | Strategic Surface |
|
||||
| Repo truth | repo-verified |
|
||||
| Screenshot | `-` |
|
||||
| Browser status | Reached in local browser with workspace `3`; implementation pass verified long-name layout behavior. |
|
||||
|
||||
## First Five Seconds
|
||||
|
||||
The page is the workspace-scoped environment portfolio entry point. Operators need to identify the correct environment quickly before drilling into environment-owned dashboards, evidence, backup, restore, findings, or access surfaces.
|
||||
|
||||
## Productization Review
|
||||
|
||||
- Decision-first: improved by presenting each environment as a full-width selectable row/card with stable identity and status.
|
||||
- Evidence-first: intentionally light; this is a portfolio chooser, not a posture dashboard.
|
||||
- Context: workspace name and environment count remain visible above the list.
|
||||
- Customer/auditor safety: operator-facing only; no customer evidence copy is introduced.
|
||||
- Diagnostics: raw diagnostics stay out of the default surface.
|
||||
|
||||
## Information Inventory
|
||||
|
||||
Default-visible content includes workspace identity, environment count, environment name, provider/external identifier, and lifecycle status. Add-environment and switch-workspace actions are available in the header. Empty state remains bounded to onboarding and workspace switching.
|
||||
|
||||
## Dangerous Actions
|
||||
|
||||
No destructive action is present. Opening an environment is a navigation action; adding an environment routes to onboarding and does not mutate from this page.
|
||||
|
||||
## Scores
|
||||
|
||||
| IA | Density | User Clarity | Sellability | Disclosure | Hierarchy | DS Fit | A11y | Responsive | Components | UX Writing | Perf |
|
||||
| ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: |
|
||||
| 4 | 4 | 4 | 4 | 4 | 4 | 4 | 4 | 4 | 4 | 4 | 4 |
|
||||
|
||||
## Top Issues
|
||||
|
||||
1. No portfolio-level health, sync, or backup posture signal is shown yet.
|
||||
2. No search/filter exists for larger MSP portfolios.
|
||||
3. Static screenshot evidence is still absent from the durable audit artifact set.
|
||||
|
||||
## Target Direction
|
||||
|
||||
Keep the current runtime as a robust enterprise baseline for environment selection. A later portfolio spec can add search/filter and lightweight posture signals once the page carries enough environments to justify that added interaction surface.
|
||||
@ -8,8 +8,8 @@ # UI-040 Environment Review Detail
|
||||
| Archetype | Evidence / Audit |
|
||||
| Design depth | Strategic Surface |
|
||||
| Repo truth | repo-verified |
|
||||
| Screenshot | `Spec 350 browser proof: specs/350-operator-resolution-guidance-framework-v1/artifacts/screenshots/02-detail-context.png` |
|
||||
| Browser status | Reached through direct environment route and customer-workspace handoff. |
|
||||
| Screenshot | `Spec 351 browser proof: specs/351-review-output-resolve-actions-v1/artifacts/screenshots/02-mutable-blocked.png` |
|
||||
| Browser status | Reached through direct environment routes for mutable-blocked and ready-draft states, plus the existing customer-workspace handoff regression path. |
|
||||
|
||||
## First Five Seconds
|
||||
|
||||
@ -47,6 +47,23 @@ ## Spec 350 Follow-up
|
||||
- source refs and evidence refs remain repo-backed in the underlying contract
|
||||
- customer-workspace detail mode still suppresses repeated action buttons and keeps the limitations/technical-details path as the primary inspection flow
|
||||
|
||||
## Spec 351 Follow-up
|
||||
|
||||
Spec 351 aligns detail-surface next-step semantics to the same resolve-action mapper without creating a duplicate CTA rail inside the guidance card.
|
||||
|
||||
- mutable blocked reviews now resolve to the existing `refresh_review` header action
|
||||
- ready mutable reviews now resolve to the existing `publish_review` header action with the current publication-reason flow
|
||||
- published blocked reviews can align to `create_next_review` or `open_successor_review` only when the action/target is repo-real
|
||||
- customer-workspace detail mode remains suppress-primary and keeps the explanation/proof path intact
|
||||
- the workspace remains the only surface that groups supporting secondary actions; detail keeps a single dominant lifecycle rail and explanation/proof follow-through
|
||||
|
||||
### Browser proof
|
||||
|
||||
- Spec351 screenshots: `specs/351-review-output-resolve-actions-v1/artifacts/screenshots/`
|
||||
- Verified states:
|
||||
- `02-mutable-blocked.png` shows the `Refresh review` confirmation path
|
||||
- `03-ready-draft.png` shows the `Publish review` modal with the existing reason field
|
||||
|
||||
## Target Direction
|
||||
|
||||
Keep this surface audit- and evidence-oriented. If future work broadens it beyond the review-output path, that should happen through a dedicated detail-surface spec rather than hidden incremental drift.
|
||||
|
||||
@ -15,7 +15,7 @@ # Route Inventory
|
||||
| UI-007 | `/admin/workspaces` | Workspace resource | Manage Workspaces | Settings / admin | workspace | reachable | workspace membership management capability | Settings / Admin | Workspace / Tenant Context | Strategic Surface | repo-verified | - | - | Membership-management surface, high trust/RBAC importance. |
|
||||
| UI-008 | `/admin/workspaces/create` | Workspace resource | Create Workspace | Settings / admin | workspace | route exists | create capability | Settings / Admin | Commercial / Entitlements | Domain Pattern Surface | repo-verified | - | - | Requires form review for entitlement and owner language. |
|
||||
| UI-009 | `/admin/workspaces/{record}` and `/edit` | Workspace resource | Workspace Detail / Edit | Settings / admin | workspace | route exists | workspace view/edit capability | Settings / Admin | Workspace / Tenant Context | Domain Pattern Surface | repo-verified | - | - | Dynamic record routes need seeded workspace context for visual review. |
|
||||
| UI-010 | `/admin/workspaces/{workspace}/environments` | route + `ManagedEnvironmentsLanding` | Managed Environments | Workspace / environment | workspace | route exists | workspace member | Workspace / Tenant Context | Provider / Integration | Strategic Surface | repo-verified | - | - | Portfolio entry point for environments. |
|
||||
| UI-010 | `/admin/workspaces/{workspace}/environments` | route + `ManagedEnvironmentsLanding` | Managed Environments | Workspace / environment | workspace | route exists | workspace member | Workspace / Tenant Context | Provider / Integration | Strategic Surface | repo-verified | - | [report](page-reports/ui-010-managed-environments.md) | Portfolio entry point for environments; runtime updated to robust selection layout. |
|
||||
| UI-011 | `/admin/workspaces/{workspace}/environments/{environment}` | route + `EnvironmentDashboard` | Environment Dashboard | Workspace / environment | environment-bound | reachable | workspace + environment entitlement | Overview / Dashboard | Workspace / Tenant Context | Strategic Surface | repo-verified | [desktop](screenshots/desktop/ui-002-environment-dashboard.png) | [report](page-reports/ui-002-environment-dashboard.md) | Core environment product surface. |
|
||||
| UI-012 | `/admin/workspaces/{workspace}/environments/{environment}/diagnostics` | route + page | Environment Diagnostics | Support | environment-bound | route exists | environment entitlement | Support / Diagnostics | Provider / Integration | Domain Pattern Surface | repo-verified | - | - | Diagnostics must remain secondary to operator surfaces. |
|
||||
| UI-013 | `/admin/workspaces/{workspace}/environments/{environment}/access-scopes` | resource page | Environment Access Scopes | Settings / admin | environment-bound | route exists | owner/manager capability expected | Settings / Admin | Auth / Access | Strategic Surface | repo-verified | - | - | RBAC-sensitive environment access surface. |
|
||||
|
||||
@ -4,15 +4,14 @@ # Unresolved Pages
|
||||
|
||||
Summary:
|
||||
|
||||
- High-priority unresolved/manual-review entries: 31.
|
||||
- High-priority unresolved/manual-review entries: 30.
|
||||
- Capability/fixture blockers with desktop evidence: UI-051, UI-053, UI-061.
|
||||
- Strategic routes not browser-captured in this bounded pass: 27.
|
||||
- Strategic routes not browser-captured in this bounded pass: 26.
|
||||
- Hidden/file-discovered manual-review surface: UI-080.
|
||||
|
||||
| ID | Page | Blocker / Reason | Needed Evidence | Next Action |
|
||||
| --- | --- | --- | --- | --- |
|
||||
| UI-007 | Manage Workspaces | Strategic RBAC/admin page was not captured in browser pass. | Workspace admin fixture with member/owner states. | Include in settings/admin target pass. |
|
||||
| UI-010 | Managed Environments | Environment portfolio route exists but was not captured. | Workspace with multiple environment states. | Include in app-shell/environment target pass. |
|
||||
| UI-013 | Environment Access Scopes | RBAC-sensitive environment route was not captured. | Owner/manager fixture with editable and read-only states. | Include in access-control target pass. |
|
||||
| UI-014 | Environment Onboarding | Provider setup wizard route exists but was not captured. | Draft/onboarding fixture with consent and permission states. | Include in provider onboarding target pass. |
|
||||
| UI-017 | Operation Detail | Dynamic operation record route requires a run fixture. | OperationRun records covering success, failure, running, retryable states. | Add operation detail report later. |
|
||||
|
||||
BIN
spec351-after-publish-detail.png
Normal file
|
After Width: | Height: | Size: 439 KiB |
BIN
spec351-after-publish-workspace.png
Normal file
|
After Width: | Height: | Size: 428 KiB |
BIN
spec351-publish-modal-before-submit.png
Normal file
|
After Width: | Height: | Size: 445 KiB |
BIN
spec351-ready-fixture-detail.png
Normal file
|
After Width: | Height: | Size: 426 KiB |
BIN
spec351-ready-fixture-workspace.png
Normal file
|
After Width: | Height: | Size: 142 KiB |
@ -0,0 +1,398 @@
|
||||
# Spec 351 Browser Flow Audit — Review Output Resolve Actions
|
||||
|
||||
## Executive Summary
|
||||
- Overall readiness: not ready
|
||||
- Main flow result: `Create next review` is browser-verifiziert and scope-safe on `environment_id=38`, but the operator loop does not close cleanly because the workspace loses the released-review entry once a draft exists.
|
||||
- Top issues:
|
||||
- P1: Workspace does not surface `Open draft review` or any successor action once a draft exists; both `environment_id=39` and the post-create state for `environment_id=38` fall back to an empty released-review state.
|
||||
- P1: `Publish review` is visible on draft review `14` while the same page still says `Publication blocked` / `Output not customer-ready` and points to `Refresh review` as the next step.
|
||||
- P2: `Refresh review` confirms cleanly but gives no browser-verifizierte success toast or obvious state delta, so the operator cannot tell whether anything changed.
|
||||
- Recommendation: fix before close
|
||||
|
||||
## Repo State
|
||||
- Branch: `351-review-output-resolve-actions-v1`
|
||||
- Dirty tracked files: 26
|
||||
- Untracked files: 7
|
||||
- Spec 351 active/uncommitted: yes
|
||||
- App code already changed: yes
|
||||
- Browser flow ran against the current uncommitted working tree: yes
|
||||
- Relevant Spec 351 files in the dirty tree:
|
||||
- `apps/platform/app/Support/ResolutionGuidance/ResolutionAction.php`
|
||||
- `apps/platform/app/Support/ResolutionGuidance/ResolutionCase.php`
|
||||
- `apps/platform/app/Support/ResolutionGuidance/Adapters/ReviewPackOutputResolutionAdapter.php`
|
||||
- `apps/platform/app/Support/ResolutionGuidance/ReviewOutputResolveActionMapper.php`
|
||||
- `apps/platform/app/Filament/Pages/Reviews/CustomerReviewWorkspace.php`
|
||||
- `apps/platform/app/Filament/Resources/EnvironmentReviewResource.php`
|
||||
- `apps/platform/app/Filament/Resources/EnvironmentReviewResource/Pages/ViewEnvironmentReview.php`
|
||||
- `apps/platform/resources/views/filament/pages/reviews/customer-review-workspace.blade.php`
|
||||
- `apps/platform/resources/views/components/resolution-guidance-card.blade.php`
|
||||
- `apps/platform/tests/Unit/ResolutionGuidance/Spec351ReviewOutputResolveActionMapperTest.php`
|
||||
- `apps/platform/tests/Feature/Filament/Spec351CustomerReviewWorkspaceResolveActionTest.php`
|
||||
- `apps/platform/tests/Feature/EnvironmentReview/Spec351EnvironmentReviewResolveActionTest.php`
|
||||
- `apps/platform/tests/Browser/Spec351ReviewOutputResolveActionsSmokeTest.php`
|
||||
- Tests executed during this audit: no
|
||||
|
||||
## Repo-Verified Action Inventory
|
||||
|
||||
### Repo-backed resolve actions
|
||||
- `create_next_review`
|
||||
- type: mutating / operation-backed
|
||||
- browser surface: Customer Review Workspace and Environment Review detail
|
||||
- confirmation: yes
|
||||
- capability: `Capabilities::ENVIRONMENT_REVIEW_MANAGE`
|
||||
- audit / operation backing: `AuditActionId::EnvironmentReviewSuccessorCreated`, `OperationRunType::EnvironmentReviewCompose`
|
||||
- `refresh_review`
|
||||
- type: mutating / operation-backed
|
||||
- browser surface: Environment Review detail only
|
||||
- confirmation: yes
|
||||
- capability: `Capabilities::ENVIRONMENT_REVIEW_MANAGE`
|
||||
- audit / operation backing: `AuditActionId::EnvironmentReviewRefreshed`, `OperationRunType::EnvironmentReviewCompose`
|
||||
- `publish_review`
|
||||
- type: mutating / domain action
|
||||
- browser surface: Environment Review detail only
|
||||
- confirmation: yes
|
||||
- capability: `Capabilities::ENVIRONMENT_REVIEW_MANAGE`
|
||||
- audit backing: `AuditActionId::EnvironmentReviewPublished`
|
||||
- `open_successor_review`
|
||||
- type: navigation
|
||||
- browser surface: detail action when `superseded_by_review_id` resolves to a same-scope review
|
||||
- confirmation: no
|
||||
|
||||
### Fallback / navigation actions
|
||||
- `resolve_review_blockers`
|
||||
- `open_evidence_basis`
|
||||
- `open_operation_proof`
|
||||
- `open_review`
|
||||
- download actions such as `download_review_pack_with_limitations`
|
||||
|
||||
### Actions intentionally not offered as executable in workspace
|
||||
- `refresh_review`
|
||||
- `publish_review`
|
||||
- generic “fix automatically”
|
||||
- generic “make customer-ready”
|
||||
|
||||
## Browser Environment
|
||||
- Base URL: `http://localhost`
|
||||
- User: authenticated workspace manager; resulting compose operations were initiated by `Ahmed Darrazi` (repo-verifiziert after mutation)
|
||||
- Workspace: `wp` / workspace `3`
|
||||
- Environments used:
|
||||
- `38` — `Spec342 Demo Evidence Incomplete`
|
||||
- `39` — `Spec342 Demo Ready`
|
||||
- Reviews used:
|
||||
- `3` — published review for environment `38`, later superseded by `14`
|
||||
- `14` — draft created during this audit for environment `38`
|
||||
- `4` — superseded released review for environment `39`
|
||||
- `13` — existing draft for environment `39`
|
||||
- Review / pack state:
|
||||
- environment `38` pre-action: published blocked output, review pack `4`, operation `24`
|
||||
- environment `38` post-create: draft `14`, operation `47`, previous review `3` now `superseded_by_review_id=14`
|
||||
- environment `39`: released review `4` already superseded by draft `13`
|
||||
|
||||
## Flow Walkthrough
|
||||
|
||||
### Step 0 — Existing-draft workspace state (`environment_id=39`)
|
||||
- Step: reload current `Customer Review Workspace`
|
||||
- URL: `http://localhost/admin/reviews/workspace?environment_id=39`
|
||||
- Visible title: `Customer Review Workspace - TenantPilot`
|
||||
- Primary guidance state: not present
|
||||
- Primary action: not present
|
||||
- Secondary actions: not present
|
||||
- Clicked action: none
|
||||
- Expected result: if an existing draft is the next repo-backed step, the workspace should guide to it
|
||||
- Actual result:
|
||||
- browser-verifiziert: workspace shows `No released customer reviews match the active environment filter.`
|
||||
- repo-verifiziert: environment `39` has superseded review `4` plus draft `13`
|
||||
- nicht vorhanden: `Open draft review`
|
||||
- Scope preserved? yes
|
||||
- Console errors? no
|
||||
- Network/server errors? no visible server error page
|
||||
- Toast/notification: none
|
||||
- State changed? no
|
||||
- Operator clarity: low; the workspace gives no next step for an environment that already has a draft successor
|
||||
- Issue: successor/draft flow is unreachable from the workspace once the released review is superseded
|
||||
- Severity: P1
|
||||
- Screenshot: `00-workspace-env39-no-released-review.png`
|
||||
|
||||
### Step 1 — Published blocked workspace (`environment_id=38`)
|
||||
- Step: open blocked released-review workspace state
|
||||
- URL: `http://localhost/admin/reviews/workspace?environment_id=38`
|
||||
- Visible title: `Customer Review Workspace - TenantPilot`
|
||||
- Primary guidance state: `Output not customer-ready` / `Requires review`
|
||||
- Primary action: `Create next review`
|
||||
- Secondary actions:
|
||||
- `Inspect review blockers`
|
||||
- `Download review pack with limitations`
|
||||
- `Open evidence basis`
|
||||
- `Open operation proof`
|
||||
- Clicked action: none
|
||||
- Expected result: one dominant real next step with secondary supporting actions
|
||||
- Actual result:
|
||||
- browser-verifiziert: one dominant `Create next review` CTA
|
||||
- browser-verifiziert: supporting actions are visually subordinate
|
||||
- browser-verifiziert: acknowledgement copy distinguishes consumption from customer-ready status
|
||||
- Scope preserved? yes
|
||||
- Console errors? no
|
||||
- Network/server errors? no visible server error page
|
||||
- Toast/notification: none
|
||||
- State changed? no
|
||||
- Operator clarity: high on first screen
|
||||
- Issue: none at this step
|
||||
- Severity: none
|
||||
- Screenshot: `01-customer-review-workspace-output-not-ready.png`
|
||||
|
||||
### Step 2 — Create next review confirmation
|
||||
- Step: click `Create next review`
|
||||
- URL: `http://localhost/admin/reviews/workspace?environment_id=38`
|
||||
- Visible title: `Customer Review Workspace - TenantPilot`
|
||||
- Primary guidance state: unchanged beneath modal
|
||||
- Primary action: `Create next review`
|
||||
- Secondary actions: unchanged beneath modal
|
||||
- Clicked action: `Create next review`
|
||||
- Expected result: confirmation before mutation
|
||||
- Actual result:
|
||||
- browser-verifiziert: confirmation modal appears with `Create next review?`
|
||||
- Scope preserved? yes
|
||||
- Console errors? no
|
||||
- Network/server errors? no visible server error page
|
||||
- Toast/notification: modal only
|
||||
- State changed? not yet
|
||||
- Operator clarity: high
|
||||
- Issue: none
|
||||
- Severity: none
|
||||
- Screenshot: `02-create-next-review-primary-action.png`
|
||||
|
||||
### Step 3 — Result after create-next-review confirmation
|
||||
- Step: confirm `Create next review`
|
||||
- URL: `http://localhost/admin/workspaces/3/environments/spec342-demo-evidence-incomplete/environment-reviews/14?source_surface=customer_review_workspace&tenant_filter_id=38`
|
||||
- Visible title: `View Review - TenantPilot`
|
||||
- Primary guidance state: draft review detail, initially `Internal only`, then `Publication blocked` / `Output not customer-ready` after refresh interactions
|
||||
- Primary action: `Refresh review`
|
||||
- Secondary actions:
|
||||
- `Inspect review blockers`
|
||||
- `Open evidence basis`
|
||||
- `Open operation proof`
|
||||
- Clicked action: confirmed `Create next review`
|
||||
- Expected result: new draft is created and opened in the correct environment scope
|
||||
- Actual result:
|
||||
- browser-verifiziert: detail page for review `14` opens
|
||||
- repo-verifiziert: review `14` exists as draft and review `3` is now superseded by `14`
|
||||
- browser-verifiziert: `tenant_filter_id=38` and environment scope are preserved in the URL
|
||||
- Scope preserved? yes
|
||||
- Console errors? no
|
||||
- Network/server errors? no visible server error page
|
||||
- Toast/notification: none clearly visible at landing time
|
||||
- State changed? yes
|
||||
- Operator clarity: medium; the draft opens correctly, but the route is a normal detail route rather than a suppressed customer-workspace detail route
|
||||
- Issue: no direct break, but the operator has already left the calm workspace surface
|
||||
- Severity: P2
|
||||
- Screenshot: `03-after-create-next-review-draft.png`
|
||||
|
||||
### Step 4 — Draft review guidance
|
||||
- Step: inspect the newly opened draft detail
|
||||
- URL: `http://localhost/admin/workspaces/3/environments/spec342-demo-evidence-incomplete/environment-reviews/14?source_surface=customer_review_workspace&tenant_filter_id=38`
|
||||
- Visible title: `View Review - TenantPilot`
|
||||
- Primary guidance state: `Output not customer-ready` / `Publication blocked`
|
||||
- Primary action: `Refresh review`
|
||||
- Secondary actions:
|
||||
- `Inspect review blockers`
|
||||
- `Open evidence basis`
|
||||
- `Open operation proof`
|
||||
- Clicked action: none
|
||||
- Expected result: next action should align with missing inputs / stale evidence
|
||||
- Actual result:
|
||||
- browser-verifiziert: detail guidance points to `Refresh review`
|
||||
- browser-verifiziert: boundary copy remains non-customer-safe
|
||||
- browser-verifiziert + repo-verifiziert: `Publish review` is still visible in the header on this not-ready draft
|
||||
- Scope preserved? yes
|
||||
- Console errors? no
|
||||
- Network/server errors? no visible server error page
|
||||
- Toast/notification: none
|
||||
- State changed? no
|
||||
- Operator clarity: medium; the guidance is clear, but the visible `Publish review` header CTA competes with it
|
||||
- Issue: readiness and publish availability are contradictory on the same screen
|
||||
- Severity: P1
|
||||
- Screenshot: `04-draft-review-guidance.png`
|
||||
|
||||
### Step 5 — Refresh review inputs
|
||||
- Step: open and confirm `Refresh review`
|
||||
- URL: `http://localhost/admin/workspaces/3/environments/spec342-demo-evidence-incomplete/environment-reviews/14?source_surface=customer_review_workspace&tenant_filter_id=38`
|
||||
- Visible title: `View Review - TenantPilot`
|
||||
- Primary guidance state: remains blocked / refresh-oriented
|
||||
- Primary action: `Refresh review`
|
||||
- Secondary actions: unchanged
|
||||
- Clicked action: `Refresh review`
|
||||
- Expected result: obvious success feedback or visible state transition
|
||||
- Actual result:
|
||||
- browser-verifiziert: confirmation opens
|
||||
- browser-verifiziert: confirm completes without console errors
|
||||
- browser-verifiziert: no obvious success toast, no clear state delta, no new operator-facing completion cue
|
||||
- plausibel: refresh may have run, but the UI does not make that outcome legible
|
||||
- Scope preserved? yes
|
||||
- Console errors? no
|
||||
- Network/server errors? no visible server error page
|
||||
- Toast/notification: none browser-verifiziert
|
||||
- State changed? not obvious
|
||||
- Operator clarity: low after confirmation
|
||||
- Issue: refresh feedback is too weak for an action that is supposed to move the draft forward
|
||||
- Severity: P2
|
||||
- Screenshot: `05-refresh-review-inputs-if-available.png`, `06-after-refresh-result.png`
|
||||
|
||||
### Step 6 — Evidence basis target
|
||||
- Step: click `View evidence snapshot`
|
||||
- URL: `http://localhost/admin/workspaces/3/environments/spec342-demo-evidence-incomplete/evidence/5`
|
||||
- Visible title: `View Evidence Snapshot - TenantPilot`
|
||||
- Primary guidance state: evidence page
|
||||
- Primary action: none in scope for this audit
|
||||
- Secondary actions: page-local
|
||||
- Clicked action: `View evidence snapshot`
|
||||
- Expected result: correct environment-scoped evidence detail
|
||||
- Actual result:
|
||||
- browser-verifiziert: correct evidence detail opens
|
||||
- browser-verifiziert: environment scope remains `Spec342 Demo Evidence Incomplete`
|
||||
- Scope preserved? yes
|
||||
- Console errors? no
|
||||
- Network/server errors? no visible server error page
|
||||
- Toast/notification: none
|
||||
- State changed? navigated
|
||||
- Operator clarity: good
|
||||
- Issue: none
|
||||
- Severity: none
|
||||
- Screenshot: `08-evidence-basis-target.png`
|
||||
|
||||
### Step 7 — Operation proof target
|
||||
- Step: click visible `Open operation` for the current draft
|
||||
- URL: `http://localhost/admin/workspaces/3/operations/47`
|
||||
- Visible title: `Operation #47 - TenantPilot`
|
||||
- Primary guidance state: operation detail
|
||||
- Primary action: none in scope for this audit
|
||||
- Secondary actions: page-local
|
||||
- Clicked action: `Open operation`
|
||||
- Expected result: operation proof opens for the current review/compose context
|
||||
- Actual result:
|
||||
- browser-verifiziert: operation page opens
|
||||
- browser-verifiziert: operation shows compose context and succeeded outcome
|
||||
- Scope preserved? yes
|
||||
- Console errors? no
|
||||
- Network/server errors? no visible server error page
|
||||
- Toast/notification: none
|
||||
- State changed? navigated
|
||||
- Operator clarity: good
|
||||
- Issue: none
|
||||
- Severity: none
|
||||
- Screenshot: `09-operation-proof-target.png`
|
||||
|
||||
### Step 8 — Customer-workspace detail context
|
||||
- Step: open released review `3` directly in customer-workspace detail mode
|
||||
- URL: `http://localhost/admin/workspaces/3/environments/spec342-demo-evidence-incomplete/environment-reviews/3?customer_workspace=1&source_surface=customer_review_workspace&tenant_filter_id=38`
|
||||
- Visible title: `View Review - TenantPilot`
|
||||
- Primary guidance state: released review detail with customer-workspace context note
|
||||
- Primary action: suppressed
|
||||
- Secondary actions: suppressed in the guidance card
|
||||
- Clicked action: direct route open
|
||||
- Expected result: no duplicate CTA rail and clear context note
|
||||
- Actual result:
|
||||
- browser-verifiziert: context note `You are already on the review detail for this output.`
|
||||
- browser-verifiziert: no visible `Create next review`, `Publish review`, or `Refresh review` buttons in the guidance surface
|
||||
- browser-verifiziert: review status, output readiness, publication/sharing state, and limitations remain visible
|
||||
- Scope preserved? yes
|
||||
- Console errors? no
|
||||
- Network/server errors? no visible server error page
|
||||
- Toast/notification: none
|
||||
- State changed? navigated
|
||||
- Operator clarity: good
|
||||
- Issue: none
|
||||
- Severity: none
|
||||
- Screenshot: `10-review-detail-customer-workspace-context.png`
|
||||
|
||||
### Step 9 — Final workspace state after create-next-review
|
||||
- Step: return to `Customer Review Workspace` filtered to environment `38`
|
||||
- URL: `http://localhost/admin/reviews/workspace?environment_id=38`
|
||||
- Visible title: `Customer Review Workspace - TenantPilot`
|
||||
- Primary guidance state: not present
|
||||
- Primary action: not present
|
||||
- Secondary actions: not present
|
||||
- Clicked action: direct workspace return
|
||||
- Expected result: show current draft / successor review as the next repo-backed step
|
||||
- Actual result:
|
||||
- browser-verifiziert: workspace now shows `No released customer reviews match the active environment filter.`
|
||||
- repo-verifiziert: environment `38` has draft `14` and released review `3` is superseded by it
|
||||
- nicht vorhanden: `Open draft review`
|
||||
- Scope preserved? yes
|
||||
- Console errors? no
|
||||
- Network/server errors? no visible server error page
|
||||
- Toast/notification: none
|
||||
- State changed? yes, but the state is not actionable from the workspace
|
||||
- Operator clarity: low
|
||||
- Issue: the main operator loop breaks after the successful creation of a next review
|
||||
- Severity: P1
|
||||
- Screenshot: `11-final-customer-review-workspace-state.png`
|
||||
|
||||
## Screenshot Index
|
||||
|
||||
| Step | Screenshot | Notes |
|
||||
| --- | --- | --- |
|
||||
| Extra | `00-workspace-env39-no-released-review.png` | Existing-draft environment filtered to an empty released-review state |
|
||||
| 1 | `01-customer-review-workspace-output-not-ready.png` | Good first-screen blocked-output hierarchy |
|
||||
| 2 | `02-create-next-review-primary-action.png` | Confirmation modal present |
|
||||
| 3 | `03-after-create-next-review-draft.png` | Draft detail opened after mutation |
|
||||
| 4 | `04-draft-review-guidance.png` | Draft detail shows refresh-first guidance |
|
||||
| 5 | `05-refresh-review-inputs-if-available.png` | Refresh confirmation |
|
||||
| 5 result | `06-after-refresh-result.png` | No clear operator-facing success cue |
|
||||
| 4 / 6 | `07-publish-review-if-available.png` | `Publish review` visible on not-ready draft |
|
||||
| 6 | `08-evidence-basis-target.png` | Evidence target correct |
|
||||
| 7 | `09-operation-proof-target.png` | Operation target correct |
|
||||
| 8 | `10-review-detail-customer-workspace-context.png` | CTA suppression works in explicit customer-workspace mode |
|
||||
| 9 | `11-final-customer-review-workspace-state.png` | Final workspace loses actionable successor/draft state |
|
||||
|
||||
## Findings
|
||||
|
||||
### P0 Blockers
|
||||
- none confirmed
|
||||
|
||||
### P1 High
|
||||
- Workspace successor flow is broken once a draft exists.
|
||||
- browser-verifiziert: `environment_id=39` and post-create `environment_id=38` both end in `No released customer reviews match the active environment filter.`
|
||||
- repo-verifiziert: both environments have a draft successor (`13` for env `39`, `14` for env `38`).
|
||||
- impact: the operator cannot return to the workspace and continue with `Open draft review` or equivalent successor guidance.
|
||||
- `Publish review` is visible on non-ready draft review `14`.
|
||||
- browser-verifiziert: draft detail shows `Publication blocked` / `Output not customer-ready` and still surfaces a visible `Publish review` header CTA.
|
||||
- repo-verifiziert: `publish_review` is only hidden when the review is not mutable; it is not readiness-gated in the page action.
|
||||
- impact: the UI offers a competing high-impact mutation while the guidance says the draft is not yet publishable.
|
||||
|
||||
### P2 Medium
|
||||
- Refresh confirmation lacks clear operator feedback.
|
||||
- browser-verifiziert: confirming `Refresh review` produced no visible success toast or obvious page-state transition.
|
||||
- impact: the operator cannot tell whether the action succeeded, retried, or no-op’d.
|
||||
- Create-next-review leaves the calm workspace surface immediately and opens a normal detail surface.
|
||||
- browser-verifiziert: redirect URL is a normal review detail route with `source_surface` and `tenant_filter_id`, not `customer_workspace=1`.
|
||||
- impact: the flow is technically correct, but it weakens the “resolve from workspace” productization story and makes the return path more dependent on detail behavior.
|
||||
|
||||
### P3 Polish
|
||||
- None worth separating from the medium issues in this slice.
|
||||
|
||||
## Productization Assessment
|
||||
- Guidance quality: good on the first blocked workspace surface; mixed once the flow moves into mutable review detail.
|
||||
- Next-action clarity: good before mutation, weak after mutation because successor/draft state disappears from the workspace and `Publish review` competes with `Refresh review`.
|
||||
- Scope correctness: good; workspace `3`, environment scope, `tenant_filter_id=38`, evidence target, and operation target all stayed in the right tenant/environment.
|
||||
- Customer-safe boundary: good on the workspace and customer-workspace detail surfaces; no false customer-ready claim was shown while blocked.
|
||||
- UI density: improved on the workspace; draft detail is denser and mixes blocked guidance with broad lifecycle controls.
|
||||
- Resolve vs Diagnose: partially successful; the flow resolves into a real draft, but the loop back to the workspace does not remain actionable.
|
||||
|
||||
## Recommended Fix Scope
|
||||
|
||||
### Must be fixed before closing Spec 351
|
||||
- Restore a workspace-visible successor/draft action once a released review is superseded.
|
||||
- Align `Publish review` visibility with actual readiness, or clearly demote/gate it when the draft is blocked.
|
||||
|
||||
### Can be deferred
|
||||
- Stronger success feedback for `Refresh review` if the refresh semantics are otherwise correct.
|
||||
|
||||
### Should not be changed
|
||||
- Keep the single dominant primary CTA and subordinate supporting actions on the blocked workspace surface.
|
||||
- Keep the customer-workspace detail CTA suppression behavior.
|
||||
- Keep the evidence-basis and operation-proof targets as repo-backed supporting actions.
|
||||
|
||||
## Final Recommendation
|
||||
- Close Spec 351? no
|
||||
- Continue polish? no, fix the P1 workflow issues first
|
||||
- Next suggested spec? none until the Spec 351 operator loop is closed end-to-end
|
||||
|
After Width: | Height: | Size: 314 KiB |
|
After Width: | Height: | Size: 398 KiB |
|
After Width: | Height: | Size: 602 KiB |
|
After Width: | Height: | Size: 330 KiB |
|
After Width: | Height: | Size: 82 KiB |
|
After Width: | Height: | Size: 268 KiB |
|
After Width: | Height: | Size: 200 KiB |
|
After Width: | Height: | Size: 246 KiB |
|
After Width: | Height: | Size: 247 KiB |
|
After Width: | Height: | Size: 188 KiB |
|
After Width: | Height: | Size: 375 KiB |
|
After Width: | Height: | Size: 374 KiB |
|
After Width: | Height: | Size: 510 KiB |
|
After Width: | Height: | Size: 332 KiB |
|
After Width: | Height: | Size: 441 KiB |
|
After Width: | Height: | Size: 84 KiB |
@ -0,0 +1,52 @@
|
||||
# Requirements Checklist: Spec 351 - Review Output Resolve Actions v1
|
||||
|
||||
**Purpose**: Validate that Spec 351 is bounded, repo-based, constitution-aligned, and ready for a later implementation loop.
|
||||
**Created**: 2026-06-03
|
||||
**Feature**: `specs/351-review-output-resolve-actions-v1/spec.md`
|
||||
|
||||
## Candidate Selection And Guardrail
|
||||
|
||||
- [x] CHK001 The package names the direct user-provided Spec 351 draft as the source and ties it to the review-output productization lane instead of reopening framework work.
|
||||
- [x] CHK002 Specs 347, 349, and 350 are treated as completed or historical context only and are not rewritten or normalized.
|
||||
- [x] CHK003 The prep explicitly documents repo-truth deviations from the user draft, especially the lack of a generic workspace-level draft-open helper and the current URL-only guidance rendering.
|
||||
- [x] CHK004 The scope is narrowed to review-output resolve actions on workspace and detail surfaces only; provider, inbox, dashboard, portal, PDF, PSA, and AI lanes stay deferred.
|
||||
|
||||
## Repo Truth And Architecture
|
||||
|
||||
- [x] CHK005 The spec and plan anchor the work to existing guidance producers, existing detail lifecycle actions, and the existing workspace page-action mounting pattern.
|
||||
- [x] CHK006 The artifacts state that any new mapper or action metadata remains derived-only and request-scoped; no persistence is introduced.
|
||||
- [x] CHK007 The plan forbids direct Blade-to-service calls and requires reuse of source-owned Filament actions plus existing lifecycle services.
|
||||
- [x] CHK008 The action map documents which candidate actions are fully repo-backed, partially repo-backed, fallback-only, or deferred.
|
||||
- [x] CHK009 The prep explicitly records the current `create_next_review` confirmation gap instead of hiding it behind optimistic wording.
|
||||
|
||||
## UI/Productization Coverage
|
||||
|
||||
- [x] CHK010 UI Surface Impact is explicit and consistent with changing the dominant CTA semantics on `CustomerReviewWorkspace` and Environment Review detail.
|
||||
- [x] CHK011 UI/Productization Coverage reuses the existing page-report identities `ui-006` and `ui-040` and does not invent a new route taxonomy.
|
||||
- [x] CHK012 The spec requires one dominant next action plus honest fallback and keeps diagnostics/evidence secondary.
|
||||
- [x] CHK013 Customer-workspace detail-mode CTA suppression remains an explicit invariant.
|
||||
|
||||
## Testing And Validation
|
||||
|
||||
- [x] CHK014 Planned tests cover deterministic action selection, workspace integration, detail suppression/alignment, and one bounded browser smoke.
|
||||
- [x] CHK015 Validation commands explicitly rerun Specs 347, 349, 350, and `CustomerReviewWorkspace` regressions.
|
||||
- [x] CHK016 The artifacts name `pint --dirty` and `git diff --check` as final validation steps.
|
||||
|
||||
## Readiness Gate
|
||||
|
||||
- [x] CHK017 Candidate Selection Gate passes.
|
||||
- [x] CHK018 Spec Readiness Gate passes.
|
||||
- [x] CHK019 No blocking product question remains; the only remaining implementation choice is whether copy retains current repo-real labels or adopts clearer wording while keeping shared behavior consistent.
|
||||
- [x] CHK020 No application implementation has been performed in this preparation step.
|
||||
- [x] CHK021 Preparation analyze result: pass via repo-based artifact review checklist; no standalone local `speckit.analyze` command is available in this repo surface.
|
||||
|
||||
## Review Outcome
|
||||
|
||||
- [x] CHK022 Review outcome class: `confirmation-required-before-executable-cta`
|
||||
- [x] CHK023 Workflow outcome: `keep`
|
||||
- [x] CHK024 Final note location is the active feature PR close-out entry `Guardrail / Confirmation / Smoke Coverage`.
|
||||
|
||||
## Notes
|
||||
|
||||
- The executable guidance bridge is acceptable only because the spec keeps it review-output-only, reuses source-owned Filament actions, forbids a second workflow/action runtime, and requires confirmation before any dominant executable `create_next_review` CTA.
|
||||
- This checklist validates preparation readiness only. No application implementation, runtime test execution, or browser smoke has been performed in this prep step.
|
||||
@ -0,0 +1,51 @@
|
||||
# Review Output Resolve Action Map: Spec 351
|
||||
|
||||
**Status**: preparation contract
|
||||
**Updated**: 2026-06-03
|
||||
**Feature**: `specs/351-review-output-resolve-actions-v1/spec.md`
|
||||
|
||||
This file records which candidate resolve actions are truly repo-backed today, where they come from, and what fallback the mapper must use when the execution surface is missing or unsafe.
|
||||
|
||||
## Action Matrix
|
||||
|
||||
| Candidate action | Repo-backed today? | Current source | Capability / policy | Mutating? | Confirmation today? | Audit / `OperationRun` behavior | Allowed UI contexts now | Fallback if unavailable |
|
||||
|---|---|---|---|---|---|---|---|---|
|
||||
| Open successor review | partial | existing Environment Review view route via `EnvironmentReviewResource::environmentScopedUrl()` when a target review is already known | existing `view` policy | no | n/a | no new audit / run | detail or workspace only when a concrete successor review target is known | `Create next review` or review/detail fallback |
|
||||
| Create next review | yes | `ViewEnvironmentReview::createNextReviewAction()` -> `EnvironmentReviewLifecycleService::createNextReview()` | `Capabilities::ENVIRONMENT_REVIEW_MANAGE`, `EnvironmentReviewPolicy::createNextReview()` | yes | **no current confirmation** | audit: `EnvironmentReviewSuccessorCreated`; successor review creation reuses existing review composition `OperationRun` path | detail today; workspace can reuse as page action | review/detail fallback |
|
||||
| Refresh review | yes | `ViewEnvironmentReview::refreshReviewAction()` -> `EnvironmentReviewService::refresh()` | `Capabilities::ENVIRONMENT_REVIEW_MANAGE`, `EnvironmentReviewPolicy::refresh()` | yes | yes | audit: `EnvironmentReviewRefreshed`; review composition `OperationRun` path reused | detail today; workspace can reuse as page action | `Open evidence basis` |
|
||||
| Publish review | yes | `ViewEnvironmentReview::publishReviewAction()` -> `EnvironmentReviewLifecycleService::publish()` | `Capabilities::ENVIRONMENT_REVIEW_MANAGE`, `EnvironmentReviewPolicy::publish()` | yes | yes | audit: `EnvironmentReviewPublished`; no new run | detail today; workspace can reuse as page action | open review / detail fallback |
|
||||
| Open evidence basis | yes | existing Evidence Snapshot detail route from review surfaces | `Capabilities::EVIDENCE_VIEW` for link visibility | no | n/a | none | workspace and detail today | open review / limitations fallback |
|
||||
| Refresh evidence | yes, but outside review-output surfaces | `ViewEvidenceSnapshot::refresh_evidence` -> `EvidenceSnapshotService::refresh()` | `Capabilities::EVIDENCE_MANAGE` | yes | yes | evidence-owned audit/queue semantics | evidence detail only today | `Open evidence basis` |
|
||||
| Open operation proof | yes | `OperationRunLinks::tenantlessView()` or scoped operation link | current operation visibility | no | n/a | none | workspace and detail today when a run URL exists | review/detail fallback |
|
||||
| Download internal review pack | yes | `EnvironmentReviewResource::currentReviewPackDownloadUrlFor()` | `Capabilities::REVIEW_PACK_VIEW` | no | n/a | current download audit path remains | workspace and detail today when pack is ready | review/detail fallback |
|
||||
| Review section limitations | yes | current review detail URL | existing `view` policy | no | n/a | none | workspace and detail today | review/detail fallback |
|
||||
| Review PII / redaction state | yes as disclosure/navigation | current review detail URL and current output-guidance limitations | existing `view` policy | no | n/a | none | workspace and detail today | review/detail fallback |
|
||||
| Review output limitations | yes as disclosure/navigation | current review detail URL and current output-guidance limitations | existing `view` policy | no | n/a | none | workspace and detail today | `none` |
|
||||
|
||||
## Primary Ranking Rules For Spec 351
|
||||
|
||||
Use this order, but only when the action is truly safe and available on the current surface:
|
||||
|
||||
1. Open successor review when a concrete successor review target is known.
|
||||
2. Create next review for blocked or limited published reviews.
|
||||
3. Refresh review for mutable blocked reviews.
|
||||
4. Publish review for ready mutable reviews.
|
||||
5. Open evidence basis when no stronger review lifecycle action is safely executable.
|
||||
6. Open operation proof or review/detail disclosure when the blocker is proof-owned or no execution path exists.
|
||||
|
||||
## Hard Constraints
|
||||
|
||||
- Do not emit successor-review-open navigation unless a concrete target review ID is known.
|
||||
- Do not emit executable actions that bypass existing confirmation, authorization, audit, or service-owned `OperationRun` semantics.
|
||||
- If a workspace viewer lacks the manage capability, downgrade executable review actions to truthful fallback navigation/disclosure.
|
||||
- If `create_next_review` is used as a dominant executable CTA, the implementation must add confirmation before reuse; otherwise it must degrade to truthful navigation/disclosure fallback.
|
||||
- Do not create a new generic action engine; reuse Filament page/record actions.
|
||||
|
||||
## Deferred Actions
|
||||
|
||||
These are real or plausible adjacent actions, but they are outside the primary Spec 351 slice unless implementation proves they are needed without broadening scope:
|
||||
|
||||
- workspace-side `refresh_evidence`
|
||||
- provider-readiness actions
|
||||
- governance-inbox top recommendation reuse
|
||||
- environment-dashboard resolve-action reuse
|
||||
247
specs/351-review-output-resolve-actions-v1/plan.md
Normal file
@ -0,0 +1,247 @@
|
||||
# Implementation Plan: Spec 351 - Review Output Resolve Actions v1
|
||||
|
||||
**Branch**: `351-review-output-resolve-actions-v1` | **Date**: 2026-06-03 | **Spec**: `specs/351-review-output-resolve-actions-v1/spec.md`
|
||||
**Input**: Direct user-provided Spec 351 draft plus repo truth from the current review-output guidance path and existing review lifecycle actions.
|
||||
|
||||
## Summary
|
||||
|
||||
Productize Spec 350's review-output-first contract into one bounded review-output resolve-action layer that can surface a real next step where the repo already supports one:
|
||||
|
||||
1. choose the strongest safe next action
|
||||
2. explain why it is recommended
|
||||
3. execute it only through existing source-owned Filament actions and services
|
||||
4. degrade honestly to navigation or disclosure when execution is unavailable
|
||||
|
||||
This slice stays review-output-only and touches only the current review-output consumers:
|
||||
|
||||
- `CustomerReviewWorkspace`
|
||||
- Environment Review detail
|
||||
|
||||
It must not:
|
||||
|
||||
- broaden into provider readiness, governance inbox, or dashboard reuse
|
||||
- invent a new workflow engine or action runtime
|
||||
- create fake remediation or fake draft discovery
|
||||
- add persistence, schema, or new lifecycle states
|
||||
|
||||
## Technical Context
|
||||
|
||||
- **Language/Version**: PHP 8.4.15, Laravel 12.52.x
|
||||
- **Primary Dependencies**: Filament 5.2.x, Livewire 4.1.x, Pest 4, Tailwind CSS 4
|
||||
- **Storage**: PostgreSQL; no schema change expected
|
||||
- **Testing**: Pest Unit + Feature/Livewire + one bounded Browser smoke
|
||||
- **Validation Lanes**: fast-feedback + confidence + browser
|
||||
- **Target Platform**: `apps/platform` Laravel monolith, Sail-first locally
|
||||
- **Project Type**: server-rendered Filament web application
|
||||
- **Performance Goals**: derived-only action selection, no new remote calls during render, no new queue family, no duplicate lifecycle dispatch
|
||||
- **Constraints**: no fake executable actions, no direct service calls from Blade, no duplicate primary CTAs on detail, no provider/dashboard scope growth
|
||||
- **Scale/Scope**: one deterministic mapper, one review-output adapter integration, one workspace-side executable action bridge, one non-duplicative detail alignment
|
||||
|
||||
## UI / Surface Guardrail Plan
|
||||
|
||||
- **Guardrail scope**: changed strategic workspace surface plus changed review-detail guidance semantics
|
||||
- **Affected routes/pages/actions/states/navigation/panel/provider surfaces**:
|
||||
- `/admin/reviews/workspace`
|
||||
- existing Environment Review detail route(s)
|
||||
- existing workspace-mounted page actions and Environment Review header lifecycle actions
|
||||
- **No-impact class, if applicable**: N/A
|
||||
- **Native vs custom classification summary**: native Filament page/resource surfaces plus existing custom Blade rendering
|
||||
- **Shared-family relevance**: review-output guidance, lifecycle actions, evidence/proof follow-up links
|
||||
- **State layers in scope**: page, detail, URL-query, derived action-state envelope
|
||||
- **Audience modes in scope**: operator-MSP, manager, readonly reviewer, customer-workspace detail context
|
||||
- **Decision/diagnostic/raw hierarchy plan**: dominant next action first; limitations and evidence second; technical detail third
|
||||
- **Raw/support gating plan**: unchanged; raw/support detail stays on source-owned surfaces
|
||||
- **One-primary-action / duplicate-truth control**: workspace gets one dominant resolve action; detail aligns to one dominant lifecycle action without creating a second equal-weight rail
|
||||
- **Handling modes by drift class or surface**: review-mandatory
|
||||
- **Repository-signal treatment**: review-mandatory because the slice makes high-impact review actions more visible
|
||||
- **Special surface test profiles**: `global-context-shell` + `shared-detail-family`
|
||||
- **Required tests or manual smoke**: Unit + Feature + one bounded Browser smoke
|
||||
- **Exception path and spread control**: if workspace execution requires more than mounting source-owned Filament actions, stop and keep the action as fallback navigation/disclosure
|
||||
- **Active feature PR close-out entry**: Guardrail / Confirmation / Smoke Coverage
|
||||
- **UI/Productization coverage decision**: update `ui-006` and `ui-040` only; no new route archetype or registry row is required
|
||||
- **Coverage artifacts to update**: `docs/ui-ux-enterprise-audit/page-reports/ui-006-customer-review-workspace.md`, `docs/ui-ux-enterprise-audit/page-reports/ui-040-environment-review-detail.md`
|
||||
- **No-impact rationale**: N/A
|
||||
- **Navigation / Filament provider-panel handling**: no route or provider change
|
||||
- **Screenshot or page-report need**: yes; this is a first-screen CTA trust change on existing strategic surfaces
|
||||
|
||||
## Shared Pattern & System Fit
|
||||
|
||||
- **Cross-cutting feature marker**: yes
|
||||
- **Systems touched**:
|
||||
- `App\Support\ReviewPacks\ReviewPackOutputResolutionGuidance`
|
||||
- `App\Support\ResolutionGuidance\ResolutionAction`
|
||||
- `App\Support\ResolutionGuidance\ResolutionCase`
|
||||
- `App\Support\ResolutionGuidance\Adapters\ReviewPackOutputResolutionAdapter`
|
||||
- `App\Filament\Pages\Reviews\CustomerReviewWorkspace`
|
||||
- `App\Filament\Resources\EnvironmentReviewResource`
|
||||
- `App\Filament\Resources\EnvironmentReviewResource\Pages\ViewEnvironmentReview`
|
||||
- `App\Support\Ui\GovernanceActions\GovernanceActionCatalog`
|
||||
- **Shared abstractions reused**:
|
||||
- current review-output readiness derivation
|
||||
- existing `ResolutionCase` / `ResolutionAction` contract
|
||||
- existing Filament page-action mounting pattern on `CustomerReviewWorkspace`
|
||||
- existing lifecycle actions and services on `ViewEnvironmentReview`
|
||||
- current scoped route helpers and `OperationRunLinks`
|
||||
- **New abstraction introduced? why?**: yes, one `ReviewOutputResolveActionMapper` is required because the repo has multiple real action sources and fallbacks, but current review-output guidance remains link-first and not execution-aware
|
||||
- **Why the existing abstraction was sufficient or insufficient**: Spec 350 standardized the case shape, but not the deterministic ranking of real resolve actions or the surface-safe execution bridge for those actions
|
||||
- **Bounded deviation / spread control**: any action execution metadata added to `ResolutionAction` must stay optional, review-output-only, and derived-only
|
||||
|
||||
## OperationRun UX Impact
|
||||
|
||||
- **Touches OperationRun start/completion/link UX?**: yes, indirectly
|
||||
- **Central contract reused**:
|
||||
- `EnvironmentReviewService::refresh()`
|
||||
- `EnvironmentReviewLifecycleService::createNextReview()`
|
||||
- existing `OperationRunLinks`
|
||||
- **Delegated UX behaviors**: queueing, dedupe, run creation, and notifications remain owned by the current services and shared UX paths
|
||||
- **Surface-owned behavior kept local**: action ranking, explanation text, and honest fallback selection
|
||||
- **Queued DB-notification policy**: unchanged
|
||||
- **Terminal notification path**: unchanged
|
||||
- **Exception path**: none
|
||||
|
||||
## Provider Boundary & Portability Fit
|
||||
|
||||
N/A - the slice stays on review, evidence, pack, and operation surfaces only.
|
||||
|
||||
## Current Repo Truth Summary
|
||||
|
||||
- `ReviewPackOutputResolutionGuidance` already derives rich output state, limitation, and link/disclosure copy from `ReviewPackOutputReadiness`.
|
||||
- `ReviewPackOutputResolutionAdapter` already wraps that guidance into `ResolutionCase`, but today it can only promote safe navigation/download/disclosure actions.
|
||||
- `ViewEnvironmentReview` already exposes repo-real lifecycle actions:
|
||||
- `refresh_review` backed by `EnvironmentReviewService::refresh()`
|
||||
- `publish_review` backed by `EnvironmentReviewLifecycleService::publish()`
|
||||
- `create_next_review` backed by `EnvironmentReviewLifecycleService::createNextReview()`
|
||||
- `create_next_review` is capability-gated and audited, but it is **not** currently confirmation-gated even though it mutates review lifecycle state and starts the next review cycle.
|
||||
- `EnvironmentReviewLifecycleService::createNextReview()` supersedes the published review and returns a mutable successor review; the repo has no generic workspace-level "open existing draft review" helper unless a concrete target review is already known.
|
||||
- `CustomerReviewWorkspace` already mounts real Filament page actions from custom Blade via `mountAction()` and includes `<x-filament-actions::modals />`, so workspace-side execution of bounded actions is feasible without inventing a second runtime.
|
||||
- Environment Review detail already has dominant header lifecycle actions, and customer-workspace detail mode intentionally suppresses repeated action rails inside the guidance card.
|
||||
- `EvidenceSnapshotResource\Pages\ViewEvidenceSnapshot` already exposes `refresh_evidence` as a repo-real action, but review-output surfaces currently only link to evidence detail rather than executing evidence refresh directly.
|
||||
- `CustomerReviewWorkspace` is viewable by readonly actors, so executable resolve actions on the workspace must be capability-aware and honest when unavailable.
|
||||
|
||||
## Implementation Approach
|
||||
|
||||
### Phase 0 - Repo Truth Gate
|
||||
|
||||
1. Re-read `spec.md`, `plan.md`, `tasks.md`, `repo-truth-map.md`, `contracts/review-output-resolve-action-map.md`, and `checklists/requirements.md`.
|
||||
2. Re-verify:
|
||||
- `apps/platform/app/Support/ReviewPacks/ReviewPackOutputResolutionGuidance.php`
|
||||
- `apps/platform/app/Support/ResolutionGuidance/Adapters/ReviewPackOutputResolutionAdapter.php`
|
||||
- `apps/platform/app/Filament/Pages/Reviews/CustomerReviewWorkspace.php`
|
||||
- `apps/platform/app/Filament/Resources/EnvironmentReviewResource.php`
|
||||
- `apps/platform/app/Filament/Resources/EnvironmentReviewResource/Pages/ViewEnvironmentReview.php`
|
||||
- `apps/platform/app/Services/EnvironmentReviews/EnvironmentReviewService.php`
|
||||
- `apps/platform/app/Services/EnvironmentReviews/EnvironmentReviewLifecycleService.php`
|
||||
- `apps/platform/app/Filament/Resources/EvidenceSnapshotResource/Pages/ViewEvidenceSnapshot.php`
|
||||
3. Keep the repo-truth docs current if the narrowest safe action map changes during implementation.
|
||||
|
||||
### Phase 1 - Tests First
|
||||
|
||||
1. Add `apps/platform/tests/Unit/ResolutionGuidance/Spec351ReviewOutputResolveActionMapperTest.php`.
|
||||
2. Cover deterministic selection for:
|
||||
- published blocked review with known successor review
|
||||
- published blocked review without known successor review
|
||||
- published blocked review without safe executable action
|
||||
- mutable review with stale/missing evidence
|
||||
- ready mutable review
|
||||
- internal-only / PII / export-not-ready / no-action fallback
|
||||
3. Add workspace/detail feature tests:
|
||||
- `apps/platform/tests/Feature/Filament/Spec351CustomerReviewWorkspaceResolveActionTest.php`
|
||||
- `apps/platform/tests/Feature/EnvironmentReview/Spec351EnvironmentReviewResolveActionTest.php`
|
||||
4. Add one browser smoke:
|
||||
- `apps/platform/tests/Browser/Spec351ReviewOutputResolveActionsSmokeTest.php`, covering at least one blocked path and one ready-state path
|
||||
5. Reuse Spec 347/349/350 coverage instead of duplicating readiness/output assertions wholesale.
|
||||
|
||||
### Phase 2 - Core Mapper And Contract Extension
|
||||
|
||||
1. Create `apps/platform/app/Support/ResolutionGuidance/ReviewOutputResolveActionMapper.php`.
|
||||
2. Feed it:
|
||||
- the current `EnvironmentReview`
|
||||
- current review-output guidance/readiness truth
|
||||
- known linked targets such as evidence, review detail, operation proof, download URL
|
||||
- surface context (`customer_review_workspace`, `environment_review_detail`, `environment_review_detail.customer_workspace`)
|
||||
- actor/capability availability where action execution must differ by viewer
|
||||
3. Deterministic ranking order:
|
||||
- known successor review navigation when a concrete target exists
|
||||
- `create_next_review` for blocked/limited published reviews
|
||||
- `refresh_review` for mutable reviews with stale or incomplete inputs
|
||||
- `publish_review` for ready mutable reviews
|
||||
- evidence/proof/disclosure fallback when no safe execution path exists
|
||||
4. Extend `ResolutionAction` only if needed so a case can carry missing source-owned execution-routing metadata such as `action_name` and `execution_surface`, while reusing existing `disabled_reason`, capability, confirmation, audit-event, and `OperationRun` metadata.
|
||||
5. Make the mapper the canonical selector of `resolution_case.primary_action` and `secondary_actions` for the two in-scope surfaces. `ReviewPackOutputResolutionGuidance` remains the source for readiness, limitations, and explanatory copy; its raw action fields are compatibility data and must not drive CTA ranking on workspace or detail.
|
||||
6. Keep the contract derived-only and request-scoped.
|
||||
|
||||
### Phase 3 - Workspace-Side Source-Owned Action Execution
|
||||
|
||||
1. Add or reuse `CustomerReviewWorkspace` page actions for:
|
||||
- `refresh_review`
|
||||
- `publish_review`
|
||||
- `create_next_review`
|
||||
2. Reuse existing services, `GovernanceActionCatalog` where already repo-real, `UiEnforcement`, capability checks, notifications, and audit behavior.
|
||||
3. Mount those actions from the workspace decision card via `mountAction()` and existing Filament modals.
|
||||
4. Do not call lifecycle services directly from Blade.
|
||||
5. If a current actor or surface cannot safely execute the dominant action, downgrade to the strongest truthful navigation/disclosure fallback instead of showing a fake enabled button.
|
||||
6. If `create_next_review` becomes a dominant workspace CTA, add `requiresConfirmation()` to the source-owned action before exposing it as executable; otherwise degrade it to truthful navigation/disclosure fallback. No exception path is allowed for executable use.
|
||||
|
||||
### Phase 4 - Adapter Integration And Workspace Behavior
|
||||
|
||||
1. Update `ReviewPackOutputResolutionAdapter` so the dominant action comes from the new mapper while preserving current case/title/reason/impact/source/evidence data.
|
||||
2. Preserve current findings and accepted-risk follow-up overrides on `CustomerReviewWorkspace`; those remain higher-priority than the base review-output case.
|
||||
3. Render exactly one dominant primary CTA in the workspace decision card and move all other mapped actions into a clearly secondary supporting-action group.
|
||||
4. Keep current technical details and grouped limitations disclosure intact unless a narrower improvement is required for clarity.
|
||||
5. Keep the review-consumption flow on the workspace as a supporting reference below the primary decision surfaces instead of a peer decision card.
|
||||
6. Do not regress qualified download wording or honest internal-only wording.
|
||||
|
||||
### Phase 5 - Environment Review Detail Alignment
|
||||
|
||||
1. Align `EnvironmentReviewResource::outputGuidanceState()` to the same resolve-action semantics.
|
||||
2. Preserve current `customer_workspace` detail-mode suppression and context note.
|
||||
3. In normal detail mode, do **not** create two equal-weight primary action rails when the header already carries the same lifecycle action.
|
||||
4. Satisfy the detail requirement by aligning the guidance card `next step` semantics and copy to the existing dominant header action. Do not move lifecycle CTA ownership out of `ViewEnvironmentReview` in this slice.
|
||||
5. Only offer successor-review-open navigation on detail when a concrete successor review target is actually known.
|
||||
|
||||
### Phase 6 - Copy, Audit, And Browser Proof
|
||||
|
||||
1. Update only the required copy keys in:
|
||||
- `apps/platform/lang/en/localization.php`
|
||||
- `apps/platform/lang/de/localization.php`
|
||||
2. Clarify acknowledgement copy so it never implies acknowledgement alone makes an output customer-ready.
|
||||
3. Split the workspace review-pack state presentation into package-exists, internal-export, and customer-sharing semantics without adding new persistence or workflow state.
|
||||
4. If repo truth keeps the current labels (`Create next review`, `Refresh review`) instead of the user-draft wording (`Create next review draft`, `Refresh review inputs`), document that decision in the implementation close-out.
|
||||
5. Update:
|
||||
- `docs/ui-ux-enterprise-audit/page-reports/ui-006-customer-review-workspace.md`
|
||||
- `docs/ui-ux-enterprise-audit/page-reports/ui-040-environment-review-detail.md`
|
||||
6. Capture screenshots under `specs/351-review-output-resolve-actions-v1/artifacts/screenshots/`.
|
||||
7. When a persistent ready-path demo is needed in local/testing, seed it via a bounded Artisan browser-fixture command instead of mutating product code or widening browser smoke scope.
|
||||
|
||||
### Phase 7 - Validation And Close-Out
|
||||
|
||||
1. Run focused Spec 351 Unit, Feature, and Browser coverage.
|
||||
2. Re-run filtered regressions for Specs 347, 349, 350, and `CustomerReviewWorkspace`.
|
||||
3. Run `pint --dirty` and `git diff --check`.
|
||||
4. Report any out-of-scope failures separately without widening implementation scope.
|
||||
|
||||
## Validation Plan
|
||||
|
||||
```bash
|
||||
cd apps/platform
|
||||
./vendor/bin/sail artisan test tests/Unit/ResolutionGuidance/Spec351ReviewOutputResolveActionMapperTest.php --compact
|
||||
./vendor/bin/sail artisan test tests/Feature/Filament/Spec351CustomerReviewWorkspaceResolveActionTest.php tests/Feature/EnvironmentReview/Spec351EnvironmentReviewResolveActionTest.php --compact
|
||||
./vendor/bin/sail artisan test tests/Feature/Console/TenantpilotSeedReviewOutputBrowserFixtureCommandTest.php --compact
|
||||
./vendor/bin/sail php vendor/bin/pest tests/Browser/Spec351ReviewOutputResolveActionsSmokeTest.php --compact
|
||||
./vendor/bin/sail artisan tenantpilot:review-output:seed-browser-fixture --no-interaction
|
||||
./vendor/bin/sail artisan test --compact --filter=Spec347
|
||||
./vendor/bin/sail artisan test --compact --filter=Spec349
|
||||
./vendor/bin/sail artisan test --compact --filter=Spec350
|
||||
./vendor/bin/sail artisan test --compact --filter=CustomerReviewWorkspace
|
||||
./vendor/bin/sail pint --dirty
|
||||
git diff --check
|
||||
```
|
||||
|
||||
## Deployment Impact
|
||||
|
||||
- **Env vars**: none expected
|
||||
- **Migrations**: none
|
||||
- **Queues / scheduler**: no new queue family; existing review refresh / next-review queue behavior reused
|
||||
- **Storage**: none
|
||||
- **Assets**: no new Filament asset registration expected; `filament:assets` is not newly required by this slice
|
||||
- **Panel providers / global search**: unchanged; Filament v5 / Livewire v4 posture remains, and provider registration stays in `apps/platform/bootstrap/providers.php`
|
||||
162
specs/351-review-output-resolve-actions-v1/repo-truth-map.md
Normal file
@ -0,0 +1,162 @@
|
||||
# Repo Truth Map: Spec 351 - Review Output Resolve Actions v1
|
||||
|
||||
**Status**: preparation context
|
||||
**Updated**: 2026-06-03
|
||||
**Feature**: `specs/351-review-output-resolve-actions-v1/spec.md`
|
||||
|
||||
## Scope Boundary
|
||||
|
||||
This prep is intentionally limited to review-output resolution actions on:
|
||||
|
||||
- `CustomerReviewWorkspace`
|
||||
- Environment Review detail
|
||||
|
||||
Deferred from this prep:
|
||||
|
||||
- Governance Inbox reuse
|
||||
- Provider readiness / required permissions reuse
|
||||
- Environment dashboard reuse
|
||||
- Portal / PDF / PSA / AI follow-up
|
||||
|
||||
## Existing Review-Output Derivation Path
|
||||
|
||||
Current review-output truth flows through:
|
||||
|
||||
1. `App\Support\ReviewPacks\ReviewPackOutputReadiness`
|
||||
2. `App\Support\ReviewPacks\ReviewPackOutputResolutionGuidance`
|
||||
3. `App\Support\ResolutionGuidance\Adapters\ReviewPackOutputResolutionAdapter`
|
||||
4. first consumers:
|
||||
- `App\Filament\Pages\Reviews\CustomerReviewWorkspace`
|
||||
- `App\Filament\Resources\EnvironmentReviewResource`
|
||||
|
||||
What this already solves:
|
||||
|
||||
- derives output state, limitation list, impact, and qualified download wording
|
||||
- derives a `ResolutionCase` envelope for review output
|
||||
- keeps findings and accepted-risk follow-up overrides on the workspace
|
||||
- keeps customer-workspace detail mode free of duplicate CTA rails
|
||||
|
||||
What it does **not** yet solve:
|
||||
|
||||
- deterministic selection of repo-backed review lifecycle actions as the dominant next step
|
||||
- execution of those actions from the workspace decision card
|
||||
- safe distinction between executable action, honest fallback navigation, and disclosure-only action
|
||||
|
||||
## Existing Repo-Backed Review Lifecycle Actions
|
||||
|
||||
### Environment Review detail
|
||||
|
||||
`App\Filament\Resources\EnvironmentReviewResource\Pages\ViewEnvironmentReview`
|
||||
|
||||
- `refresh_review`
|
||||
- label from `GovernanceActionCatalog::rule('refresh_review')`
|
||||
- service: `EnvironmentReviewService::refresh()`
|
||||
- confirmation: yes
|
||||
- capability: `Capabilities::ENVIRONMENT_REVIEW_MANAGE`
|
||||
- audit: yes (`EnvironmentReviewRefreshed`)
|
||||
- `OperationRun`: yes, via existing review composition flow
|
||||
|
||||
- `publish_review`
|
||||
- label from `GovernanceActionCatalog::rule('publish_review')`
|
||||
- service: `EnvironmentReviewLifecycleService::publish()`
|
||||
- confirmation: yes
|
||||
- capability: `Capabilities::ENVIRONMENT_REVIEW_MANAGE`
|
||||
- audit: yes (`EnvironmentReviewPublished`)
|
||||
- `OperationRun`: no new run; publish is an audited lifecycle state transition
|
||||
|
||||
- `create_next_review`
|
||||
- label: `Create next review`
|
||||
- service: `EnvironmentReviewLifecycleService::createNextReview()`
|
||||
- confirmation: **no current confirmation**
|
||||
- capability: `Capabilities::ENVIRONMENT_REVIEW_MANAGE`
|
||||
- audit: yes (`EnvironmentReviewSuccessorCreated`)
|
||||
- `OperationRun`: indirectly yes through `EnvironmentReviewService::create()` / queued composition of the successor review
|
||||
|
||||
### Evidence detail
|
||||
|
||||
`App\Filament\Resources\EvidenceSnapshotResource\Pages\ViewEvidenceSnapshot`
|
||||
|
||||
- `refresh_evidence`
|
||||
- service: `EvidenceSnapshotService::refresh()`
|
||||
- confirmation: yes
|
||||
- capability: `Capabilities::EVIDENCE_MANAGE`
|
||||
- audit / queue semantics: evidence-owned and already repo-real
|
||||
|
||||
This matters because Spec 351 may use `Open evidence basis` as the primary review-output fallback, while `Refresh evidence` remains optional unless a safe review-surface reuse path is proven.
|
||||
|
||||
## Existing Workspace-Side Executable Action Pattern
|
||||
|
||||
`App\Filament\Pages\Reviews\CustomerReviewWorkspace`
|
||||
|
||||
- already defines a real Filament page action: `acknowledgeReviewAction()`
|
||||
- the Blade view already mounts actions via `wire:click="mountAction(...)"`
|
||||
- the Blade view already includes `<x-filament-actions::modals />`
|
||||
|
||||
Repo-truth implication:
|
||||
|
||||
- workspace-side execution of bounded review actions is feasible without inventing a second action system
|
||||
- implementation should prefer source-owned Filament page actions plus existing services and modal patterns
|
||||
- direct service calls from Blade are unnecessary and should remain forbidden
|
||||
|
||||
## Current Detail-Surface Constraint
|
||||
|
||||
`apps/platform/resources/views/filament/infolists/entries/review-pack-output-guidance.blade.php`
|
||||
|
||||
- current guidance card renders only URL-based buttons
|
||||
- normal Environment Review detail already has header lifecycle actions
|
||||
- customer-workspace detail mode suppresses action buttons inside the guidance card and shows context note only
|
||||
|
||||
Repo-truth implication:
|
||||
|
||||
- Spec 351 must avoid duplicate primary CTA rails on detail
|
||||
- the detail requirement can be satisfied by aligning the guidance card next-step semantics to the existing dominant header action instead of adding a second equal-weight execution button
|
||||
|
||||
## Draft / Successor Semantics
|
||||
|
||||
`App\Services\EnvironmentReviews\EnvironmentReviewLifecycleService::createNextReview()`
|
||||
|
||||
- only published reviews can start the next cycle
|
||||
- the service creates or reuses a mutable successor review through `EnvironmentReviewService::create()`
|
||||
- when a new successor review is created, the published review becomes `superseded`
|
||||
- the source review stores `superseded_by_review_id`
|
||||
|
||||
Repo-truth implication:
|
||||
|
||||
- a generic "Open existing draft review" action is not currently a standalone workspace helper
|
||||
- it is only repo-real when a concrete successor review target is already known, for example via `superseded_by_review_id` or another deterministic lookup proven at implementation time
|
||||
- the workspace must not fabricate this action from a purely conceptual draft state
|
||||
|
||||
## Authorization And Surface Availability
|
||||
|
||||
- `CustomerReviewWorkspace` is visible to entitled viewers, including readonly roles
|
||||
- `EnvironmentReviewResource::outputGuidanceState()` already capability-gates evidence links
|
||||
- mutating review actions require `Capabilities::ENVIRONMENT_REVIEW_MANAGE`
|
||||
- evidence refresh requires `Capabilities::EVIDENCE_MANAGE`
|
||||
- current review-output guidance does not yet differentiate actionable mutation from honest fallback by viewer capability
|
||||
|
||||
Repo-truth implication:
|
||||
|
||||
- action mapping must consider both repo-backed behavior **and** current actor/surface execution availability
|
||||
- readonly viewers on the workspace should see truthful fallback navigation/disclosure instead of fake enabled mutation buttons
|
||||
|
||||
## Confirmed Deviations From The User Draft
|
||||
|
||||
- The repo already has `Create next review`, `Refresh review`, and `Publish review`; Spec 351 does not need to invent those actions.
|
||||
- The repo does **not** currently have a generic workspace-level "Open draft review" resolver.
|
||||
- The repo already has a safe evidence-refresh action, but it lives on evidence detail, not on current review-output surfaces.
|
||||
- `create_next_review` is repo-real but lacks confirmation today; surfacing it more prominently makes that gap visible and must be handled intentionally.
|
||||
|
||||
## Preparation Decision
|
||||
|
||||
Spec 351 should implement:
|
||||
|
||||
- one review-output-only resolve-action mapper
|
||||
- one bounded workspace execution bridge through Filament page actions
|
||||
- one non-duplicative detail alignment path
|
||||
|
||||
It should not implement:
|
||||
|
||||
- a new action framework
|
||||
- generic draft discovery
|
||||
- provider/dashboard/governance reuse
|
||||
- a second lifecycle runtime outside existing source-owned services and actions
|
||||
352
specs/351-review-output-resolve-actions-v1/spec.md
Normal file
@ -0,0 +1,352 @@
|
||||
# Feature Specification: Spec 351 - Review Output Resolve Actions v1
|
||||
|
||||
**Feature Branch**: `351-review-output-resolve-actions-v1`
|
||||
**Created**: 2026-06-03
|
||||
**Status**: Draft
|
||||
**Type**: Platform productization / operator workflow / review-output resolution actions
|
||||
**Depends on**: Specs 347, 349, 350
|
||||
**Runtime posture**: Bounded action enablement. Reuse existing review, evidence, and pack actions only. No new workflow engine, fake remediation, customer portal, or provider/dashboard expansion.
|
||||
**Input**: Direct user-provided Spec 351 draft plus repo truth from current review-output guidance and existing environment-review lifecycle actions.
|
||||
|
||||
## Spec Candidate Check *(mandatory — SPEC-GATE-001)*
|
||||
|
||||
- **Problem**: TenantPilot can now explain why a review output is blocked or limited, but it still often stops at diagnosis instead of telling the operator what real next step the repo already supports.
|
||||
- **Today's failure**: Operators land on "Inspect review blockers" or similar disclosure-first guidance, then still have to infer whether they should create the next review, refresh the draft, publish, open evidence, or only review limitations.
|
||||
- **User-visible improvement**: Customer Review Workspace and Environment Review detail show one real next step first when a safe repo-backed action exists, keep every other action visibly secondary, separate package existence from internal export and customer sharing, and keep unsupported states honest through disclosure/navigation fallbacks.
|
||||
- **Smallest enterprise-capable version**: Review-output-only action mapping across `CustomerReviewWorkspace` and Environment Review detail, reusing existing review/evidence actions and current scoped routes.
|
||||
- **Explicit non-goals**: No workflow engine, no new persistence, no provider-readiness rollout, no governance-inbox rollout, no dashboard rollout, no customer portal, no PDF/HTML renderer, no AI suggestion system.
|
||||
- **Permanent complexity imported**: One deterministic mapper, one review-output action-capability map, focused Unit/Feature/Browser tests, and possibly a small derived extension to the existing `ResolutionAction` contract for source-owned action execution metadata.
|
||||
- **Why now**: Spec 350 already established the shared review-output-first contract. The next gap is productization: operators still lack a clear "do this now" step on the same surfaces.
|
||||
- **Why not local**: The gap spans two existing consumers of the same review-output truth, and the current adapter/view flow can only express URL-based actions. A local copy change would not safely bridge to real review lifecycle actions.
|
||||
- **Approval class**: Workflow Compression.
|
||||
- **Red flags triggered**: New meta-infrastructure is limited to one mapper over an existing contract; the main defense is that it reuses already-real actions and stays review-output-only.
|
||||
- **Score**: Nutzen: 2 | Dringlichkeit: 2 | Scope: 2 | Komplexitaet: 1 | Produktnaehe: 2 | Wiederverwendung: 2 | **Gesamt: 11/12**
|
||||
- **Decision**: approve with repo-truth-first and no-fake-action constraints.
|
||||
|
||||
## Candidate Source And Completed-Spec Guardrail
|
||||
|
||||
- **Candidate source**:
|
||||
- direct user-provided Spec 351 draft
|
||||
- roadmap relationship: customer review completion and review-output-first operator workflow productization
|
||||
- **Why selected now**:
|
||||
- the user explicitly requested a non-framework follow-up after Spec 350
|
||||
- the repo already has real review lifecycle actions (`refresh_review`, `publish_review`, `create_next_review`) that are not yet first-class resolve actions on the main review-output guidance surfaces
|
||||
- **Close alternatives deferred**:
|
||||
- provider-readiness and required-permissions productization
|
||||
- governance-inbox reuse of the same contract
|
||||
- environment/dashboard reuse of the same contract
|
||||
- portal, PDF/HTML, PSA, and AI follow-up lanes
|
||||
- **Completed-spec guardrail result**:
|
||||
- `specs/347-review-pack-output-contract-readiness-semantics`, `specs/349-customer-review-workspace-output-resolution-guidance`, and `specs/350-operator-resolution-guidance-framework-v1` already contain checked preparation/implementation evidence and are context only
|
||||
- they must not be normalized, reopened, or rewritten during this preparation step
|
||||
- **Direct repo-truth corrections to the user draft**:
|
||||
- the repo already exposes `Create next review`, `Refresh review`, and `Publish review` as real actions on `ViewEnvironmentReview`
|
||||
- the repo does **not** currently expose a generic workspace-level "Open existing draft review" resolver; successor/draft navigation is only available when a concrete review target is known
|
||||
- current review-output guidance cards render URL buttons only, so executable resolve actions require an explicit reuse of Filament page/record actions instead of a pure copy change
|
||||
|
||||
## Spec Scope Fields *(mandatory)*
|
||||
|
||||
- **Scope**: workspace review-consumption surface plus environment-review detail.
|
||||
- **Primary Routes**:
|
||||
- `/admin/reviews/workspace`
|
||||
- existing environment-scoped Environment Review detail route(s)
|
||||
- existing Evidence Snapshot detail and OperationRun detail routes only as linked follow-up or fallback destinations
|
||||
- **Data Ownership**:
|
||||
- `EnvironmentReview`, `ReviewPack`, `EvidenceSnapshot`, and `OperationRun` remain authoritative
|
||||
- `ResolutionCase`, `ResolutionAction`, and the new mapper stay derived-only and request-scoped
|
||||
- **RBAC**:
|
||||
- workspace membership plus entitled environment access remain required for visibility
|
||||
- mutating review actions require `Capabilities::ENVIRONMENT_REVIEW_MANAGE`
|
||||
- evidence refresh remains `Capabilities::EVIDENCE_MANAGE` on evidence-owned surfaces
|
||||
- non-member or non-entitled access stays 404; member-but-missing-capability stays 403
|
||||
|
||||
## UI Surface Impact *(mandatory — UI-COV-001)*
|
||||
|
||||
Does this spec add, remove, rename, or materially change any reachable UI surface?
|
||||
|
||||
- [ ] No UI surface impact
|
||||
- [x] Existing page changed
|
||||
- [ ] New page/route added
|
||||
- [ ] Navigation changed
|
||||
- [ ] Filament panel/provider surface changed
|
||||
- [x] New modal/drawer/wizard/action added
|
||||
- [x] New table/form/state added
|
||||
- [x] Customer-facing surface changed
|
||||
- [x] Dangerous action changed
|
||||
- [x] Status/evidence/review presentation changed
|
||||
- [x] Workspace/environment context presentation changed
|
||||
|
||||
## UI/Productization Coverage *(mandatory when UI Surface Impact is not "No UI surface impact"; otherwise write `N/A - no reachable UI surface impact` plus rationale)*
|
||||
|
||||
- **Route/page/surface**:
|
||||
- `App\Filament\Pages\Reviews\CustomerReviewWorkspace`
|
||||
- `App\Filament\Resources\EnvironmentReviewResource`
|
||||
- `App\Filament\Resources\EnvironmentReviewResource\Pages\ViewEnvironmentReview`
|
||||
- existing review-output guidance card and decision card CTA zones
|
||||
- **Current or new page archetype**: existing strategic workspace review surface (`UI-006`) plus existing environment review detail (`UI-040`)
|
||||
- **Design depth**: Strategic Surface for the workspace, Domain Pattern Surface for review detail
|
||||
- **Repo-truth level**: repo-verified
|
||||
- **Existing pattern reused**:
|
||||
- `docs/ui-ux-enterprise-audit/page-reports/ui-006-customer-review-workspace.md`
|
||||
- `docs/ui-ux-enterprise-audit/page-reports/ui-040-environment-review-detail.md`
|
||||
- current Filament page-action mounting pattern already used on `CustomerReviewWorkspace`
|
||||
- **New pattern required**: one bounded review-output resolve-action mapper and one bounded reuse path from guidance cards into existing source-owned Filament actions
|
||||
- **Screenshot required**: yes, under `specs/351-review-output-resolve-actions-v1/artifacts/screenshots/`
|
||||
- **Page audit required**: yes, update `ui-006` and `ui-040`
|
||||
- **Customer-safe review required**: yes, because the workspace is an operator-facing customer-safe handoff surface
|
||||
- **Dangerous-action review required**: yes; `create_next_review`, `refresh_review`, and `publish_review` become more prominent when used as recommended resolve actions
|
||||
- **Coverage files updated or explicitly not needed**:
|
||||
- [ ] `docs/ui-ux-enterprise-audit/route-inventory.md`
|
||||
- [ ] `docs/ui-ux-enterprise-audit/design-coverage-matrix.md`
|
||||
- [x] `docs/ui-ux-enterprise-audit/page-reports/...`
|
||||
- [ ] `docs/ui-ux-enterprise-audit/strategic-surfaces.md`
|
||||
- [ ] `docs/ui-ux-enterprise-audit/grouped-follow-up-candidates.md`
|
||||
- [ ] `docs/ui-ux-enterprise-audit/unresolved-pages.md`
|
||||
- [ ] `N/A - no reachable UI surface impact`
|
||||
- **No-impact rationale when applicable**: N/A
|
||||
|
||||
## 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)**: next-action guidance, lifecycle actions, status messaging, evidence/report viewers, proof links
|
||||
- **Systems touched**:
|
||||
- `App\Support\ReviewPacks\ReviewPackOutputResolutionGuidance`
|
||||
- `App\Support\ResolutionGuidance\ResolutionAction`
|
||||
- `App\Support\ResolutionGuidance\ResolutionCase`
|
||||
- `App\Support\ResolutionGuidance\Adapters\ReviewPackOutputResolutionAdapter`
|
||||
- `App\Filament\Pages\Reviews\CustomerReviewWorkspace`
|
||||
- `App\Filament\Resources\EnvironmentReviewResource`
|
||||
- `App\Filament\Resources\EnvironmentReviewResource\Pages\ViewEnvironmentReview`
|
||||
- `App\Support\Ui\GovernanceActions\GovernanceActionCatalog`
|
||||
- **Existing pattern(s) to extend**: current review-output guidance plus current source-owned Filament lifecycle actions
|
||||
- **Shared contract / presenter / builder / renderer to reuse**:
|
||||
- `ResolutionAction` / `ResolutionCase`
|
||||
- `ReviewPackOutputResolutionAdapter`
|
||||
- existing Filament page actions on `CustomerReviewWorkspace`
|
||||
- existing Filament record actions on `ViewEnvironmentReview`
|
||||
- `UiEnforcement`, `GovernanceActionCatalog`, and scoped route helpers
|
||||
- **Why the existing shared path is sufficient or insufficient**: the contract already standardizes title/reason/impact/action shape, but it does not yet choose real resolve actions deterministically or bridge those actions into executable workspace/detail UI without falling back to URL-only guidance.
|
||||
- **Allowed deviation and why**: one mapper plus a small derived execution-metadata extension on `ResolutionAction` is allowed if it is the narrowest way to reuse source-owned actions without creating a parallel workflow/action framework.
|
||||
- **Consistency impact**: the same review-output blocker must produce the same dominant next action vocabulary across workspace and detail, while follow-up overrides and capability limits remain explicit.
|
||||
- **Review focus**: prevent direct service calls from Blade, prevent fake enabled buttons, prevent duplicate primary CTAs, and prevent the contract from broadening into a general remediation engine.
|
||||
|
||||
## 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?**: yes, indirectly
|
||||
- **Shared OperationRun UX contract/layer reused**:
|
||||
- `EnvironmentReviewService::refresh()`
|
||||
- `EnvironmentReviewLifecycleService::createNextReview()`
|
||||
- existing `OperationRunLinks`
|
||||
- **Delegated start/completion UX behaviors**: review refresh and next-review creation must keep their current service-owned queue/run behavior; the resolve-action layer only selects or mounts those actions
|
||||
- **Local surface-owned behavior that remains**: action ranking, explanation text, and capability-aware fallback selection
|
||||
- **Queued DB-notification policy**: unchanged
|
||||
- **Terminal notification path**: unchanged
|
||||
- **Exception required?**: none; resolve-action guidance must not start jobs directly
|
||||
|
||||
## 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 touched beyond already-neutral review, evidence, operation, and output terminology.
|
||||
|
||||
## 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 decision card resolve action | yes | Native Filament page + existing Blade composition | review-output guidance + lifecycle actions | page, URL-query, derived action state | yes | executable CTA reuse must stay source-owned |
|
||||
| Environment Review detail output-guidance semantics | yes | Native Filament resource + infolist Blade | review-output guidance + lifecycle actions | detail, URL-query, derived action state | no | do not duplicate header and card CTA rails |
|
||||
|
||||
## 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 | Primary Decision Surface | Decide what real next step gets the current output closer to customer-safe or publishable | issue, reason, impact, one dominant next action | evidence basis, technical details, operation proof, limitations list | primary because it is the first workspace-wide handoff surface | review handoff and follow-up workflow | removes "inspect blockers then infer the action" |
|
||||
| Environment Review detail | Secondary Context | Understand or execute the review lifecycle step for the current review | aligned next-step label and context note | sections, artifact truth, evidence, header actions, proof links | secondary because it deepens the chosen review rather than ranking workspace work | detail follow-through | avoids competing status and CTA dialects |
|
||||
|
||||
## 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 | operator-MSP, manager, readonly reviewer | output state, reason, impact, one action | evidence basis, limitations, technical detail | raw/support detail remains elsewhere | yes | mutating actions hidden, disabled, or downgraded when capability/surface is not safe | findings/accepted-risk overrides remain the only higher-priority exception |
|
||||
| Environment Review detail | operator-MSP, support, customer-workspace detail context | review/output/publication state and aligned next step | lifecycle detail, sections, evidence, pack truth | support/raw detail stays in existing deeper surfaces | yes | customer-workspace detail mode suppresses duplicate CTA rails | normal detail keeps one dominant lifecycle action, not two |
|
||||
|
||||
## 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 | Utility / Workspace Decision | Customer-safe workspace hub | create next review, refresh review, publish review, or open evidence depending on state | explicit CTA in decision card | N/A | grouped supporting links in the same card | none directly in-card unless source-owned action reuse stays bounded | `/admin/reviews/workspace` | existing environment review detail | workspace + visible environment filter | Review output | dominant blocker plus next step | action execution bridge only, no new route |
|
||||
| Environment Review detail | Detail / Artifact + Review Context | Existing review detail | inspect current review or execute the dominant lifecycle action | existing detail page | current repo-real behavior only | header `More`, evidence/proof links, limitations | existing archive/danger group remains separate | existing review register route | existing environment review detail route | workspace/environment + customer_workspace query | Environment review | review/output/publication dimensions and next step | preserve single dominant lifecycle action |
|
||||
|
||||
## 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 resolve-action card | MSP operator / manager | Decide what real next step to take for the latest released review output | workspace review handoff | What do I do now to move this output forward safely? | issue, reason, impact, one dominant action, latest released review context | limitations, technical detail, evidence/proof links | review status, output readiness, publication/sharing boundary | existing review/evidence actions only | open successor review if known, create next review, refresh review, publish review, open evidence | publish and next-cycle creation remain high-impact and must retain confirmation/audit/policy behavior |
|
||||
| Environment Review detail guidance | MSP operator / support | Understand or execute the next review lifecycle step without duplicate CTA rails | detail context | Is this review blocked, ready, or historical, and where is the real action surface? | review status, output readiness, publication boundary, aligned next step | sections, evidence, proof links, technical detail | lifecycle, output readiness, publication/sharing state | existing detail lifecycle action set | existing dominant header action or one aligned CTA | archive stays in danger group; customer_workspace mode suppresses duplicated CTAs |
|
||||
|
||||
## Workspace Hierarchy Notes
|
||||
|
||||
- The workspace decision card keeps exactly one dominant primary CTA. All other mapped actions remain secondary links or a subdued button group beneath the primary action.
|
||||
- The right-side review-pack state card must separate three semantics: package exists, internal export, and customer sharing.
|
||||
- Review acknowledgement copy must stay explicit that acknowledgement records review consumption and does not make the output customer-ready.
|
||||
- The review consumption flow remains available as supporting reference only and must not read like a second decision surface beside the main decision card.
|
||||
|
||||
## Proportionality Review *(mandatory when structural complexity is introduced)*
|
||||
|
||||
- **New source of truth?**: no
|
||||
- **New persisted entity/table/artifact?**: no
|
||||
- **New abstraction?**: yes
|
||||
- **New enum/state/reason family?**: no
|
||||
- **New cross-domain UI framework/taxonomy?**: no
|
||||
- **Current operator problem**: review-output guidance explains blockers but does not always expose the next real repo-backed action first.
|
||||
- **Existing structure is insufficient because**: `ReviewPackOutputResolutionGuidance` should remain the source for readiness, limitations, and explanatory copy, but it only derives URL/disclosure actions while the repo-real lifecycle actions live separately on detail headers and are not yet selected or mounted from one canonical resolve-action path.
|
||||
- **Narrowest correct implementation**: one review-output-only mapper that becomes the canonical selector of `resolution_case.primary_action` and `secondary_actions` for the two in-scope surfaces, plus, if strictly necessary, a small `ResolutionAction` extension for missing execution-routing metadata such as `action_name` and `execution_surface` while reusing existing `disabled_reason`, capability, confirmation, audit, and `OperationRun` metadata.
|
||||
- **Ownership cost**: one mapper, targeted tests, possible action metadata extension, and review of one current confirmation gap (`create_next_review`).
|
||||
- **Alternative intentionally rejected**: a generic remediation/resolution framework or a dashboard/provider/governance roll-out in the same slice.
|
||||
- **Release truth**: current-release truth over existing review/evidence/output surfaces.
|
||||
|
||||
### Compatibility posture
|
||||
|
||||
This feature assumes a pre-production environment.
|
||||
|
||||
Backward compatibility, legacy aliases, migration shims, historical fixtures, and compatibility-specific tests are out of scope unless explicitly required by this spec.
|
||||
|
||||
Canonical replacement is preferred over preservation.
|
||||
|
||||
## Testing / Lane / Runtime Impact *(mandatory for runtime behavior changes)*
|
||||
|
||||
- **Test purpose / classification**: Unit + Feature + Browser
|
||||
- **Validation lane(s)**: fast-feedback + confidence + browser
|
||||
- **Why this classification and these lanes are sufficient**: deterministic action selection belongs in Unit tests, workspace/detail behavior belongs in Feature tests, and one browser smoke proves the dominant CTA is visible and honest on the real surfaces.
|
||||
- **New or expanded test families**: one bounded `ResolutionGuidance` mapper family only
|
||||
- **Fixture / helper cost impact**: reuse existing review/evidence factories and helpers; no new expensive global defaults
|
||||
- **Heavy-family visibility / justification**: one browser smoke is explicit because the change affects first-screen CTA trust on strategic review surfaces
|
||||
- **Special surface test profile**: `global-context-shell` + `shared-detail-family`
|
||||
- **Standard-native relief or required special coverage**: required special coverage for workspace decision card and detail suppression behavior
|
||||
- **Reviewer handoff**: verify lane fit, check that executable actions are only source-owned, confirm no fake enabled buttons remain, and rely on the exact commands below
|
||||
- **Budget / baseline / trend impact**: none expected beyond one new bounded browser smoke
|
||||
- **Escalation needed**: document-in-feature only if `create_next_review` confirmation cannot be safely aligned in-scope
|
||||
- **Active feature PR close-out entry**: Guardrail / Confirmation / Smoke Coverage
|
||||
- **Planned validation commands**:
|
||||
- `cd apps/platform && ./vendor/bin/sail artisan test tests/Unit/ResolutionGuidance/Spec351ReviewOutputResolveActionMapperTest.php --compact`
|
||||
- `cd apps/platform && ./vendor/bin/sail artisan test tests/Feature/Filament/Spec351CustomerReviewWorkspaceResolveActionTest.php tests/Feature/EnvironmentReview/Spec351EnvironmentReviewResolveActionTest.php --compact`
|
||||
- `cd apps/platform && ./vendor/bin/sail php vendor/bin/pest tests/Browser/Spec351ReviewOutputResolveActionsSmokeTest.php --compact`
|
||||
- `cd apps/platform && ./vendor/bin/sail artisan test --compact --filter=Spec347`
|
||||
- `cd apps/platform && ./vendor/bin/sail artisan test --compact --filter=Spec349`
|
||||
- `cd apps/platform && ./vendor/bin/sail artisan test --compact --filter=Spec350`
|
||||
- `cd apps/platform && ./vendor/bin/sail artisan test --compact --filter=CustomerReviewWorkspace`
|
||||
- `cd apps/platform && ./vendor/bin/sail pint --dirty`
|
||||
- `git diff --check`
|
||||
|
||||
## User Scenarios & Testing *(mandatory)*
|
||||
|
||||
### User Story 1 - Move a blocked published output into the next safe review cycle (Priority: P1)
|
||||
|
||||
As an MSP operator, I need the workspace to show the real next review-cycle action for a blocked or limited published output so I can stop diagnosing and start moving the output forward safely.
|
||||
|
||||
**Why this priority**: This is the core productization gap after Spec 350.
|
||||
|
||||
**Independent Test**: Can be fully tested by seeding a published review with blocked output and asserting that the workspace selects a repo-backed successor/create/fallback action deterministically.
|
||||
|
||||
**Acceptance Scenarios**:
|
||||
|
||||
1. **Given** a published review with output blockers and a deterministic successor review target, **When** the workspace is opened, **Then** the dominant action opens that successor review instead of showing a fake fix.
|
||||
2. **Given** a published review with output blockers and no known successor review, **When** the workspace is opened by a manage-capable actor, **Then** the dominant action offers the existing next-cycle creation path or an honest fallback.
|
||||
|
||||
---
|
||||
|
||||
### User Story 2 - Resolve a mutable review from stale or incomplete inputs (Priority: P1)
|
||||
|
||||
As an operator on a mutable review, I need the guidance to point to the real input-refresh or evidence path that unblocks publication.
|
||||
|
||||
**Why this priority**: Draft/ready reviews already have lifecycle actions, but the guidance surfaces do not yet map to them directly.
|
||||
|
||||
**Independent Test**: Can be tested by seeding mutable reviews with missing/stale evidence and asserting refresh/evidence fallback behavior on workspace or detail.
|
||||
|
||||
**Acceptance Scenarios**:
|
||||
|
||||
1. **Given** a mutable review with stale or incomplete evidence, **When** the mapper runs, **Then** it chooses `refresh_review` only when that action is safe and available on the current surface.
|
||||
2. **Given** a mutable review without a safe execution surface for refresh, **When** the mapper runs, **Then** it falls back to `Open evidence basis` or another truthful disclosure action.
|
||||
|
||||
---
|
||||
|
||||
### User Story 3 - Publish a ready draft without CTA duplication (Priority: P2)
|
||||
|
||||
As an operator on a publishable draft, I need the guidance to align with the publish action without creating two competing primary CTAs on the detail surface.
|
||||
|
||||
**Why this priority**: The detail page already has lifecycle header actions, so Spec 351 must improve clarity without creating visual competition.
|
||||
|
||||
**Independent Test**: Can be tested by seeding a ready review or running the local/testing browser fixture command and asserting that the dominant next step matches the publish path while customer-workspace detail mode stays suppressed.
|
||||
|
||||
**Acceptance Scenarios**:
|
||||
|
||||
1. **Given** a ready mutable review, **When** the detail surface is opened normally, **Then** the next-step semantics align with `Publish review` and do not create duplicate primary action rails.
|
||||
2. **Given** the same review is opened in `customer_workspace` detail mode, **When** the guidance card renders, **Then** the CTA remains suppressed and the context note stays visible.
|
||||
|
||||
## Requirements
|
||||
|
||||
- **FR-351-001**: Add a deterministic `ReviewOutputResolveActionMapper` that is the canonical selector of one dominant next action plus optional secondary actions for `resolution_case` on the two in-scope surfaces.
|
||||
- **FR-351-002**: Published blocked or limited reviews MUST prefer a known successor-review-open path only when the target review is actually known; otherwise they MUST prefer `create_next_review` or an honest fallback.
|
||||
- **FR-351-003**: Mutable blocked reviews MUST prefer `refresh_review` only when the current surface and actor can safely execute it; otherwise they MUST fall back to evidence/detail navigation.
|
||||
- **FR-351-004**: Ready mutable reviews MUST align to the existing `publish_review` action when it is safe and permitted.
|
||||
- **FR-351-005**: Unsupported, unsafe, or unauthorized executable actions MUST degrade to truthful navigation, disclosure, or `none`; no fake enabled fix buttons are allowed.
|
||||
- **FR-351-006**: `CustomerReviewWorkspace` MUST use the mapper for the dominant review-output CTA while preserving existing findings and accepted-risk follow-up overrides.
|
||||
- **FR-351-007**: `EnvironmentReviewResource` detail MUST use the same action semantics by aligning the guidance next-step semantics to the existing dominant header lifecycle action in normal detail mode, without reintroducing duplicate primary CTA rails, and while preserving `customer_workspace` detail-mode suppression.
|
||||
- **FR-351-008**: If `create_next_review` is surfaced as a dominant executable resolve action, the source-owned action MUST require confirmation before it can be executed from workspace or detail; otherwise the mapper MUST degrade to truthful navigation, disclosure, or `none`.
|
||||
- **FR-351-009**: Any workspace-side executable CTA MUST reuse Filament page actions and existing services, not direct Blade-to-service calls.
|
||||
- **FR-351-010**: No new persistence, lifecycle states, provider/dashboard consumers, or workflow engine abstractions may be introduced in this slice.
|
||||
- **FR-351-011**: `CustomerReviewWorkspace` MUST keep exactly one dominant primary CTA in the decision card and MUST render every other mapped action as secondary navigation or subdued supporting actions, not as peer primary CTAs.
|
||||
- **FR-351-012**: `CustomerReviewWorkspace` MUST split review-pack truth into distinct package-exists, internal-export, and customer-sharing semantics instead of flattening them into one undifferentiated state list.
|
||||
- **FR-351-013**: Review acknowledgement copy MUST explicitly state that acknowledgement records review consumption and does not determine whether the output is customer-ready.
|
||||
- **FR-351-014**: The workspace review-consumption flow MUST remain a supporting reference below the primary decision surfaces and MUST not present itself as a second decision surface.
|
||||
|
||||
## Non-Goals
|
||||
|
||||
- Provider readiness, required permissions, and verification productization
|
||||
- Governance Inbox or Environment Dashboard adoption of the same contract
|
||||
- Customer portal, PDF/HTML rendering, PSA/ITSM, or AI suggestions
|
||||
- New tables, new enums, or new workflow/lifecycle engines
|
||||
- Broad review register or review detail redesign outside the resolve-action slice
|
||||
|
||||
## Acceptance Criteria
|
||||
|
||||
- **AC1**: The prep package documents which candidate resolve actions are repo-backed, partially repo-backed, fallback-only, or deferred.
|
||||
- **AC2**: Action selection is deterministic and covered by Unit tests for published blocked, mutable blocked, ready draft, internal-only, and no-action fallback states.
|
||||
- **AC3**: Workspace guidance no longer defaults to disclosure-first copy when a stronger real review-output action exists and can be executed or linked honestly.
|
||||
- **AC4**: Published reviews are never presented as directly editable; next-cycle or disclosure semantics stay explicit.
|
||||
- **AC5**: Mutable drafts can resolve to refresh or publish actions when repo-backed and permitted.
|
||||
- **AC6**: No fake successor-review-open action appears unless a real target review is known.
|
||||
- **AC7**: Environment Review detail stays non-duplicative and preserves `customer_workspace` CTA suppression.
|
||||
- **AC8**: Capability, confirmation, audit, and `OperationRun` behavior are reused from the source-owned actions.
|
||||
- **AC9**: Spec 347, 349, 350, and `CustomerReviewWorkspace` regressions are part of the implementation validation plan.
|
||||
- **AC10**: Browser smoke proves one real next step or one honest fallback on at least one blocked and one ready-state path.
|
||||
- **AC11**: In workspace browser proof, `Create next review` or the mapped dominant action is the only visually primary CTA; other actions render as secondary supporting actions.
|
||||
- **AC12**: The workspace sidebar separates package existence, internal export, and customer sharing, and the acknowledgement card states that acknowledgement does not make the output customer-ready.
|
||||
- **AC13**: Local/testing manual verification may use a bounded browser fixture command that seeds a published predecessor plus a linked ready successor review without adding new production persistence families or workflow abstractions.
|
||||
|
||||
## Risks
|
||||
|
||||
- **Risk 1**: The current guidance contract can select executable intent, but the current blades only understand URL buttons.
|
||||
- **Mitigation**: reuse existing Filament page actions on `CustomerReviewWorkspace` and avoid inline execution on detail unless it replaces, not duplicates, the header action.
|
||||
- **Risk 2**: `create_next_review` is currently repo-real but not confirmation-gated.
|
||||
- **Mitigation**: treat confirmation alignment as an explicit implementation decision for this spec before the action becomes the dominant CTA.
|
||||
- **Risk 3**: "Open successor review" may be overclaimed if no deterministic successor target exists.
|
||||
- **Mitigation**: require a known `superseded_by_review_id` or another repo-verified target before showing that action.
|
||||
- **Risk 4**: Readonly workspace viewers may see unsafe or misleading action affordances.
|
||||
- **Mitigation**: capability-aware fallback selection and no fake enabled buttons.
|
||||
|
||||
## Assumptions
|
||||
|
||||
- `CustomerReviewWorkspace` can safely mount additional Filament page actions because it already uses `mountAction()` plus `<x-filament-actions::modals />`.
|
||||
- Environment Review detail can satisfy the resolve-action contract by aligning with existing lifecycle header actions instead of always adding a second button inside the guidance card.
|
||||
- Review-output action mapping remains review-output-only in this slice; provider/dashboard/inbox reuse stays deferred.
|
||||
|
||||
## Open Questions
|
||||
|
||||
- No blocking product questions remain for preparation. Implementation may decide whether to retain current repo-real labels (`Create next review`, `Refresh review`) or adopt clearer copy (`Create next review draft`, `Refresh review inputs`) as long as the source-owned behavior and audit/capability semantics stay consistent.
|
||||
|
||||
## Follow-up Spec Candidates
|
||||
|
||||
- `352-platform-sellable-smoke-matrix`
|
||||
- `353-provider-readiness-productization`
|
||||
- `354-review-pack-pdf-html-renderer-v1`
|
||||
- `355-customer-portal-boundary-contract`
|
||||
- `356-private-ai-resolution-suggestion-foundation`
|
||||
117
specs/351-review-output-resolve-actions-v1/tasks.md
Normal file
@ -0,0 +1,117 @@
|
||||
# Tasks: Spec 351 - Review Output Resolve Actions v1
|
||||
|
||||
**Input**: `specs/351-review-output-resolve-actions-v1/spec.md`, `plan.md`, `repo-truth-map.md`, `contracts/review-output-resolve-action-map.md`, and `checklists/requirements.md`
|
||||
|
||||
**Tests**: Required. This is a review-output action-selection and operator-trust change over existing Filament pages, detail surfaces, and source-owned lifecycle actions.
|
||||
|
||||
## Test Governance Checklist
|
||||
|
||||
- [x] Lane assignment is explicit and narrow: Unit for deterministic action selection, Feature for workspace/detail integration, Browser for first-screen CTA trust proof.
|
||||
- [x] New or changed tests stay in the smallest honest family, and the browser addition is explicit.
|
||||
- [x] Shared helpers, factories, seeds, fixtures, and context defaults stay cheap by default.
|
||||
- [x] Planned validation commands cover the change without pulling unrelated lane cost.
|
||||
- [x] The declared surface profiles (`global-context-shell` and `shared-detail-family`) are explicit.
|
||||
- [x] Any new action metadata remains derived-only and does not create hidden persistence or a workflow engine.
|
||||
|
||||
## Phase 1: Preparation And Repo Truth
|
||||
|
||||
**Purpose**: Keep the implementation bounded to the repo-real review-output and lifecycle surfaces.
|
||||
|
||||
- [x] T001 Re-read `spec.md`, `plan.md`, `repo-truth-map.md`, `contracts/review-output-resolve-action-map.md`, and `checklists/requirements.md` before runtime changes.
|
||||
- [x] T002 Re-read Specs 347, 349, and 350 as historical context only. Do not modify their artifacts.
|
||||
- [x] T003 Re-verify the current runtime truth in `apps/platform/app/Support/ReviewPacks/ReviewPackOutputResolutionGuidance.php`, `apps/platform/app/Support/ResolutionGuidance/Adapters/ReviewPackOutputResolutionAdapter.php`, `apps/platform/app/Support/ResolutionGuidance/ResolutionAction.php`, and `apps/platform/app/Support/ResolutionGuidance/ResolutionCase.php`.
|
||||
- [x] T004 Re-verify the current runtime truth in `apps/platform/app/Filament/Pages/Reviews/CustomerReviewWorkspace.php`, `apps/platform/app/Filament/Resources/EnvironmentReviewResource.php`, `apps/platform/app/Filament/Resources/EnvironmentReviewResource/Pages/ViewEnvironmentReview.php`, `apps/platform/app/Services/EnvironmentReviews/EnvironmentReviewService.php`, `apps/platform/app/Services/EnvironmentReviews/EnvironmentReviewLifecycleService.php`, and `apps/platform/app/Filament/Resources/EvidenceSnapshotResource/Pages/ViewEvidenceSnapshot.php`.
|
||||
- [x] T005 Keep `specs/351-review-output-resolve-actions-v1/repo-truth-map.md` and `specs/351-review-output-resolve-actions-v1/contracts/review-output-resolve-action-map.md` current if runtime inspection reveals a narrower or broader safe action map.
|
||||
- [x] T006 Confirm no migration, package, env var, queue family, scheduler change, storage-topology change, panel/provider change, or global-search change is required, and confirm Filament v5 / Livewire v4.0+ plus `apps/platform/bootstrap/providers.php` remain unchanged.
|
||||
- [x] T007 Decide and document whether `create_next_review` is confirmation-hardened in-scope; if it is not, keep it as truthful navigation/disclosure fallback and do not surface it as an executable dominant CTA.
|
||||
|
||||
## Phase 2: Tests First
|
||||
|
||||
**Purpose**: Lock the deterministic mapper behavior and the workspace/detail safety rules before runtime changes.
|
||||
|
||||
- [x] T008 Add `apps/platform/tests/Unit/ResolutionGuidance/Spec351ReviewOutputResolveActionMapperTest.php`.
|
||||
- [x] T009 Add mapper assertions for published blocked review + known successor review -> open successor review only when the target is repo-real.
|
||||
- [x] T010 Add mapper assertions for published blocked review + no known successor + safe next-cycle execution -> `create_next_review`.
|
||||
- [x] T011 Add mapper assertions for published blocked review + no safe execution path -> truthful fallback such as review limitations or open review.
|
||||
- [x] T012 Add mapper assertions for mutable review + stale/missing evidence -> `refresh_review` when executable, otherwise evidence fallback.
|
||||
- [x] T013 Add mapper assertions for ready mutable review -> `publish_review`.
|
||||
- [x] T014 Add mapper assertions for internal-only / PII / export-not-ready / no supported action states.
|
||||
- [x] T015 Add `apps/platform/tests/Feature/Filament/Spec351CustomerReviewWorkspaceResolveActionTest.php`.
|
||||
- [x] T016 Add `apps/platform/tests/Feature/EnvironmentReview/Spec351EnvironmentReviewResolveActionTest.php`.
|
||||
- [x] T017 Extend existing lifecycle or action semantics tests if `create_next_review` confirmation/visibility changes.
|
||||
- [x] T018 Add `apps/platform/tests/Browser/Spec351ReviewOutputResolveActionsSmokeTest.php` covering at least one blocked path and one ready-state path.
|
||||
- [x] T019 Reuse or extend Spec 347/349/350 regressions instead of duplicating their existing readiness/output contract coverage wholesale.
|
||||
|
||||
## Phase 3: Mapper And Contract Extension
|
||||
|
||||
**Purpose**: Introduce the narrowest review-output-only mapper over the existing contract.
|
||||
|
||||
- [x] T020 Create `apps/platform/app/Support/ResolutionGuidance/ReviewOutputResolveActionMapper.php`.
|
||||
- [x] T021 Accept the current `EnvironmentReview`, current guidance/readiness truth, known target URLs, surface context, and capability/execution-envelope inputs.
|
||||
- [x] T022 Rank actions deterministically in this order: known successor review, next-cycle creation, review refresh, review publish, evidence/proof fallback, disclosure fallback.
|
||||
- [x] T023 Extend `apps/platform/app/Support/ResolutionGuidance/ResolutionAction.php` only if necessary to carry missing source-owned execution-routing metadata such as `action_name` and `execution_surface`, while reusing existing `disabled_reason`, capability, confirmation, audit, and `OperationRun` fields.
|
||||
- [x] T024 Keep the mapper canonical for `resolution_case.primary_action` and `secondary_actions` on workspace/detail surfaces, derived-only and request-scoped; do not add persistence or request-crossing cache behavior.
|
||||
- [x] T025 Ensure unsupported or unsafe executable actions degrade to truthful navigation, disclosure, or `none`.
|
||||
- [x] T026 Reuse existing capability keys, audit verbs, and service-owned `OperationRun` behavior instead of duplicating lifecycle semantics inside the mapper.
|
||||
- [x] T027 Only return "Open successor review" when a concrete repo-verified successor review target is actually known, such as `superseded_by_review_id` or another deterministic lookup proven during implementation; do not invent draft discovery.
|
||||
|
||||
## Phase 4: Workspace Execution Bridge
|
||||
|
||||
**Purpose**: Let the workspace surface real repo-backed actions without creating a second action runtime.
|
||||
|
||||
- [x] T028 Add or reuse source-owned Filament page actions on `apps/platform/app/Filament/Pages/Reviews/CustomerReviewWorkspace.php` for `refresh_review`, `publish_review`, and `create_next_review`.
|
||||
- [x] T029 Reuse `EnvironmentReviewService`, `EnvironmentReviewLifecycleService`, `GovernanceActionCatalog` where already repo-real, `UiEnforcement`, existing notifications, and current policy/capability behavior inside those page actions; if `create_next_review` is surfaced as executable, harden the source-owned `ViewEnvironmentReview::createNextReviewAction()` with confirmation before reuse.
|
||||
- [x] T030 Render the dominant workspace CTA through `mountAction()` and existing Filament modals when the action is executable on this surface, while keeping all other mapped actions in a clearly secondary supporting-action group.
|
||||
- [x] T031 When the dominant action is not executable for the current actor or surface, downgrade to the strongest truthful fallback action instead of showing a fake enabled button.
|
||||
- [x] T032 Preserve current findings and accepted-risk follow-up overrides above the base review-output resolve-action mapping.
|
||||
- [x] T033 If copy changes are adopted (`Create next review draft`, `Refresh review inputs`), apply them through shared localization and keep the source-owned action vocabulary consistent across button label, modal title, notification, and audit prose.
|
||||
|
||||
## Phase 5: Adapter Integration And Detail Alignment
|
||||
|
||||
**Purpose**: Use the same action semantics on the detail surface without duplicate CTA rails.
|
||||
|
||||
- [x] T034 Update `apps/platform/app/Support/ResolutionGuidance/Adapters/ReviewPackOutputResolutionAdapter.php` so primary and secondary action selection comes from the new mapper.
|
||||
- [x] T035 Update `apps/platform/app/Filament/Pages/Reviews/CustomerReviewWorkspace.php` to consume the mapped resolve action while preserving existing output guidance fields, follow-up overrides, and a semantically split review-pack state model.
|
||||
- [x] T036 Update `apps/platform/resources/views/filament/pages/reviews/customer-review-workspace.blade.php` so the decision card renders exactly one primary CTA, demotes other mapped actions into supporting actions, and keeps the review-consumption flow as a subordinate reference surface.
|
||||
- [x] T037 Update `apps/platform/app/Filament/Resources/EnvironmentReviewResource.php` so `outputGuidanceState()` aligns the next-step semantics and `resolution_case` to the existing dominant header lifecycle action in normal detail mode.
|
||||
- [x] T038 Preserve `customer_workspace` detail-mode suppression and the current context note in `apps/platform/resources/views/filament/infolists/entries/review-pack-output-guidance.blade.php`.
|
||||
- [x] T039 In normal detail mode, keep exactly one dominant lifecycle action surface. Do not move lifecycle CTA ownership out of `ViewEnvironmentReview`, and if the header action already represents the same resolve action, do not duplicate it inside the guidance card.
|
||||
- [x] T040 Only show successor-review-open navigation on detail when the actual target review is known; otherwise keep create/fallback semantics.
|
||||
|
||||
## Phase 6: Copy, Audit, And Browser Proof
|
||||
|
||||
**Purpose**: Align visible copy and coverage artifacts with the new action-first workflow.
|
||||
|
||||
- [x] T041 Update only the required resolve-action, acknowledgement-semantics, and supporting-surface keys in `apps/platform/lang/en/localization.php`.
|
||||
- [x] T042 Update matching keys in `apps/platform/lang/de/localization.php`.
|
||||
- [x] T043 Update `docs/ui-ux-enterprise-audit/page-reports/ui-006-customer-review-workspace.md` for resolve-action-first behavior, one-primary-CTA hierarchy, and honest fallbacks.
|
||||
- [x] T044 Update `docs/ui-ux-enterprise-audit/page-reports/ui-040-environment-review-detail.md` for aligned next-step semantics and CTA suppression behavior.
|
||||
- [x] T045 Capture screenshots under `specs/351-review-output-resolve-actions-v1/artifacts/screenshots/` for published-blocked, mutable-blocked, ready-draft, and fallback states.
|
||||
- [x] T056 Add a local/testing-only ready-path fixture command in `apps/platform/app/Console/Commands/SeedReviewOutputBrowserFixture.php` plus `tenantpilot.review_output.browser_smoke_fixture` config so manual browser proof can seed a published predecessor with a linked ready successor without widening product scope.
|
||||
- [x] T057 Add `apps/platform/tests/Feature/Console/TenantpilotSeedReviewOutputBrowserFixtureCommandTest.php` to verify the fixture command seeds the workspace/detail publish path honestly.
|
||||
|
||||
## Phase 7: Validation
|
||||
|
||||
**Purpose**: Prove the new mapper stays bounded and preserves current trust/safety rules.
|
||||
|
||||
- [x] T046 Run `cd apps/platform && ./vendor/bin/sail artisan test tests/Unit/ResolutionGuidance/Spec351ReviewOutputResolveActionMapperTest.php --compact`.
|
||||
- [x] T047 Run `cd apps/platform && ./vendor/bin/sail artisan test tests/Feature/Filament/Spec351CustomerReviewWorkspaceResolveActionTest.php tests/Feature/EnvironmentReview/Spec351EnvironmentReviewResolveActionTest.php --compact`.
|
||||
- [x] T048 Run `cd apps/platform && ./vendor/bin/sail php vendor/bin/pest tests/Browser/Spec351ReviewOutputResolveActionsSmokeTest.php --compact`.
|
||||
- [x] T049 Run `cd apps/platform && ./vendor/bin/sail artisan test --compact --filter=Spec347`.
|
||||
- [x] T050 Run `cd apps/platform && ./vendor/bin/sail artisan test --compact --filter=Spec349`.
|
||||
- [x] T051 Run `cd apps/platform && ./vendor/bin/sail artisan test --compact --filter=Spec350`.
|
||||
- [x] T052 Run `cd apps/platform && ./vendor/bin/sail artisan test --compact --filter=CustomerReviewWorkspace`.
|
||||
- [x] T053 Run `cd apps/platform && ./vendor/bin/sail pint --dirty`.
|
||||
- [x] T054 Run `git diff --check`.
|
||||
- [x] T055 Report any unrelated broader-suite failures honestly if they remain out of scope.
|
||||
- [x] T058 Run `cd apps/platform && ./vendor/bin/sail artisan test tests/Feature/Console/TenantpilotSeedReviewOutputBrowserFixtureCommandTest.php --compact`.
|
||||
- [x] T059 Run `cd apps/platform && ./vendor/bin/sail artisan tenantpilot:review-output:seed-browser-fixture --no-interaction` before manual browser verification when a persistent ready-path demo is required.
|
||||
|
||||
## Non-Goals Checklist
|
||||
|
||||
- [x] NT001 Do not create a new persisted resolution entity, table, or workflow queue.
|
||||
- [x] NT002 Do not roll the mapper out to provider readiness, governance inbox, or environment dashboard in this slice.
|
||||
- [x] NT003 Do not invent a generic "open draft review" workflow when no deterministic target review exists.
|
||||
- [x] NT004 Do not call lifecycle services directly from Blade or bypass existing source-owned Filament actions.
|
||||
- [x] NT005 Do not weaken workspace/environment scope, confirmation, authorization, audit, or signed-download safety.
|
||||
- [x] NT006 Do not add portal, PDF/HTML renderer, PSA, or AI follow-up work.
|
||||