Compare commits
2 Commits
216-homepa
...
dev
| Author | SHA1 | Date | |
|---|---|---|---|
| a089350f98 | |||
| 40039337d8 |
7
.github/agents/copilot-instructions.md
vendored
7
.github/agents/copilot-instructions.md
vendored
@ -216,6 +216,10 @@ ## Active Technologies
|
|||||||
- PostgreSQL via existing `baseline_snapshots`, `evidence_snapshots`, `evidence_snapshot_items`, `tenant_reviews`, `review_packs`, and `operation_runs` tables; no schema change planned (214-governance-outcome-compression)
|
- PostgreSQL via existing `baseline_snapshots`, `evidence_snapshots`, `evidence_snapshot_items`, `tenant_reviews`, `review_packs`, and `operation_runs` tables; no schema change planned (214-governance-outcome-compression)
|
||||||
- Astro 6.0.0 templates + TypeScript 5.9 strict + Astro 6, Tailwind CSS v4 via `@tailwindcss/vite`, Astro content collections, local Astro layout/primitive/content helpers, Playwright smoke tests (215-website-core-pages)
|
- Astro 6.0.0 templates + TypeScript 5.9 strict + Astro 6, Tailwind CSS v4 via `@tailwindcss/vite`, Astro content collections, local Astro layout/primitive/content helpers, Playwright smoke tests (215-website-core-pages)
|
||||||
- Static filesystem pages, content modules, and Astro content collections under `apps/website/src` and `apps/website/public`; no database (215-website-core-pages)
|
- Static filesystem pages, content modules, and Astro content collections under `apps/website/src` and `apps/website/public`; no database (215-website-core-pages)
|
||||||
|
- PHP 8.4.15, Laravel 12, Filament v5, Livewire v4 + Filament Resources/Pages/Actions, Livewire 4, Pest 4, `ProviderOperationStartGate`, `ProviderOperationRegistry`, `ProviderConnectionResolver`, `OperationRunService`, `ProviderNextStepsRegistry`, `ReasonPresenter`, `OperationUxPresenter`, `OperationRunLinks` (216-provider-dispatch-gate)
|
||||||
|
- PostgreSQL via existing `operation_runs`, `provider_connections`, `managed_tenant_onboarding_sessions`, `restore_runs`, and tenant-owned runtime records; no new tables planned (216-provider-dispatch-gate)
|
||||||
|
- Astro 6.0.0 templates + TypeScript 5.9.x + Astro 6, Tailwind CSS v4, local Astro layout/section primitives, Astro content collections, Playwright browser smoke tests (216-homepage-structure)
|
||||||
|
- Static filesystem content, Astro content collections, and assets under `apps/website/src` and `apps/website/public`; no database (216-homepage-structure)
|
||||||
|
|
||||||
- PHP 8.4.15 (feat/005-bulk-operations)
|
- PHP 8.4.15 (feat/005-bulk-operations)
|
||||||
|
|
||||||
@ -250,10 +254,11 @@ ## Code Style
|
|||||||
PHP 8.4.15: Follow standard conventions
|
PHP 8.4.15: Follow standard conventions
|
||||||
|
|
||||||
## Recent Changes
|
## Recent Changes
|
||||||
|
- 216-provider-dispatch-gate: Added PHP 8.4.15, Laravel 12, Filament v5, Livewire v4 + Filament Resources/Pages/Actions, Livewire 4, Pest 4, `ProviderOperationStartGate`, `ProviderOperationRegistry`, `ProviderConnectionResolver`, `OperationRunService`, `ProviderNextStepsRegistry`, `ReasonPresenter`, `OperationUxPresenter`, `OperationRunLinks`
|
||||||
|
- 216-homepage-structure: Added Astro 6.0.0 templates + TypeScript 5.9.x + Astro 6, Tailwind CSS v4, local Astro layout/section primitives, Astro content collections, Playwright browser smoke tests
|
||||||
- 215-website-core-pages: Added Astro 6.0.0 templates + TypeScript 5.9 strict + Astro 6, Tailwind CSS v4 via `@tailwindcss/vite`, Astro content collections, local Astro layout/primitive/content helpers, Playwright smoke tests
|
- 215-website-core-pages: Added Astro 6.0.0 templates + TypeScript 5.9 strict + Astro 6, Tailwind CSS v4 via `@tailwindcss/vite`, Astro content collections, local Astro layout/primitive/content helpers, Playwright smoke tests
|
||||||
- 214-governance-outcome-compression: Added PHP 8.4.15, Laravel 12, Filament v5, Livewire v4, Blade + Filament v5, Livewire v4, Pest v4, Laravel Sail, `ArtifactTruthPresenter`, `ArtifactTruthEnvelope`, `OperatorExplanationBuilder`, `BaselineSnapshotPresenter`, `BadgeCatalog`, `BadgeRenderer`, existing governance Filament resources/pages, and current Enterprise Detail builders
|
- 214-governance-outcome-compression: Added PHP 8.4.15, Laravel 12, Filament v5, Livewire v4, Blade + Filament v5, Livewire v4, Pest v4, Laravel Sail, `ArtifactTruthPresenter`, `ArtifactTruthEnvelope`, `OperatorExplanationBuilder`, `BaselineSnapshotPresenter`, `BadgeCatalog`, `BadgeRenderer`, existing governance Filament resources/pages, and current Enterprise Detail builders
|
||||||
- 213-website-foundation-v0: Added Astro 6.0.0 templates + TypeScript 5.x (explicit setup in `apps/website`) + Astro 6, Tailwind CSS v4, custom Astro component primitives (shadcn-inspired), lightweight Playwright browser smoke tests
|
- 213-website-foundation-v0: Added Astro 6.0.0 templates + TypeScript 5.x (explicit setup in `apps/website`) + Astro 6, Tailwind CSS v4, custom Astro component primitives (shadcn-inspired), lightweight Playwright browser smoke tests
|
||||||
- 214-website-visual-foundation: Added Astro 6.0.0 templates + TypeScript 5.9 strict + Astro 6, Tailwind CSS v4 via `@tailwindcss/vite`, Astro content collections, local Astro component primitives, Playwright browser smoke tests
|
- 214-website-visual-foundation: Added Astro 6.0.0 templates + TypeScript 5.9 strict + Astro 6, Tailwind CSS v4 via `@tailwindcss/vite`, Astro content collections, local Astro component primitives, Playwright browser smoke tests
|
||||||
- 201-enforcement-review-guardrails: Added Markdown governance artifacts, JSON Schema plus logical OpenAPI planning contracts, and Bash-backed SpecKit scripts inside a PHP 8.4.15 / Laravel 12 / Filament v5 / Livewire v4 repository + `.specify/memory/constitution.md`, `.specify/templates/spec-template.md`, `.specify/templates/plan-template.md`, `.specify/templates/tasks-template.md`, `.specify/templates/checklist-template.md`, `.specify/README.md`, `docs/ui/operator-ux-surface-standards.md`, and Specs 196 through 200
|
|
||||||
<!-- MANUAL ADDITIONS START -->
|
<!-- MANUAL ADDITIONS START -->
|
||||||
<!-- MANUAL ADDITIONS END -->
|
<!-- MANUAL ADDITIONS END -->
|
||||||
|
|||||||
@ -13,7 +13,9 @@ trait ResolvesPanelTenantContext
|
|||||||
{
|
{
|
||||||
protected static function resolveTenantContextForCurrentPanel(): ?Tenant
|
protected static function resolveTenantContextForCurrentPanel(): ?Tenant
|
||||||
{
|
{
|
||||||
if (Filament::getCurrentPanel()?->getId() === 'admin') {
|
$request = request();
|
||||||
|
|
||||||
|
if (static::currentPanelId($request) === 'admin') {
|
||||||
$tenant = app(OperateHubShell::class)->tenantOwnedPanelContext(request());
|
$tenant = app(OperateHubShell::class)->tenantOwnedPanelContext(request());
|
||||||
|
|
||||||
return $tenant instanceof Tenant ? $tenant : null;
|
return $tenant instanceof Tenant ? $tenant : null;
|
||||||
@ -49,4 +51,41 @@ protected static function resolveTrustedPanelTenantContextOrFail(): Tenant
|
|||||||
{
|
{
|
||||||
return static::resolveTenantContextForCurrentPanelOrFail();
|
return static::resolveTenantContextForCurrentPanelOrFail();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static function currentPanelId(mixed $request): ?string
|
||||||
|
{
|
||||||
|
$panelId = Filament::getCurrentPanel()?->getId();
|
||||||
|
|
||||||
|
if (is_string($panelId) && $panelId !== '') {
|
||||||
|
return $panelId;
|
||||||
|
}
|
||||||
|
|
||||||
|
$routeName = is_object($request) && method_exists($request, 'route')
|
||||||
|
? $request->route()?->getName()
|
||||||
|
: null;
|
||||||
|
|
||||||
|
if (is_string($routeName) && $routeName !== '') {
|
||||||
|
if (str_contains($routeName, '.tenant.')) {
|
||||||
|
return 'tenant';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (str_contains($routeName, '.admin.')) {
|
||||||
|
return 'admin';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$path = is_object($request) && method_exists($request, 'path')
|
||||||
|
? '/'.ltrim((string) $request->path(), '/')
|
||||||
|
: null;
|
||||||
|
|
||||||
|
if (is_string($path) && str_starts_with($path, '/admin/t/')) {
|
||||||
|
return 'tenant';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (is_string($path) && str_starts_with($path, '/admin/')) {
|
||||||
|
return 'admin';
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -521,7 +521,7 @@ public function basisRunSummary(): array
|
|||||||
'badgeColor' => null,
|
'badgeColor' => null,
|
||||||
'runUrl' => null,
|
'runUrl' => null,
|
||||||
'historyUrl' => null,
|
'historyUrl' => null,
|
||||||
'inventoryItemsUrl' => InventoryItemResource::getUrl('index', tenant: $tenant),
|
'inventoryItemsUrl' => InventoryItemResource::getUrl('index', panel: 'tenant', tenant: $tenant),
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -537,7 +537,7 @@ public function basisRunSummary(): array
|
|||||||
'badgeColor' => $badge->color,
|
'badgeColor' => $badge->color,
|
||||||
'runUrl' => $canViewRun ? route('admin.operations.view', ['run' => (int) $truth->basisRun->getKey()]) : null,
|
'runUrl' => $canViewRun ? route('admin.operations.view', ['run' => (int) $truth->basisRun->getKey()]) : null,
|
||||||
'historyUrl' => $canViewRun ? $this->inventorySyncHistoryUrl($tenant) : null,
|
'historyUrl' => $canViewRun ? $this->inventorySyncHistoryUrl($tenant) : null,
|
||||||
'inventoryItemsUrl' => InventoryItemResource::getUrl('index', tenant: $tenant),
|
'inventoryItemsUrl' => InventoryItemResource::getUrl('index', panel: 'tenant', tenant: $tenant),
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -122,17 +122,17 @@ public function table(Table $table): Table
|
|||||||
TextColumn::make('outcome')
|
TextColumn::make('outcome')
|
||||||
->label('Outcome')
|
->label('Outcome')
|
||||||
->badge()
|
->badge()
|
||||||
->getStateUsing(fn (TenantReview $record): string => $this->reviewOutcome($record)->primaryLabel)
|
->getStateUsing(\Closure::fromCallable([$this, 'reviewOutcomeLabel']))
|
||||||
->color(fn (TenantReview $record): string => $this->reviewOutcome($record)->primaryBadge->color)
|
->color(\Closure::fromCallable([$this, 'reviewOutcomeBadgeColor']))
|
||||||
->icon(fn (TenantReview $record): ?string => $this->reviewOutcome($record)->primaryBadge->icon)
|
->icon(\Closure::fromCallable([$this, 'reviewOutcomeBadgeIcon']))
|
||||||
->iconColor(fn (TenantReview $record): ?string => $this->reviewOutcome($record)->primaryBadge->iconColor)
|
->iconColor(\Closure::fromCallable([$this, 'reviewOutcomeBadgeIconColor']))
|
||||||
->description(fn (TenantReview $record): ?string => $this->reviewOutcome($record)->primaryReason)
|
->description(\Closure::fromCallable([$this, 'reviewOutcomeDescription']))
|
||||||
->wrap(),
|
->wrap(),
|
||||||
TextColumn::make('generated_at')->dateTime()->placeholder('—')->sortable(),
|
TextColumn::make('generated_at')->dateTime()->placeholder('—')->sortable(),
|
||||||
TextColumn::make('published_at')->dateTime()->placeholder('—')->sortable(),
|
TextColumn::make('published_at')->dateTime()->placeholder('—')->sortable(),
|
||||||
TextColumn::make('next_step')
|
TextColumn::make('next_step')
|
||||||
->label('Next step')
|
->label('Next step')
|
||||||
->getStateUsing(fn (TenantReview $record): string => $this->reviewOutcome($record)->nextActionText)
|
->getStateUsing(\Closure::fromCallable([$this, 'reviewOutcomeNextStep']))
|
||||||
->wrap(),
|
->wrap(),
|
||||||
])
|
])
|
||||||
->filters([
|
->filters([
|
||||||
@ -330,13 +330,46 @@ private function reviewTruth(TenantReview $record, bool $fresh = false): Artifac
|
|||||||
: $presenter->forTenantReview($record);
|
: $presenter->forTenantReview($record);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private function reviewOutcomeLabel(TenantReview $record): string
|
||||||
|
{
|
||||||
|
return $this->reviewOutcome($record)->primaryLabel;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function reviewOutcomeBadgeColor(TenantReview $record): string
|
||||||
|
{
|
||||||
|
return $this->reviewOutcome($record)->primaryBadge->color;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function reviewOutcomeBadgeIcon(TenantReview $record): ?string
|
||||||
|
{
|
||||||
|
return $this->reviewOutcome($record)->primaryBadge->icon;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function reviewOutcomeBadgeIconColor(TenantReview $record): ?string
|
||||||
|
{
|
||||||
|
return $this->reviewOutcome($record)->primaryBadge->iconColor;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function reviewOutcomeDescription(TenantReview $record): ?string
|
||||||
|
{
|
||||||
|
return $this->reviewOutcome($record)->primaryReason;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function reviewOutcomeNextStep(TenantReview $record): string
|
||||||
|
{
|
||||||
|
return $this->reviewOutcome($record)->nextActionText;
|
||||||
|
}
|
||||||
|
|
||||||
private function reviewOutcome(TenantReview $record, bool $fresh = false): CompressedGovernanceOutcome
|
private function reviewOutcome(TenantReview $record, bool $fresh = false): CompressedGovernanceOutcome
|
||||||
{
|
{
|
||||||
$presenter = app(ArtifactTruthPresenter::class);
|
$presenter = app(ArtifactTruthPresenter::class);
|
||||||
|
$truth = $fresh
|
||||||
|
? $this->reviewTruth($record, true)
|
||||||
|
: $this->reviewTruth($record);
|
||||||
|
|
||||||
return $presenter->compressedOutcomeFor($record, SurfaceCompressionContext::reviewRegister(), $fresh)
|
return $presenter->compressedOutcomeFor($record, SurfaceCompressionContext::reviewRegister(), $fresh)
|
||||||
?? $presenter->compressedOutcomeFromEnvelope(
|
?? $presenter->compressedOutcomeFromEnvelope(
|
||||||
$this->reviewTruth($record, $fresh),
|
$truth,
|
||||||
SurfaceCompressionContext::reviewRegister(),
|
SurfaceCompressionContext::reviewRegister(),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -46,6 +46,7 @@
|
|||||||
use App\Support\OperationRunOutcome;
|
use App\Support\OperationRunOutcome;
|
||||||
use App\Support\OperationRunStatus;
|
use App\Support\OperationRunStatus;
|
||||||
use App\Support\OpsUx\OperationUxPresenter;
|
use App\Support\OpsUx\OperationUxPresenter;
|
||||||
|
use App\Support\OpsUx\ProviderOperationStartResultPresenter;
|
||||||
use App\Support\OpsUx\OpsUxBrowserEvents;
|
use App\Support\OpsUx\OpsUxBrowserEvents;
|
||||||
use App\Support\Providers\ProviderConnectionType;
|
use App\Support\Providers\ProviderConnectionType;
|
||||||
use App\Support\Providers\ProviderConsentStatus;
|
use App\Support\Providers\ProviderConsentStatus;
|
||||||
@ -2873,65 +2874,22 @@ public function startVerification(): void
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
$notification = app(ProviderOperationStartResultPresenter::class)->notification(
|
||||||
|
result: $result,
|
||||||
|
blockedTitle: 'Verification blocked',
|
||||||
|
runUrl: $this->tenantlessOperationRunUrl((int) $result->run->getKey()),
|
||||||
|
);
|
||||||
|
|
||||||
if ($result->status === 'scope_busy') {
|
if ($result->status === 'scope_busy') {
|
||||||
OpsUxBrowserEvents::dispatchRunEnqueued($this);
|
OpsUxBrowserEvents::dispatchRunEnqueued($this);
|
||||||
|
|
||||||
Notification::make()
|
$notification->send();
|
||||||
->title('Another operation is already running')
|
|
||||||
->body('Please wait for the active operation to finish.')
|
|
||||||
->warning()
|
|
||||||
->actions([
|
|
||||||
Action::make('view_run')
|
|
||||||
->label(OperationRunLinks::openLabel())
|
|
||||||
->url($this->tenantlessOperationRunUrl((int) $result->run->getKey())),
|
|
||||||
])
|
|
||||||
->send();
|
|
||||||
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if ($result->status === 'blocked') {
|
if ($result->status === 'blocked') {
|
||||||
$reasonCode = is_string($result->run->context['reason_code'] ?? null)
|
$notification->send();
|
||||||
? (string) $result->run->context['reason_code']
|
|
||||||
: 'unknown_error';
|
|
||||||
|
|
||||||
$actions = [
|
|
||||||
Action::make('view_run')
|
|
||||||
->label(OperationRunLinks::openLabel())
|
|
||||||
->url($this->tenantlessOperationRunUrl((int) $result->run->getKey())),
|
|
||||||
];
|
|
||||||
|
|
||||||
$nextSteps = $result->run->context['next_steps'] ?? [];
|
|
||||||
$nextSteps = is_array($nextSteps) ? $nextSteps : [];
|
|
||||||
|
|
||||||
foreach ($nextSteps as $index => $step) {
|
|
||||||
if (! is_array($step)) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
$label = is_string($step['label'] ?? null) ? trim((string) $step['label']) : '';
|
|
||||||
$url = is_string($step['url'] ?? null) ? trim((string) $step['url']) : '';
|
|
||||||
|
|
||||||
if ($label === '' || $url === '') {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
$actions[] = Action::make('next_step_'.$index)
|
|
||||||
->label($label)
|
|
||||||
->url($url);
|
|
||||||
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
|
|
||||||
$reasonEnvelope = app(\App\Support\ReasonTranslation\ReasonPresenter::class)->forOperationRun($result->run, 'notification');
|
|
||||||
$bodyLines = $reasonEnvelope?->toBodyLines() ?? ['Blocked by provider configuration.'];
|
|
||||||
|
|
||||||
Notification::make()
|
|
||||||
->title('Verification blocked')
|
|
||||||
->body(implode("\n", $bodyLines))
|
|
||||||
->warning()
|
|
||||||
->actions($actions)
|
|
||||||
->send();
|
|
||||||
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@ -2939,24 +2897,12 @@ public function startVerification(): void
|
|||||||
OpsUxBrowserEvents::dispatchRunEnqueued($this);
|
OpsUxBrowserEvents::dispatchRunEnqueued($this);
|
||||||
|
|
||||||
if ($result->status === 'deduped') {
|
if ($result->status === 'deduped') {
|
||||||
OperationUxPresenter::alreadyQueuedToast((string) $result->run->type)
|
$notification->send();
|
||||||
->actions([
|
|
||||||
Action::make('view_run')
|
|
||||||
->label(OperationRunLinks::openLabel())
|
|
||||||
->url($this->tenantlessOperationRunUrl((int) $result->run->getKey())),
|
|
||||||
])
|
|
||||||
->send();
|
|
||||||
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
OperationUxPresenter::queuedToast((string) $result->run->type)
|
$notification->send();
|
||||||
->actions([
|
|
||||||
Action::make('view_run')
|
|
||||||
->label(OperationRunLinks::openLabel())
|
|
||||||
->url($this->tenantlessOperationRunUrl((int) $result->run->getKey())),
|
|
||||||
])
|
|
||||||
->send();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public function refreshVerificationStatus(): void
|
public function refreshVerificationStatus(): void
|
||||||
@ -3056,85 +3002,73 @@ public function startBootstrap(array $operationTypes): void
|
|||||||
actor: $user,
|
actor: $user,
|
||||||
expectedVersion: $this->expectedDraftVersion(),
|
expectedVersion: $this->expectedDraftVersion(),
|
||||||
mutator: function (TenantOnboardingSession $draft) use ($tenant, $connection, $types, $registry, $user, &$result): void {
|
mutator: function (TenantOnboardingSession $draft) use ($tenant, $connection, $types, $registry, $user, &$result): void {
|
||||||
$lockedConnection = ProviderConnection::query()
|
$nextOperationType = $this->nextBootstrapOperationType($draft, $types, (int) $connection->getKey());
|
||||||
->whereKey($connection->getKey())
|
|
||||||
->lockForUpdate()
|
|
||||||
->firstOrFail();
|
|
||||||
|
|
||||||
$activeRun = OperationRun::query()
|
if ($nextOperationType === null) {
|
||||||
->where('tenant_id', $tenant->getKey())
|
|
||||||
->active()
|
|
||||||
->where('context->provider_connection_id', (int) $lockedConnection->getKey())
|
|
||||||
->orderByDesc('id')
|
|
||||||
->first();
|
|
||||||
|
|
||||||
if ($activeRun instanceof OperationRun) {
|
|
||||||
$result = [
|
$result = [
|
||||||
'status' => 'scope_busy',
|
'status' => 'already_completed',
|
||||||
'run' => $activeRun,
|
'operation_type' => null,
|
||||||
|
'remaining_types' => [],
|
||||||
];
|
];
|
||||||
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
$runsService = app(OperationRunService::class);
|
$capability = $this->resolveBootstrapCapability($nextOperationType);
|
||||||
$bootstrapRuns = [];
|
|
||||||
$bootstrapCreated = [];
|
|
||||||
|
|
||||||
foreach ($types as $operationType) {
|
if ($capability === null) {
|
||||||
$definition = $registry->get($operationType);
|
throw new RuntimeException("Unsupported bootstrap operation type: {$nextOperationType}");
|
||||||
|
}
|
||||||
|
|
||||||
$context = [
|
$startResult = app(ProviderOperationStartGate::class)->start(
|
||||||
|
tenant: $tenant,
|
||||||
|
connection: $connection,
|
||||||
|
operationType: $nextOperationType,
|
||||||
|
dispatcher: function (OperationRun $run) use ($tenant, $user, $connection, $nextOperationType): void {
|
||||||
|
$this->dispatchBootstrapJob(
|
||||||
|
operationType: $nextOperationType,
|
||||||
|
tenantId: (int) $tenant->getKey(),
|
||||||
|
userId: (int) $user->getKey(),
|
||||||
|
providerConnectionId: (int) $connection->getKey(),
|
||||||
|
run: $run,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
initiator: $user,
|
||||||
|
extraContext: [
|
||||||
'wizard' => [
|
'wizard' => [
|
||||||
'flow' => 'managed_tenant_onboarding',
|
'flow' => 'managed_tenant_onboarding',
|
||||||
'step' => 'bootstrap',
|
'step' => 'bootstrap',
|
||||||
],
|
],
|
||||||
'provider' => $lockedConnection->provider,
|
'required_capability' => $capability,
|
||||||
'module' => $definition['module'],
|
|
||||||
'provider_connection_id' => (int) $lockedConnection->getKey(),
|
|
||||||
'target_scope' => [
|
|
||||||
'entra_tenant_id' => $lockedConnection->entra_tenant_id,
|
|
||||||
],
|
],
|
||||||
];
|
|
||||||
|
|
||||||
$run = $runsService->ensureRunWithIdentity(
|
|
||||||
tenant: $tenant,
|
|
||||||
type: $operationType,
|
|
||||||
identityInputs: [
|
|
||||||
'provider_connection_id' => (int) $lockedConnection->getKey(),
|
|
||||||
],
|
|
||||||
context: $context,
|
|
||||||
initiator: $user,
|
|
||||||
);
|
);
|
||||||
|
|
||||||
if ($run->wasRecentlyCreated) {
|
|
||||||
$this->dispatchBootstrapJob(
|
|
||||||
operationType: $operationType,
|
|
||||||
tenantId: (int) $tenant->getKey(),
|
|
||||||
userId: (int) $user->getKey(),
|
|
||||||
providerConnectionId: (int) $lockedConnection->getKey(),
|
|
||||||
run: $run,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
$bootstrapRuns[$operationType] = (int) $run->getKey();
|
|
||||||
$bootstrapCreated[$operationType] = (bool) $run->wasRecentlyCreated;
|
|
||||||
}
|
|
||||||
|
|
||||||
$state = $draft->state ?? [];
|
$state = $draft->state ?? [];
|
||||||
$existing = $state['bootstrap_operation_runs'] ?? [];
|
$existing = $state['bootstrap_operation_runs'] ?? [];
|
||||||
$existing = is_array($existing) ? $existing : [];
|
$existing = is_array($existing) ? $existing : [];
|
||||||
|
|
||||||
$state['bootstrap_operation_runs'] = array_merge($existing, $bootstrapRuns);
|
if ($startResult->status !== 'scope_busy') {
|
||||||
|
$existing[$nextOperationType] = (int) $startResult->run->getKey();
|
||||||
|
}
|
||||||
|
|
||||||
|
$state['bootstrap_operation_runs'] = $existing;
|
||||||
$state['bootstrap_operation_types'] = $types;
|
$state['bootstrap_operation_types'] = $types;
|
||||||
|
|
||||||
$draft->state = $state;
|
$draft->state = $state;
|
||||||
$draft->current_step = 'bootstrap';
|
$draft->current_step = 'bootstrap';
|
||||||
|
|
||||||
|
$remainingTypes = array_values(array_filter(
|
||||||
|
$types,
|
||||||
|
fn (string $candidate): bool => $candidate !== $nextOperationType
|
||||||
|
&& ! $this->bootstrapOperationSucceeded($draft, $candidate, (int) $connection->getKey()),
|
||||||
|
));
|
||||||
|
|
||||||
$result = [
|
$result = [
|
||||||
'status' => 'started',
|
'status' => $startResult->status,
|
||||||
'runs' => $bootstrapRuns,
|
'start_result' => $startResult,
|
||||||
'created' => $bootstrapCreated,
|
'operation_type' => $nextOperationType,
|
||||||
|
'run' => $startResult->run,
|
||||||
|
'remaining_types' => $remainingTypes,
|
||||||
];
|
];
|
||||||
},
|
},
|
||||||
));
|
));
|
||||||
@ -3152,26 +3086,36 @@ public function startBootstrap(array $operationTypes): void
|
|||||||
throw new RuntimeException('Bootstrap start did not return a run result.');
|
throw new RuntimeException('Bootstrap start did not return a run result.');
|
||||||
}
|
}
|
||||||
|
|
||||||
if ($result['status'] === 'scope_busy') {
|
if ($result['status'] === 'already_completed') {
|
||||||
OpsUxBrowserEvents::dispatchRunEnqueued($this);
|
|
||||||
|
|
||||||
Notification::make()
|
Notification::make()
|
||||||
->title('Another operation is already running')
|
->title('Bootstrap already completed')
|
||||||
->body('Please wait for the active operation to finish.')
|
->body('All selected bootstrap actions have already finished successfully for this provider connection.')
|
||||||
->warning()
|
->info()
|
||||||
->actions([
|
|
||||||
Action::make('view_run')
|
|
||||||
->label(OperationRunLinks::openLabel())
|
|
||||||
->url($this->tenantlessOperationRunUrl((int) $result['run']->getKey())),
|
|
||||||
])
|
|
||||||
->send();
|
->send();
|
||||||
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
$bootstrapRuns = $result['runs'];
|
$operationType = (string) ($result['operation_type'] ?? '');
|
||||||
|
$startResult = $result['start_result'] ?? null;
|
||||||
|
$run = $result['run'] ?? null;
|
||||||
|
|
||||||
|
if (! $startResult instanceof \App\Services\Providers\ProviderOperationStartResult || ! $run instanceof OperationRun || $operationType === '') {
|
||||||
|
throw new RuntimeException('Bootstrap start did not return a canonical run result.');
|
||||||
|
}
|
||||||
|
|
||||||
|
$remainingTypes = is_array($result['remaining_types'] ?? null)
|
||||||
|
? array_values(array_filter($result['remaining_types'], static fn (mixed $value): bool => is_string($value) && $value !== ''))
|
||||||
|
: [];
|
||||||
|
|
||||||
if ($this->onboardingSession instanceof TenantOnboardingSession) {
|
if ($this->onboardingSession instanceof TenantOnboardingSession) {
|
||||||
|
$auditStatus = match ($result['status']) {
|
||||||
|
'started' => 'success',
|
||||||
|
'deduped' => 'deduped',
|
||||||
|
'scope_busy' => 'blocked',
|
||||||
|
default => 'success',
|
||||||
|
};
|
||||||
|
|
||||||
app(WorkspaceAuditLogger::class)->log(
|
app(WorkspaceAuditLogger::class)->log(
|
||||||
workspace: $this->workspace,
|
workspace: $this->workspace,
|
||||||
action: AuditActionId::ManagedTenantOnboardingBootstrapStarted->value,
|
action: AuditActionId::ManagedTenantOnboardingBootstrapStarted->value,
|
||||||
@ -3181,36 +3125,40 @@ public function startBootstrap(array $operationTypes): void
|
|||||||
'tenant_db_id' => (int) $tenant->getKey(),
|
'tenant_db_id' => (int) $tenant->getKey(),
|
||||||
'onboarding_session_id' => (int) $this->onboardingSession->getKey(),
|
'onboarding_session_id' => (int) $this->onboardingSession->getKey(),
|
||||||
'operation_types' => $types,
|
'operation_types' => $types,
|
||||||
'operation_run_ids' => $bootstrapRuns,
|
'started_operation_type' => $operationType,
|
||||||
|
'operation_run_id' => (int) $run->getKey(),
|
||||||
|
'result' => (string) $result['status'],
|
||||||
],
|
],
|
||||||
],
|
],
|
||||||
actor: $user,
|
actor: $user,
|
||||||
status: 'success',
|
status: $auditStatus,
|
||||||
resourceType: 'managed_tenant_onboarding_session',
|
resourceType: 'managed_tenant_onboarding_session',
|
||||||
resourceId: (string) $this->onboardingSession->getKey(),
|
resourceId: (string) $this->onboardingSession->getKey(),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
$notification = app(ProviderOperationStartResultPresenter::class)->notification(
|
||||||
|
result: $startResult,
|
||||||
|
blockedTitle: 'Bootstrap action blocked',
|
||||||
|
runUrl: $this->tenantlessOperationRunUrl((int) $run->getKey()),
|
||||||
|
scopeBusyTitle: 'Bootstrap action busy',
|
||||||
|
scopeBusyBody: $remainingTypes !== []
|
||||||
|
? 'Another provider-backed bootstrap action is already running for this provider connection. Open the active operation, then continue with the remaining bootstrap actions after it finishes.'
|
||||||
|
: 'Another provider-backed bootstrap action is already running for this provider connection. Open the active operation for progress and next steps.',
|
||||||
|
);
|
||||||
|
|
||||||
|
if (in_array($result['status'], ['started', 'deduped', 'scope_busy'], true)) {
|
||||||
OpsUxBrowserEvents::dispatchRunEnqueued($this);
|
OpsUxBrowserEvents::dispatchRunEnqueued($this);
|
||||||
|
|
||||||
foreach ($types as $operationType) {
|
|
||||||
$runId = (int) ($bootstrapRuns[$operationType] ?? 0);
|
|
||||||
$runUrl = $runId > 0 ? $this->tenantlessOperationRunUrl($runId) : null;
|
|
||||||
$wasCreated = (bool) ($result['created'][$operationType] ?? false);
|
|
||||||
|
|
||||||
$toast = $wasCreated
|
|
||||||
? OperationUxPresenter::queuedToast($operationType)
|
|
||||||
: OperationUxPresenter::alreadyQueuedToast($operationType);
|
|
||||||
|
|
||||||
if ($runUrl !== null) {
|
|
||||||
$toast->actions([
|
|
||||||
Action::make('view_run')
|
|
||||||
->label(OperationRunLinks::openLabel())
|
|
||||||
->url($runUrl),
|
|
||||||
]);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
$toast->send();
|
$notification->send();
|
||||||
|
|
||||||
|
if ($remainingTypes !== [] && in_array($result['status'], ['started', 'deduped'], true)) {
|
||||||
|
Notification::make()
|
||||||
|
->title('Continue bootstrap after this run finishes')
|
||||||
|
->body(sprintf('%d additional bootstrap action(s) remain selected for this provider connection.', count($remainingTypes)))
|
||||||
|
->info()
|
||||||
|
->send();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -3227,17 +3175,65 @@ private function dispatchBootstrapJob(
|
|||||||
userId: $userId,
|
userId: $userId,
|
||||||
providerConnectionId: $providerConnectionId,
|
providerConnectionId: $providerConnectionId,
|
||||||
operationRun: $run,
|
operationRun: $run,
|
||||||
),
|
)->afterCommit(),
|
||||||
'compliance.snapshot' => ProviderComplianceSnapshotJob::dispatch(
|
'compliance.snapshot' => ProviderComplianceSnapshotJob::dispatch(
|
||||||
tenantId: $tenantId,
|
tenantId: $tenantId,
|
||||||
userId: $userId,
|
userId: $userId,
|
||||||
providerConnectionId: $providerConnectionId,
|
providerConnectionId: $providerConnectionId,
|
||||||
operationRun: $run,
|
operationRun: $run,
|
||||||
),
|
)->afterCommit(),
|
||||||
default => throw new RuntimeException("Unsupported bootstrap operation type: {$operationType}"),
|
default => throw new RuntimeException("Unsupported bootstrap operation type: {$operationType}"),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<int, string> $types
|
||||||
|
*/
|
||||||
|
private function nextBootstrapOperationType(TenantOnboardingSession $draft, array $types, int $providerConnectionId): ?string
|
||||||
|
{
|
||||||
|
foreach ($types as $type) {
|
||||||
|
if (! $this->bootstrapOperationSucceeded($draft, $type, $providerConnectionId)) {
|
||||||
|
return $type;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function bootstrapOperationSucceeded(TenantOnboardingSession $draft, string $type, int $providerConnectionId): bool
|
||||||
|
{
|
||||||
|
$state = is_array($draft->state) ? $draft->state : [];
|
||||||
|
$runMap = $state['bootstrap_operation_runs'] ?? [];
|
||||||
|
|
||||||
|
if (! is_array($runMap)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
$runId = $runMap[$type] ?? null;
|
||||||
|
|
||||||
|
if (! is_numeric($runId)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
$run = OperationRun::query()->whereKey((int) $runId)->first();
|
||||||
|
|
||||||
|
if (! $run instanceof OperationRun) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
$context = is_array($run->context ?? null) ? $run->context : [];
|
||||||
|
$runProviderConnectionId = is_numeric($context['provider_connection_id'] ?? null)
|
||||||
|
? (int) $context['provider_connection_id']
|
||||||
|
: null;
|
||||||
|
|
||||||
|
if ($runProviderConnectionId !== $providerConnectionId) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $run->status === OperationRunStatus::Completed->value
|
||||||
|
&& $run->outcome === OperationRunOutcome::Succeeded->value;
|
||||||
|
}
|
||||||
|
|
||||||
private function resolveBootstrapCapability(string $operationType): ?string
|
private function resolveBootstrapCapability(string $operationType): ?string
|
||||||
{
|
{
|
||||||
return match ($operationType) {
|
return match ($operationType) {
|
||||||
|
|||||||
@ -182,7 +182,11 @@ public static function table(Table $table): Table
|
|||||||
->color(static fn (BaselineSnapshot $record): string => self::compressedOutcome($record)->primaryBadge->color)
|
->color(static fn (BaselineSnapshot $record): string => self::compressedOutcome($record)->primaryBadge->color)
|
||||||
->icon(static fn (BaselineSnapshot $record): ?string => self::compressedOutcome($record)->primaryBadge->icon)
|
->icon(static fn (BaselineSnapshot $record): ?string => self::compressedOutcome($record)->primaryBadge->icon)
|
||||||
->iconColor(static fn (BaselineSnapshot $record): ?string => self::compressedOutcome($record)->primaryBadge->iconColor)
|
->iconColor(static fn (BaselineSnapshot $record): ?string => self::compressedOutcome($record)->primaryBadge->iconColor)
|
||||||
->description(static fn (BaselineSnapshot $record): ?string => self::compressedOutcome($record)->primaryReason)
|
->description(static fn (BaselineSnapshot $record): ?string => self::truthHeadline($record))
|
||||||
|
->wrap(),
|
||||||
|
TextColumn::make('coverage_summary')
|
||||||
|
->label('Coverage')
|
||||||
|
->getStateUsing(static fn (BaselineSnapshot $record): string => self::fidelitySummary($record))
|
||||||
->wrap(),
|
->wrap(),
|
||||||
TextColumn::make('next_step')
|
TextColumn::make('next_step')
|
||||||
->label('Next step')
|
->label('Next step')
|
||||||
@ -377,6 +381,12 @@ private static function truthEnvelope(BaselineSnapshot $snapshot, bool $fresh =
|
|||||||
: $presenter->forBaselineSnapshot($snapshot);
|
: $presenter->forBaselineSnapshot($snapshot);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static function truthHeadline(BaselineSnapshot $record): ?string
|
||||||
|
{
|
||||||
|
return self::truthEnvelope($record)->operatorExplanation?->headline
|
||||||
|
?? self::compressedOutcome($record)->primaryReason;
|
||||||
|
}
|
||||||
|
|
||||||
private static function compressedOutcome(BaselineSnapshot $snapshot, bool $fresh = false): CompressedGovernanceOutcome
|
private static function compressedOutcome(BaselineSnapshot $snapshot, bool $fresh = false): CompressedGovernanceOutcome
|
||||||
{
|
{
|
||||||
$presenter = app(ArtifactTruthPresenter::class);
|
$presenter = app(ArtifactTruthPresenter::class);
|
||||||
|
|||||||
@ -3,16 +3,14 @@
|
|||||||
namespace App\Filament\Resources\EntraGroupResource\Pages;
|
namespace App\Filament\Resources\EntraGroupResource\Pages;
|
||||||
|
|
||||||
use App\Filament\Resources\EntraGroupResource;
|
use App\Filament\Resources\EntraGroupResource;
|
||||||
use App\Jobs\EntraGroupSyncJob;
|
|
||||||
use App\Models\Tenant;
|
use App\Models\Tenant;
|
||||||
use App\Models\User;
|
use App\Models\User;
|
||||||
use App\Services\Directory\EntraGroupSelection;
|
use App\Services\Directory\EntraGroupSyncService;
|
||||||
use App\Services\OperationRunService;
|
|
||||||
use App\Support\Auth\Capabilities;
|
use App\Support\Auth\Capabilities;
|
||||||
use App\Support\Filament\CanonicalAdminTenantFilterState;
|
use App\Support\Filament\CanonicalAdminTenantFilterState;
|
||||||
use App\Support\OperationRunLinks;
|
use App\Support\OperationRunLinks;
|
||||||
use App\Support\OpsUx\OperationUxPresenter;
|
|
||||||
use App\Support\OpsUx\OpsUxBrowserEvents;
|
use App\Support\OpsUx\OpsUxBrowserEvents;
|
||||||
|
use App\Support\OpsUx\ProviderOperationStartResultPresenter;
|
||||||
use App\Support\Rbac\UiEnforcement;
|
use App\Support\Rbac\UiEnforcement;
|
||||||
use Filament\Actions\Action;
|
use Filament\Actions\Action;
|
||||||
use Filament\Facades\Filament;
|
use Filament\Facades\Filament;
|
||||||
@ -55,7 +53,7 @@ protected function getHeaderActions(): array
|
|||||||
->label('Sync Groups')
|
->label('Sync Groups')
|
||||||
->icon('heroicon-o-arrow-path')
|
->icon('heroicon-o-arrow-path')
|
||||||
->color('primary')
|
->color('primary')
|
||||||
->action(function (): void {
|
->action(function (EntraGroupSyncService $syncService): void {
|
||||||
$user = auth()->user();
|
$user = auth()->user();
|
||||||
$tenant = EntraGroupResource::panelTenantContext();
|
$tenant = EntraGroupResource::panelTenantContext();
|
||||||
|
|
||||||
@ -63,52 +61,18 @@ protected function getHeaderActions(): array
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
$selectionKey = EntraGroupSelection::allGroupsV1();
|
$result = $syncService->startManualSync($tenant, $user);
|
||||||
|
$notification = app(ProviderOperationStartResultPresenter::class)->notification(
|
||||||
// --- Phase 3: Canonical Operation Run Start ---
|
result: $result,
|
||||||
/** @var OperationRunService $opService */
|
blockedTitle: 'Directory groups sync blocked',
|
||||||
$opService = app(OperationRunService::class);
|
runUrl: OperationRunLinks::view($result->run, $tenant),
|
||||||
$opRun = $opService->ensureRunWithIdentity(
|
|
||||||
tenant: $tenant,
|
|
||||||
type: 'entra_group_sync',
|
|
||||||
identityInputs: ['selection_key' => $selectionKey],
|
|
||||||
context: [
|
|
||||||
'selection_key' => $selectionKey,
|
|
||||||
'trigger' => 'manual',
|
|
||||||
],
|
|
||||||
initiator: $user,
|
|
||||||
);
|
);
|
||||||
|
|
||||||
if (! $opRun->wasRecentlyCreated && in_array($opRun->status, ['queued', 'running'])) {
|
if (in_array($result->status, ['started', 'deduped', 'scope_busy'], true)) {
|
||||||
OpsUxBrowserEvents::dispatchRunEnqueued($this);
|
OpsUxBrowserEvents::dispatchRunEnqueued($this);
|
||||||
OperationUxPresenter::alreadyQueuedToast((string) $opRun->type)
|
|
||||||
->actions([
|
|
||||||
Action::make('view_run')
|
|
||||||
->label('Open operation')
|
|
||||||
->url(OperationRunLinks::view($opRun, $tenant)),
|
|
||||||
])
|
|
||||||
->send();
|
|
||||||
|
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
// ----------------------------------------------
|
|
||||||
|
|
||||||
dispatch(new EntraGroupSyncJob(
|
$notification->send();
|
||||||
tenantId: (int) $tenant->getKey(),
|
|
||||||
selectionKey: $selectionKey,
|
|
||||||
slotKey: null,
|
|
||||||
runId: null,
|
|
||||||
operationRun: $opRun
|
|
||||||
));
|
|
||||||
|
|
||||||
OpsUxBrowserEvents::dispatchRunEnqueued($this);
|
|
||||||
OperationUxPresenter::queuedToast((string) $opRun->type)
|
|
||||||
->actions([
|
|
||||||
Action::make('view_run')
|
|
||||||
->label('Open operation')
|
|
||||||
->url(OperationRunLinks::view($opRun, $tenant)),
|
|
||||||
])
|
|
||||||
->send();
|
|
||||||
})
|
})
|
||||||
)
|
)
|
||||||
->requireCapability(Capabilities::TENANT_SYNC)
|
->requireCapability(Capabilities::TENANT_SYNC)
|
||||||
|
|||||||
@ -691,9 +691,12 @@ private static function truthEnvelope(EvidenceSnapshot $record, bool $fresh = fa
|
|||||||
private static function truthState(EvidenceSnapshot $record, bool $fresh = false): array
|
private static function truthState(EvidenceSnapshot $record, bool $fresh = false): array
|
||||||
{
|
{
|
||||||
$presenter = app(ArtifactTruthPresenter::class);
|
$presenter = app(ArtifactTruthPresenter::class);
|
||||||
|
$truth = $fresh
|
||||||
|
? static::truthEnvelope($record, true)
|
||||||
|
: static::truthEnvelope($record);
|
||||||
|
|
||||||
return $presenter->surfaceStateFor($record, SurfaceCompressionContext::evidenceSnapshot(), $fresh)
|
return $presenter->surfaceStateFor($record, SurfaceCompressionContext::evidenceSnapshot(), $fresh)
|
||||||
?? static::truthEnvelope($record, $fresh)->toArray(static::compressedOutcome($record, $fresh));
|
?? $truth->toArray(static::compressedOutcome($record, $fresh));
|
||||||
}
|
}
|
||||||
|
|
||||||
private static function compressedOutcome(EvidenceSnapshot $record, bool $fresh = false): CompressedGovernanceOutcome
|
private static function compressedOutcome(EvidenceSnapshot $record, bool $fresh = false): CompressedGovernanceOutcome
|
||||||
|
|||||||
@ -316,7 +316,13 @@ public static function getEloquentQuery(): Builder
|
|||||||
|
|
||||||
public static function resolveScopedRecordOrFail(int|string $key): Model
|
public static function resolveScopedRecordOrFail(int|string $key): Model
|
||||||
{
|
{
|
||||||
return static::resolveTenantOwnedRecordOrFail($key, parent::getEloquentQuery()->with('lastSeenRun'));
|
$tenant = static::resolveTenantContextForCurrentPanelOrFail();
|
||||||
|
|
||||||
|
return static::resolveTenantOwnedRecordOrFail(
|
||||||
|
$key,
|
||||||
|
parent::getEloquentQuery()->with('lastSeenRun'),
|
||||||
|
$tenant,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
public static function getPages(): array
|
public static function getPages(): array
|
||||||
|
|||||||
@ -2,14 +2,30 @@
|
|||||||
|
|
||||||
namespace App\Filament\Resources\InventoryItemResource\Pages;
|
namespace App\Filament\Resources\InventoryItemResource\Pages;
|
||||||
|
|
||||||
|
use App\Filament\Concerns\ResolvesPanelTenantContext;
|
||||||
use App\Filament\Resources\InventoryItemResource;
|
use App\Filament\Resources\InventoryItemResource;
|
||||||
|
use App\Models\Tenant;
|
||||||
|
use App\Support\Workspaces\WorkspaceContext;
|
||||||
use Filament\Resources\Pages\ViewRecord;
|
use Filament\Resources\Pages\ViewRecord;
|
||||||
use Illuminate\Database\Eloquent\Model;
|
use Illuminate\Database\Eloquent\Model;
|
||||||
|
|
||||||
class ViewInventoryItem extends ViewRecord
|
class ViewInventoryItem extends ViewRecord
|
||||||
{
|
{
|
||||||
|
use ResolvesPanelTenantContext;
|
||||||
|
|
||||||
protected static string $resource = InventoryItemResource::class;
|
protected static string $resource = InventoryItemResource::class;
|
||||||
|
|
||||||
|
public function mount(int|string $record): void
|
||||||
|
{
|
||||||
|
$tenant = static::resolveTenantContextForCurrentPanel();
|
||||||
|
|
||||||
|
if ($tenant instanceof Tenant) {
|
||||||
|
app(WorkspaceContext::class)->rememberTenantContext($tenant, request());
|
||||||
|
}
|
||||||
|
|
||||||
|
parent::mount($record);
|
||||||
|
}
|
||||||
|
|
||||||
protected function resolveRecord(int|string $key): Model
|
protected function resolveRecord(int|string $key): Model
|
||||||
{
|
{
|
||||||
return InventoryItemResource::resolveScopedRecordOrFail($key);
|
return InventoryItemResource::resolveScopedRecordOrFail($key);
|
||||||
|
|||||||
@ -21,6 +21,7 @@
|
|||||||
use App\Support\Filament\TablePaginationProfiles;
|
use App\Support\Filament\TablePaginationProfiles;
|
||||||
use App\Support\OperationRunLinks;
|
use App\Support\OperationRunLinks;
|
||||||
use App\Support\OpsUx\OperationUxPresenter;
|
use App\Support\OpsUx\OperationUxPresenter;
|
||||||
|
use App\Support\OpsUx\ProviderOperationStartResultPresenter;
|
||||||
use App\Support\OpsUx\OpsUxBrowserEvents;
|
use App\Support\OpsUx\OpsUxBrowserEvents;
|
||||||
use App\Support\Providers\ProviderConnectionType;
|
use App\Support\Providers\ProviderConnectionType;
|
||||||
use App\Support\Providers\ProviderReasonCodes;
|
use App\Support\Providers\ProviderReasonCodes;
|
||||||
@ -1357,20 +1358,23 @@ private static function handleCheckConnectionAction(ProviderConnection $record,
|
|||||||
initiator: $user,
|
initiator: $user,
|
||||||
);
|
);
|
||||||
|
|
||||||
if ($result->status === 'scope_busy') {
|
$runUrl = OperationRunLinks::view($result->run, $tenant);
|
||||||
Notification::make()
|
$extraActions = $result->status === 'started'
|
||||||
->title('Scope busy')
|
? []
|
||||||
->body('Another provider operation is already running for this connection.')
|
: [
|
||||||
->warning()
|
|
||||||
->actions([
|
|
||||||
Actions\Action::make('view_run')
|
|
||||||
->label('Open operation')
|
|
||||||
->url(OperationRunLinks::view($result->run, $tenant)),
|
|
||||||
Actions\Action::make('manage_connections')
|
Actions\Action::make('manage_connections')
|
||||||
->label('Manage Provider Connections')
|
->label('Manage Provider Connections')
|
||||||
->url(static::getUrl('index', tenant: $tenant)),
|
->url(static::getUrl('index', tenant: $tenant)),
|
||||||
])
|
];
|
||||||
->send();
|
$notification = app(ProviderOperationStartResultPresenter::class)->notification(
|
||||||
|
result: $result,
|
||||||
|
blockedTitle: 'Connection check blocked',
|
||||||
|
runUrl: $runUrl,
|
||||||
|
extraActions: $extraActions,
|
||||||
|
);
|
||||||
|
|
||||||
|
if ($result->status === 'scope_busy') {
|
||||||
|
$notification->send();
|
||||||
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@ -1378,50 +1382,20 @@ private static function handleCheckConnectionAction(ProviderConnection $record,
|
|||||||
if ($result->status === 'deduped') {
|
if ($result->status === 'deduped') {
|
||||||
OpsUxBrowserEvents::dispatchRunEnqueued($livewire);
|
OpsUxBrowserEvents::dispatchRunEnqueued($livewire);
|
||||||
|
|
||||||
OperationUxPresenter::alreadyQueuedToast((string) $result->run->type)
|
$notification->send();
|
||||||
->actions([
|
|
||||||
Actions\Action::make('view_run')
|
|
||||||
->label('Open operation')
|
|
||||||
->url(OperationRunLinks::view($result->run, $tenant)),
|
|
||||||
Actions\Action::make('manage_connections')
|
|
||||||
->label('Manage Provider Connections')
|
|
||||||
->url(static::getUrl('index', tenant: $tenant)),
|
|
||||||
])
|
|
||||||
->send();
|
|
||||||
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if ($result->status === 'blocked') {
|
if ($result->status === 'blocked') {
|
||||||
$reasonEnvelope = app(\App\Support\ReasonTranslation\ReasonPresenter::class)->forOperationRun($result->run, 'notification');
|
$notification->send();
|
||||||
$bodyLines = $reasonEnvelope?->toBodyLines() ?? ['Blocked by provider configuration.'];
|
|
||||||
|
|
||||||
Notification::make()
|
|
||||||
->title('Connection check blocked')
|
|
||||||
->body(implode("\n", $bodyLines))
|
|
||||||
->warning()
|
|
||||||
->actions([
|
|
||||||
Actions\Action::make('view_run')
|
|
||||||
->label('Open operation')
|
|
||||||
->url(OperationRunLinks::view($result->run, $tenant)),
|
|
||||||
Actions\Action::make('manage_connections')
|
|
||||||
->label('Manage Provider Connections')
|
|
||||||
->url(static::getUrl('index', tenant: $tenant)),
|
|
||||||
])
|
|
||||||
->send();
|
|
||||||
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
OpsUxBrowserEvents::dispatchRunEnqueued($livewire);
|
OpsUxBrowserEvents::dispatchRunEnqueued($livewire);
|
||||||
|
|
||||||
OperationUxPresenter::queuedToast((string) $result->run->type)
|
$notification->send();
|
||||||
->actions([
|
|
||||||
Actions\Action::make('view_run')
|
|
||||||
->label('Open operation')
|
|
||||||
->url(OperationRunLinks::view($result->run, $tenant)),
|
|
||||||
])
|
|
||||||
->send();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -1452,17 +1426,14 @@ private static function handleProviderOperationAction(
|
|||||||
initiator: $user,
|
initiator: $user,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
$notification = app(ProviderOperationStartResultPresenter::class)->notification(
|
||||||
|
result: $result,
|
||||||
|
blockedTitle: $blockedTitle,
|
||||||
|
runUrl: OperationRunLinks::view($result->run, $tenant),
|
||||||
|
);
|
||||||
|
|
||||||
if ($result->status === 'scope_busy') {
|
if ($result->status === 'scope_busy') {
|
||||||
Notification::make()
|
$notification->send();
|
||||||
->title('Scope is busy')
|
|
||||||
->body('Another provider operation is already running for this connection.')
|
|
||||||
->danger()
|
|
||||||
->actions([
|
|
||||||
Actions\Action::make('view_run')
|
|
||||||
->label('Open operation')
|
|
||||||
->url(OperationRunLinks::view($result->run, $tenant)),
|
|
||||||
])
|
|
||||||
->send();
|
|
||||||
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@ -1470,44 +1441,20 @@ private static function handleProviderOperationAction(
|
|||||||
if ($result->status === 'deduped') {
|
if ($result->status === 'deduped') {
|
||||||
OpsUxBrowserEvents::dispatchRunEnqueued($livewire);
|
OpsUxBrowserEvents::dispatchRunEnqueued($livewire);
|
||||||
|
|
||||||
OperationUxPresenter::alreadyQueuedToast((string) $result->run->type)
|
$notification->send();
|
||||||
->actions([
|
|
||||||
Actions\Action::make('view_run')
|
|
||||||
->label('Open operation')
|
|
||||||
->url(OperationRunLinks::view($result->run, $tenant)),
|
|
||||||
])
|
|
||||||
->send();
|
|
||||||
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if ($result->status === 'blocked') {
|
if ($result->status === 'blocked') {
|
||||||
$reasonEnvelope = app(\App\Support\ReasonTranslation\ReasonPresenter::class)->forOperationRun($result->run, 'notification');
|
$notification->send();
|
||||||
$bodyLines = $reasonEnvelope?->toBodyLines() ?? ['Blocked by provider configuration.'];
|
|
||||||
|
|
||||||
Notification::make()
|
|
||||||
->title($blockedTitle)
|
|
||||||
->body(implode("\n", $bodyLines))
|
|
||||||
->warning()
|
|
||||||
->actions([
|
|
||||||
Actions\Action::make('view_run')
|
|
||||||
->label('Open operation')
|
|
||||||
->url(OperationRunLinks::view($result->run, $tenant)),
|
|
||||||
])
|
|
||||||
->send();
|
|
||||||
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
OpsUxBrowserEvents::dispatchRunEnqueued($livewire);
|
OpsUxBrowserEvents::dispatchRunEnqueued($livewire);
|
||||||
|
|
||||||
OperationUxPresenter::queuedToast((string) $result->run->type)
|
$notification->send();
|
||||||
->actions([
|
|
||||||
Actions\Action::make('view_run')
|
|
||||||
->label('Open operation')
|
|
||||||
->url(OperationRunLinks::view($result->run, $tenant)),
|
|
||||||
])
|
|
||||||
->send();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public static function getEloquentQuery(): Builder
|
public static function getEloquentQuery(): Builder
|
||||||
|
|||||||
@ -14,6 +14,7 @@
|
|||||||
use App\Models\BackupItem;
|
use App\Models\BackupItem;
|
||||||
use App\Models\BackupSet;
|
use App\Models\BackupSet;
|
||||||
use App\Models\EntraGroup;
|
use App\Models\EntraGroup;
|
||||||
|
use App\Models\OperationRun;
|
||||||
use App\Models\RestoreRun;
|
use App\Models\RestoreRun;
|
||||||
use App\Models\Tenant;
|
use App\Models\Tenant;
|
||||||
use App\Models\User;
|
use App\Models\User;
|
||||||
@ -26,6 +27,8 @@
|
|||||||
use App\Services\Intune\RestoreService;
|
use App\Services\Intune\RestoreService;
|
||||||
use App\Services\OperationRunService;
|
use App\Services\OperationRunService;
|
||||||
use App\Services\Operations\BulkSelectionIdentity;
|
use App\Services\Operations\BulkSelectionIdentity;
|
||||||
|
use App\Services\Providers\ProviderOperationStartGate;
|
||||||
|
use App\Services\Providers\ProviderOperationStartResult;
|
||||||
use App\Support\Auth\Capabilities;
|
use App\Support\Auth\Capabilities;
|
||||||
use App\Support\BackupQuality\BackupQualityResolver;
|
use App\Support\BackupQuality\BackupQualityResolver;
|
||||||
use App\Support\Badges\BadgeDomain;
|
use App\Support\Badges\BadgeDomain;
|
||||||
@ -35,6 +38,7 @@
|
|||||||
use App\Support\OperationRunLinks;
|
use App\Support\OperationRunLinks;
|
||||||
use App\Support\OpsUx\OperationUxPresenter;
|
use App\Support\OpsUx\OperationUxPresenter;
|
||||||
use App\Support\OpsUx\OpsUxBrowserEvents;
|
use App\Support\OpsUx\OpsUxBrowserEvents;
|
||||||
|
use App\Support\OpsUx\ProviderOperationStartResultPresenter;
|
||||||
use App\Support\Rbac\UiEnforcement;
|
use App\Support\Rbac\UiEnforcement;
|
||||||
use App\Support\RestoreRunIdempotency;
|
use App\Support\RestoreRunIdempotency;
|
||||||
use App\Support\RestoreRunStatus;
|
use App\Support\RestoreRunStatus;
|
||||||
@ -1917,6 +1921,53 @@ public static function createRestoreRun(array $data): RestoreRun
|
|||||||
->executionSafetySnapshot($tenant, $user, $data)
|
->executionSafetySnapshot($tenant, $user, $data)
|
||||||
->toArray();
|
->toArray();
|
||||||
|
|
||||||
|
[$result, $restoreRun] = static::startQueuedRestoreExecution(
|
||||||
|
tenant: $tenant,
|
||||||
|
backupSet: $backupSet,
|
||||||
|
selectedItemIds: $selectedItemIds,
|
||||||
|
preview: $preview,
|
||||||
|
metadata: $metadata,
|
||||||
|
groupMapping: $groupMapping,
|
||||||
|
actorEmail: $actorEmail,
|
||||||
|
actorName: $actorName,
|
||||||
|
);
|
||||||
|
|
||||||
|
app(ProviderOperationStartResultPresenter::class)
|
||||||
|
->notification(
|
||||||
|
result: $result,
|
||||||
|
blockedTitle: 'Restore execution blocked',
|
||||||
|
runUrl: OperationRunLinks::view($result->run, $tenant),
|
||||||
|
)
|
||||||
|
->send();
|
||||||
|
|
||||||
|
if (! in_array($result->status, ['started', 'deduped'], true)) {
|
||||||
|
throw new \Filament\Support\Exceptions\Halt;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (! $restoreRun instanceof RestoreRun) {
|
||||||
|
throw new \RuntimeException('Restore execution was accepted without creating a restore run.');
|
||||||
|
}
|
||||||
|
|
||||||
|
return $restoreRun;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<int>|null $selectedItemIds
|
||||||
|
* @param array<string, mixed> $preview
|
||||||
|
* @param array<string, mixed> $metadata
|
||||||
|
* @param array<string, mixed> $groupMapping
|
||||||
|
* @return array{0: \App\Services\Providers\ProviderOperationStartResult, 1: ?RestoreRun}
|
||||||
|
*/
|
||||||
|
private static function startQueuedRestoreExecution(
|
||||||
|
Tenant $tenant,
|
||||||
|
BackupSet $backupSet,
|
||||||
|
?array $selectedItemIds,
|
||||||
|
array $preview,
|
||||||
|
array $metadata,
|
||||||
|
array $groupMapping,
|
||||||
|
?string $actorEmail,
|
||||||
|
?string $actorName,
|
||||||
|
): array {
|
||||||
$idempotencyKey = RestoreRunIdempotency::restoreExecuteKey(
|
$idempotencyKey = RestoreRunIdempotency::restoreExecuteKey(
|
||||||
tenantId: (int) $tenant->getKey(),
|
tenantId: (int) $tenant->getKey(),
|
||||||
backupSetId: (int) $backupSet->getKey(),
|
backupSetId: (int) $backupSet->getKey(),
|
||||||
@ -1924,34 +1975,27 @@ public static function createRestoreRun(array $data): RestoreRun
|
|||||||
groupMapping: $groupMapping,
|
groupMapping: $groupMapping,
|
||||||
);
|
);
|
||||||
|
|
||||||
$existing = RestoreRunIdempotency::findActiveRestoreRun((int) $tenant->getKey(), $idempotencyKey);
|
$initiator = auth()->user();
|
||||||
|
$initiator = $initiator instanceof User ? $initiator : null;
|
||||||
|
|
||||||
if ($existing) {
|
$queuedRestoreRun = null;
|
||||||
$existingOpRunId = (int) ($existing->operation_run_id ?? 0);
|
|
||||||
$existingOpRun = $existingOpRunId > 0
|
|
||||||
? \App\Models\OperationRun::query()->find($existingOpRunId)
|
|
||||||
: null;
|
|
||||||
|
|
||||||
$toast = OperationUxPresenter::alreadyQueuedToast('restore.execute')
|
$dispatcher = function (OperationRun $run) use (
|
||||||
->body('Reusing the active restore run.');
|
$tenant,
|
||||||
|
$backupSet,
|
||||||
if ($existingOpRun) {
|
$selectedItemIds,
|
||||||
$toast->actions([
|
$preview,
|
||||||
Actions\Action::make('view_run')
|
$metadata,
|
||||||
->label('Open operation')
|
$groupMapping,
|
||||||
->url(OperationRunLinks::view($existingOpRun, $tenant)),
|
$actorEmail,
|
||||||
]);
|
$actorName,
|
||||||
}
|
$idempotencyKey,
|
||||||
|
&$queuedRestoreRun,
|
||||||
$toast->send();
|
): void {
|
||||||
|
$queuedRestoreRun = RestoreRun::create([
|
||||||
return $existing;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
$restoreRun = RestoreRun::create([
|
|
||||||
'tenant_id' => $tenant->id,
|
'tenant_id' => $tenant->id,
|
||||||
'backup_set_id' => $backupSet->id,
|
'backup_set_id' => $backupSet->id,
|
||||||
|
'operation_run_id' => $run->getKey(),
|
||||||
'requested_by' => $actorEmail,
|
'requested_by' => $actorEmail,
|
||||||
'is_dry_run' => false,
|
'is_dry_run' => false,
|
||||||
'status' => RestoreRunStatus::Queued->value,
|
'status' => RestoreRunStatus::Queued->value,
|
||||||
@ -1961,83 +2005,114 @@ public static function createRestoreRun(array $data): RestoreRun
|
|||||||
'metadata' => $metadata,
|
'metadata' => $metadata,
|
||||||
'group_mapping' => $groupMapping !== [] ? $groupMapping : null,
|
'group_mapping' => $groupMapping !== [] ? $groupMapping : null,
|
||||||
]);
|
]);
|
||||||
} catch (QueryException $exception) {
|
|
||||||
$existing = RestoreRunIdempotency::findActiveRestoreRun((int) $tenant->getKey(), $idempotencyKey);
|
|
||||||
|
|
||||||
if ($existing) {
|
$context = is_array($run->context) ? $run->context : [];
|
||||||
$existingOpRunId = (int) ($existing->operation_run_id ?? 0);
|
$context['restore_run_id'] = (int) $queuedRestoreRun->getKey();
|
||||||
$existingOpRun = $existingOpRunId > 0
|
$run->forceFill(['context' => $context])->save();
|
||||||
? \App\Models\OperationRun::query()->find($existingOpRunId)
|
|
||||||
: null;
|
|
||||||
|
|
||||||
$toast = OperationUxPresenter::alreadyQueuedToast('restore.execute')
|
|
||||||
->body('Reusing the active restore run.');
|
|
||||||
|
|
||||||
if ($existingOpRun) {
|
|
||||||
$toast->actions([
|
|
||||||
Actions\Action::make('view_run')
|
|
||||||
->label('Open operation')
|
|
||||||
->url(OperationRunLinks::view($existingOpRun, $tenant)),
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
|
|
||||||
$toast->send();
|
|
||||||
|
|
||||||
return $existing;
|
|
||||||
}
|
|
||||||
|
|
||||||
throw $exception;
|
|
||||||
}
|
|
||||||
|
|
||||||
app(AuditLogger::class)->log(
|
app(AuditLogger::class)->log(
|
||||||
tenant: $tenant,
|
tenant: $tenant,
|
||||||
action: 'restore.queued',
|
action: 'restore.queued',
|
||||||
context: [
|
context: [
|
||||||
'metadata' => [
|
'metadata' => [
|
||||||
'restore_run_id' => $restoreRun->id,
|
'restore_run_id' => $queuedRestoreRun->id,
|
||||||
'backup_set_id' => $backupSet->id,
|
'backup_set_id' => $backupSet->id,
|
||||||
],
|
],
|
||||||
],
|
],
|
||||||
actorEmail: $actorEmail,
|
actorEmail: $actorEmail,
|
||||||
actorName: $actorName,
|
actorName: $actorName,
|
||||||
resourceType: 'restore_run',
|
resourceType: 'restore_run',
|
||||||
resourceId: (string) $restoreRun->id,
|
resourceId: (string) $queuedRestoreRun->id,
|
||||||
status: 'success',
|
status: 'success',
|
||||||
);
|
);
|
||||||
|
|
||||||
/** @var OperationRunService $runs */
|
$providerConnectionId = is_numeric($context['provider_connection_id'] ?? null)
|
||||||
$runs = app(OperationRunService::class);
|
? (int) $context['provider_connection_id']
|
||||||
$initiator = auth()->user();
|
: null;
|
||||||
$initiator = $initiator instanceof \App\Models\User ? $initiator : null;
|
|
||||||
|
|
||||||
$opRun = $runs->ensureRun(
|
ExecuteRestoreRunJob::dispatch(
|
||||||
|
restoreRunId: (int) $queuedRestoreRun->getKey(),
|
||||||
|
actorEmail: $actorEmail,
|
||||||
|
actorName: $actorName,
|
||||||
|
operationRun: $run,
|
||||||
|
providerConnectionId: $providerConnectionId,
|
||||||
|
)->afterCommit();
|
||||||
|
};
|
||||||
|
|
||||||
|
if (static::requiresProviderExecution($backupSet, $selectedItemIds)) {
|
||||||
|
$result = app(ProviderOperationStartGate::class)->start(
|
||||||
tenant: $tenant,
|
tenant: $tenant,
|
||||||
type: 'restore.execute',
|
connection: null,
|
||||||
inputs: [
|
operationType: 'restore.execute',
|
||||||
'restore_run_id' => (int) $restoreRun->getKey(),
|
dispatcher: $dispatcher,
|
||||||
|
initiator: $initiator,
|
||||||
|
extraContext: [
|
||||||
'backup_set_id' => (int) $backupSet->getKey(),
|
'backup_set_id' => (int) $backupSet->getKey(),
|
||||||
'is_dry_run' => (bool) ($restoreRun->is_dry_run ?? false),
|
'is_dry_run' => false,
|
||||||
'execution_authority_mode' => 'actor_bound',
|
'execution_authority_mode' => 'actor_bound',
|
||||||
'required_capability' => Capabilities::TENANT_MANAGE,
|
'required_capability' => Capabilities::TENANT_MANAGE,
|
||||||
],
|
],
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
$run = app(OperationRunService::class)->ensureRunWithIdentity(
|
||||||
|
tenant: $tenant,
|
||||||
|
type: 'restore.execute',
|
||||||
|
identityInputs: [
|
||||||
|
'idempotency_key' => $idempotencyKey,
|
||||||
|
],
|
||||||
|
context: [
|
||||||
|
'backup_set_id' => (int) $backupSet->getKey(),
|
||||||
|
'is_dry_run' => false,
|
||||||
|
'execution_authority_mode' => 'actor_bound',
|
||||||
|
'required_capability' => Capabilities::TENANT_MANAGE,
|
||||||
|
'target_scope' => [
|
||||||
|
'entra_tenant_id' => $tenant->graphTenantId(),
|
||||||
|
],
|
||||||
|
],
|
||||||
initiator: $initiator,
|
initiator: $initiator,
|
||||||
);
|
);
|
||||||
|
|
||||||
if ((int) ($restoreRun->operation_run_id ?? 0) !== (int) $opRun->getKey()) {
|
if ($run->wasRecentlyCreated) {
|
||||||
$restoreRun->update(['operation_run_id' => $opRun->getKey()]);
|
$dispatcher($run);
|
||||||
|
|
||||||
|
$result = ProviderOperationStartResult::started($run, true);
|
||||||
|
} else {
|
||||||
|
$result = ProviderOperationStartResult::deduped($run);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
ExecuteRestoreRunJob::dispatch($restoreRun->id, $actorEmail, $actorName, $opRun);
|
if (! $queuedRestoreRun instanceof RestoreRun && $result->status === 'deduped') {
|
||||||
|
$restoreRunId = data_get($result->run->context ?? [], 'restore_run_id');
|
||||||
|
|
||||||
OperationUxPresenter::queuedToast('restore.execute')
|
if (is_numeric($restoreRunId)) {
|
||||||
->actions([
|
$queuedRestoreRun = RestoreRun::query()->whereKey((int) $restoreRunId)->first();
|
||||||
Actions\Action::make('view_run')
|
}
|
||||||
->label('Open operation')
|
|
||||||
->url(OperationRunLinks::view($opRun, $tenant)),
|
|
||||||
])
|
|
||||||
->send();
|
|
||||||
|
|
||||||
return $restoreRun->refresh();
|
$queuedRestoreRun ??= RestoreRunIdempotency::findActiveRestoreRun(
|
||||||
|
(int) $tenant->getKey(),
|
||||||
|
$idempotencyKey,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return [$result, $queuedRestoreRun?->refresh()];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<int>|null $selectedItemIds
|
||||||
|
*/
|
||||||
|
private static function requiresProviderExecution(BackupSet $backupSet, ?array $selectedItemIds): bool
|
||||||
|
{
|
||||||
|
$query = $backupSet->items()->select(['id', 'policy_type']);
|
||||||
|
|
||||||
|
if (is_array($selectedItemIds) && $selectedItemIds !== []) {
|
||||||
|
$query->whereIn('id', $selectedItemIds);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $query->get()->contains(function (BackupItem $item): bool {
|
||||||
|
$restoreMode = static::typeMeta($item->policy_type)['restore'] ?? 'preview-only';
|
||||||
|
|
||||||
|
return $restoreMode !== 'preview-only';
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -2452,122 +2527,34 @@ private static function rerunActionWithGate(): Actions\Action|BulkAction
|
|||||||
'rerun_of_restore_run_id' => $record->id,
|
'rerun_of_restore_run_id' => $record->id,
|
||||||
];
|
];
|
||||||
|
|
||||||
$idempotencyKey = RestoreRunIdempotency::restoreExecuteKey(
|
$metadata['rerun_of_restore_run_id'] = $record->id;
|
||||||
tenantId: (int) $tenant->getKey(),
|
|
||||||
backupSetId: (int) $backupSet->getKey(),
|
|
||||||
selectedItemIds: $selectedItemIds,
|
|
||||||
groupMapping: $groupMapping,
|
|
||||||
);
|
|
||||||
|
|
||||||
$existing = RestoreRunIdempotency::findActiveRestoreRun((int) $tenant->getKey(), $idempotencyKey);
|
[$result, $newRun] = static::startQueuedRestoreExecution(
|
||||||
|
|
||||||
if ($existing) {
|
|
||||||
$existingOpRunId = (int) ($existing->operation_run_id ?? 0);
|
|
||||||
$existingOpRun = $existingOpRunId > 0
|
|
||||||
? \App\Models\OperationRun::query()->find($existingOpRunId)
|
|
||||||
: null;
|
|
||||||
|
|
||||||
OpsUxBrowserEvents::dispatchRunEnqueued($livewire);
|
|
||||||
|
|
||||||
$toast = OperationUxPresenter::alreadyQueuedToast('restore.execute')
|
|
||||||
->body('Reusing the active restore run.');
|
|
||||||
|
|
||||||
if ($existingOpRun) {
|
|
||||||
$toast->actions([
|
|
||||||
Action::make('view_run')
|
|
||||||
->label('Open operation')
|
|
||||||
->url(OperationRunLinks::view($existingOpRun, $tenant)),
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
|
|
||||||
$toast->send();
|
|
||||||
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
$newRun = RestoreRun::create([
|
|
||||||
'tenant_id' => $tenant->id,
|
|
||||||
'backup_set_id' => $backupSet->id,
|
|
||||||
'requested_by' => $actorEmail,
|
|
||||||
'is_dry_run' => false,
|
|
||||||
'status' => RestoreRunStatus::Queued->value,
|
|
||||||
'idempotency_key' => $idempotencyKey,
|
|
||||||
'requested_items' => $selectedItemIds,
|
|
||||||
'preview' => $preview,
|
|
||||||
'metadata' => $metadata,
|
|
||||||
'group_mapping' => $groupMapping !== [] ? $groupMapping : null,
|
|
||||||
]);
|
|
||||||
} catch (QueryException $exception) {
|
|
||||||
$existing = RestoreRunIdempotency::findActiveRestoreRun((int) $tenant->getKey(), $idempotencyKey);
|
|
||||||
|
|
||||||
if ($existing) {
|
|
||||||
$existingOpRunId = (int) ($existing->operation_run_id ?? 0);
|
|
||||||
$existingOpRun = $existingOpRunId > 0
|
|
||||||
? \App\Models\OperationRun::query()->find($existingOpRunId)
|
|
||||||
: null;
|
|
||||||
|
|
||||||
OpsUxBrowserEvents::dispatchRunEnqueued($livewire);
|
|
||||||
|
|
||||||
$toast = OperationUxPresenter::alreadyQueuedToast('restore.execute')
|
|
||||||
->body('Reusing the active restore run.');
|
|
||||||
|
|
||||||
if ($existingOpRun) {
|
|
||||||
$toast->actions([
|
|
||||||
Action::make('view_run')
|
|
||||||
->label('Open operation')
|
|
||||||
->url(OperationRunLinks::view($existingOpRun, $tenant)),
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
|
|
||||||
$toast->send();
|
|
||||||
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
throw $exception;
|
|
||||||
}
|
|
||||||
|
|
||||||
$auditLogger->log(
|
|
||||||
tenant: $tenant,
|
tenant: $tenant,
|
||||||
action: 'restore.queued',
|
backupSet: $backupSet,
|
||||||
context: [
|
selectedItemIds: $selectedItemIds,
|
||||||
'metadata' => [
|
preview: $preview,
|
||||||
'restore_run_id' => $newRun->id,
|
metadata: $metadata,
|
||||||
'backup_set_id' => $backupSet->id,
|
groupMapping: $groupMapping,
|
||||||
'rerun_of_restore_run_id' => $record->id,
|
|
||||||
],
|
|
||||||
],
|
|
||||||
actorEmail: $actorEmail,
|
actorEmail: $actorEmail,
|
||||||
actorName: $actorName,
|
actorName: $actorName,
|
||||||
resourceType: 'restore_run',
|
|
||||||
resourceId: (string) $newRun->id,
|
|
||||||
status: 'success',
|
|
||||||
);
|
);
|
||||||
|
|
||||||
/** @var OperationRunService $runs */
|
if (in_array($result->status, ['started', 'deduped', 'scope_busy'], true)) {
|
||||||
$runs = app(OperationRunService::class);
|
OpsUxBrowserEvents::dispatchRunEnqueued($livewire);
|
||||||
$initiator = auth()->user();
|
|
||||||
$initiator = $initiator instanceof \App\Models\User ? $initiator : null;
|
|
||||||
|
|
||||||
$opRun = $runs->ensureRun(
|
|
||||||
tenant: $tenant,
|
|
||||||
type: 'restore.execute',
|
|
||||||
inputs: [
|
|
||||||
'restore_run_id' => (int) $newRun->getKey(),
|
|
||||||
'backup_set_id' => (int) $backupSet->getKey(),
|
|
||||||
'is_dry_run' => (bool) ($newRun->is_dry_run ?? false),
|
|
||||||
'execution_authority_mode' => 'actor_bound',
|
|
||||||
'required_capability' => Capabilities::TENANT_MANAGE,
|
|
||||||
],
|
|
||||||
initiator: $initiator,
|
|
||||||
);
|
|
||||||
|
|
||||||
if ((int) ($newRun->operation_run_id ?? 0) !== (int) $opRun->getKey()) {
|
|
||||||
$newRun->update(['operation_run_id' => $opRun->getKey()]);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
ExecuteRestoreRunJob::dispatch($newRun->id, $actorEmail, $actorName, $opRun);
|
app(ProviderOperationStartResultPresenter::class)
|
||||||
|
->notification(
|
||||||
|
result: $result,
|
||||||
|
blockedTitle: 'Restore execution blocked',
|
||||||
|
runUrl: OperationRunLinks::view($result->run, $tenant),
|
||||||
|
)
|
||||||
|
->send();
|
||||||
|
|
||||||
|
if ($result->status !== 'started' || ! $newRun instanceof RestoreRun) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
$auditLogger->log(
|
$auditLogger->log(
|
||||||
tenant: $tenant,
|
tenant: $tenant,
|
||||||
@ -2585,15 +2572,6 @@ private static function rerunActionWithGate(): Actions\Action|BulkAction
|
|||||||
actorName: $actorName,
|
actorName: $actorName,
|
||||||
);
|
);
|
||||||
|
|
||||||
OpsUxBrowserEvents::dispatchRunEnqueued($livewire);
|
|
||||||
OperationUxPresenter::queuedToast('restore.execute')
|
|
||||||
->actions([
|
|
||||||
Action::make('view_run')
|
|
||||||
->label('Open operation')
|
|
||||||
->url(OperationRunLinks::view($opRun, $tenant)),
|
|
||||||
])
|
|
||||||
->send();
|
|
||||||
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -399,9 +399,12 @@ private static function truthEnvelope(ReviewPack $record, bool $fresh = false):
|
|||||||
private static function truthState(ReviewPack $record, bool $fresh = false): array
|
private static function truthState(ReviewPack $record, bool $fresh = false): array
|
||||||
{
|
{
|
||||||
$presenter = app(ArtifactTruthPresenter::class);
|
$presenter = app(ArtifactTruthPresenter::class);
|
||||||
|
$truth = $fresh
|
||||||
|
? static::truthEnvelope($record, true)
|
||||||
|
: static::truthEnvelope($record);
|
||||||
|
|
||||||
return $presenter->surfaceStateFor($record, SurfaceCompressionContext::reviewPack(), $fresh)
|
return $presenter->surfaceStateFor($record, SurfaceCompressionContext::reviewPack(), $fresh)
|
||||||
?? static::truthEnvelope($record, $fresh)->toArray(static::compressedOutcome($record, $fresh));
|
?? $truth->toArray(static::compressedOutcome($record, $fresh));
|
||||||
}
|
}
|
||||||
|
|
||||||
private static function compressedOutcome(ReviewPack $record, bool $fresh = false): CompressedGovernanceOutcome
|
private static function compressedOutcome(ReviewPack $record, bool $fresh = false): CompressedGovernanceOutcome
|
||||||
|
|||||||
@ -42,6 +42,7 @@
|
|||||||
use App\Support\Filament\TablePaginationProfiles;
|
use App\Support\Filament\TablePaginationProfiles;
|
||||||
use App\Support\OperationRunLinks;
|
use App\Support\OperationRunLinks;
|
||||||
use App\Support\OpsUx\OperationUxPresenter;
|
use App\Support\OpsUx\OperationUxPresenter;
|
||||||
|
use App\Support\OpsUx\ProviderOperationStartResultPresenter;
|
||||||
use App\Support\OpsUx\OpsUxBrowserEvents;
|
use App\Support\OpsUx\OpsUxBrowserEvents;
|
||||||
use App\Support\PortfolioTriage\PortfolioArrivalContextToken;
|
use App\Support\PortfolioTriage\PortfolioArrivalContextToken;
|
||||||
use App\Support\PortfolioTriage\TenantTriageReviewStateResolver;
|
use App\Support\PortfolioTriage\TenantTriageReviewStateResolver;
|
||||||
@ -513,20 +514,16 @@ private static function handleVerifyConfigurationAction(
|
|||||||
);
|
);
|
||||||
|
|
||||||
$runUrl = OperationRunLinks::tenantlessView($result->run);
|
$runUrl = OperationRunLinks::tenantlessView($result->run);
|
||||||
|
$notification = app(ProviderOperationStartResultPresenter::class)->notification(
|
||||||
|
result: $result,
|
||||||
|
blockedTitle: 'Verification blocked',
|
||||||
|
runUrl: $runUrl,
|
||||||
|
);
|
||||||
|
|
||||||
if ($result->status === 'scope_busy') {
|
if ($result->status === 'scope_busy') {
|
||||||
OpsUxBrowserEvents::dispatchRunEnqueued($livewire);
|
OpsUxBrowserEvents::dispatchRunEnqueued($livewire);
|
||||||
|
|
||||||
Notification::make()
|
$notification->send();
|
||||||
->title('Another operation is already running')
|
|
||||||
->body('Please wait for the active operation to finish.')
|
|
||||||
->warning()
|
|
||||||
->actions([
|
|
||||||
Actions\Action::make('view_run')
|
|
||||||
->label(OperationRunLinks::openLabel())
|
|
||||||
->url($runUrl),
|
|
||||||
])
|
|
||||||
->send();
|
|
||||||
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@ -534,68 +531,20 @@ private static function handleVerifyConfigurationAction(
|
|||||||
if ($result->status === 'deduped') {
|
if ($result->status === 'deduped') {
|
||||||
OpsUxBrowserEvents::dispatchRunEnqueued($livewire);
|
OpsUxBrowserEvents::dispatchRunEnqueued($livewire);
|
||||||
|
|
||||||
OperationUxPresenter::alreadyQueuedToast((string) $result->run->type)
|
$notification->send();
|
||||||
->actions([
|
|
||||||
Actions\Action::make('view_run')
|
|
||||||
->label(OperationRunLinks::openLabel())
|
|
||||||
->url($runUrl),
|
|
||||||
])
|
|
||||||
->send();
|
|
||||||
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if ($result->status === 'blocked') {
|
if ($result->status === 'blocked') {
|
||||||
$actions = [
|
$notification->send();
|
||||||
Actions\Action::make('view_run')
|
|
||||||
->label(OperationRunLinks::openLabel())
|
|
||||||
->url($runUrl),
|
|
||||||
];
|
|
||||||
|
|
||||||
$nextSteps = $result->run->context['next_steps'] ?? [];
|
|
||||||
$nextSteps = is_array($nextSteps) ? $nextSteps : [];
|
|
||||||
|
|
||||||
foreach ($nextSteps as $index => $step) {
|
|
||||||
if (! is_array($step)) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
$label = is_string($step['label'] ?? null) ? trim((string) $step['label']) : '';
|
|
||||||
$url = is_string($step['url'] ?? null) ? trim((string) $step['url']) : '';
|
|
||||||
|
|
||||||
if ($label === '' || $url === '') {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
$actions[] = Actions\Action::make('next_step_'.$index)
|
|
||||||
->label($label)
|
|
||||||
->url($url);
|
|
||||||
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
|
|
||||||
$reasonEnvelope = app(\App\Support\ReasonTranslation\ReasonPresenter::class)->forOperationRun($result->run, 'notification');
|
|
||||||
$bodyLines = $reasonEnvelope?->toBodyLines() ?? ['Blocked by provider configuration.'];
|
|
||||||
|
|
||||||
Notification::make()
|
|
||||||
->title('Verification blocked')
|
|
||||||
->body(implode("\n", $bodyLines))
|
|
||||||
->warning()
|
|
||||||
->actions($actions)
|
|
||||||
->send();
|
|
||||||
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
OpsUxBrowserEvents::dispatchRunEnqueued($livewire);
|
OpsUxBrowserEvents::dispatchRunEnqueued($livewire);
|
||||||
|
|
||||||
OperationUxPresenter::queuedToast((string) $result->run->type)
|
$notification->send();
|
||||||
->actions([
|
|
||||||
Actions\Action::make('view_run')
|
|
||||||
->label(OperationRunLinks::openLabel())
|
|
||||||
->url($runUrl),
|
|
||||||
])
|
|
||||||
->send();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private static function userCanManageAnyTenant(User $user): bool
|
private static function userCanManageAnyTenant(User $user): bool
|
||||||
@ -3319,29 +3268,14 @@ public static function syncRoleDefinitionsAction(): Actions\Action
|
|||||||
abort(403);
|
abort(403);
|
||||||
}
|
}
|
||||||
|
|
||||||
$opRun = $syncService->startManualSync($record, $user);
|
$result = $syncService->startManualSync($record, $user);
|
||||||
|
$notification = app(ProviderOperationStartResultPresenter::class)->notification(
|
||||||
|
result: $result,
|
||||||
|
blockedTitle: 'Role definitions sync blocked',
|
||||||
|
runUrl: OperationRunLinks::tenantlessView($result->run),
|
||||||
|
);
|
||||||
|
|
||||||
$runUrl = OperationRunLinks::tenantlessView($opRun);
|
$notification->send();
|
||||||
|
|
||||||
if (! $opRun->wasRecentlyCreated && in_array($opRun->status, ['queued', 'running'], true)) {
|
|
||||||
OperationUxPresenter::alreadyQueuedToast((string) $opRun->type)
|
|
||||||
->actions([
|
|
||||||
Actions\Action::make('view_run')
|
|
||||||
->label(OperationRunLinks::openLabel())
|
|
||||||
->url($runUrl),
|
|
||||||
])
|
|
||||||
->send();
|
|
||||||
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
OperationUxPresenter::queuedToast('directory_role_definitions.sync')
|
|
||||||
->actions([
|
|
||||||
Actions\Action::make('view_run')
|
|
||||||
->label(OperationRunLinks::openLabel())
|
|
||||||
->url($runUrl),
|
|
||||||
])
|
|
||||||
->send();
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -17,6 +17,7 @@
|
|||||||
use App\Support\OperationRunLinks;
|
use App\Support\OperationRunLinks;
|
||||||
use App\Support\OperationRunStatus;
|
use App\Support\OperationRunStatus;
|
||||||
use App\Support\OpsUx\OperationUxPresenter;
|
use App\Support\OpsUx\OperationUxPresenter;
|
||||||
|
use App\Support\OpsUx\ProviderOperationStartResultPresenter;
|
||||||
use App\Support\OpsUx\OpsUxBrowserEvents;
|
use App\Support\OpsUx\OpsUxBrowserEvents;
|
||||||
use Filament\Actions\Action;
|
use Filament\Actions\Action;
|
||||||
use Filament\Facades\Filament;
|
use Filament\Facades\Filament;
|
||||||
@ -71,20 +72,16 @@ public function startVerification(StartVerification $verification): void
|
|||||||
);
|
);
|
||||||
|
|
||||||
$runUrl = OperationRunLinks::tenantlessView($result->run);
|
$runUrl = OperationRunLinks::tenantlessView($result->run);
|
||||||
|
$notification = app(ProviderOperationStartResultPresenter::class)->notification(
|
||||||
|
result: $result,
|
||||||
|
blockedTitle: 'Verification blocked',
|
||||||
|
runUrl: $runUrl,
|
||||||
|
);
|
||||||
|
|
||||||
if ($result->status === 'scope_busy') {
|
if ($result->status === 'scope_busy') {
|
||||||
OpsUxBrowserEvents::dispatchRunEnqueued($this);
|
OpsUxBrowserEvents::dispatchRunEnqueued($this);
|
||||||
|
|
||||||
Notification::make()
|
$notification->send();
|
||||||
->title('Another operation is already running')
|
|
||||||
->body('Please wait for the active operation to finish.')
|
|
||||||
->warning()
|
|
||||||
->actions([
|
|
||||||
Action::make('view_run')
|
|
||||||
->label(OperationRunLinks::openLabel())
|
|
||||||
->url($runUrl),
|
|
||||||
])
|
|
||||||
->send();
|
|
||||||
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@ -92,72 +89,20 @@ public function startVerification(StartVerification $verification): void
|
|||||||
if ($result->status === 'deduped') {
|
if ($result->status === 'deduped') {
|
||||||
OpsUxBrowserEvents::dispatchRunEnqueued($this);
|
OpsUxBrowserEvents::dispatchRunEnqueued($this);
|
||||||
|
|
||||||
OperationUxPresenter::alreadyQueuedToast((string) $result->run->type)
|
$notification->send();
|
||||||
->actions([
|
|
||||||
Action::make('view_run')
|
|
||||||
->label(OperationRunLinks::openLabel())
|
|
||||||
->url($runUrl),
|
|
||||||
])
|
|
||||||
->send();
|
|
||||||
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if ($result->status === 'blocked') {
|
if ($result->status === 'blocked') {
|
||||||
$reasonCode = is_string($result->run->context['reason_code'] ?? null)
|
$notification->send();
|
||||||
? (string) $result->run->context['reason_code']
|
|
||||||
: 'unknown_error';
|
|
||||||
|
|
||||||
$actions = [
|
|
||||||
Action::make('view_run')
|
|
||||||
->label(OperationRunLinks::openLabel())
|
|
||||||
->url($runUrl),
|
|
||||||
];
|
|
||||||
|
|
||||||
$nextSteps = $result->run->context['next_steps'] ?? [];
|
|
||||||
$nextSteps = is_array($nextSteps) ? $nextSteps : [];
|
|
||||||
|
|
||||||
foreach ($nextSteps as $index => $step) {
|
|
||||||
if (! is_array($step)) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
$label = is_string($step['label'] ?? null) ? trim((string) $step['label']) : '';
|
|
||||||
$url = is_string($step['url'] ?? null) ? trim((string) $step['url']) : '';
|
|
||||||
|
|
||||||
if ($label === '' || $url === '') {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
$actions[] = Action::make('next_step_'.$index)
|
|
||||||
->label($label)
|
|
||||||
->url($url);
|
|
||||||
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
|
|
||||||
$reasonEnvelope = app(\App\Support\ReasonTranslation\ReasonPresenter::class)->forOperationRun($result->run, 'notification');
|
|
||||||
$bodyLines = $reasonEnvelope?->toBodyLines() ?? ['Blocked by provider configuration.'];
|
|
||||||
|
|
||||||
Notification::make()
|
|
||||||
->title('Verification blocked')
|
|
||||||
->body(implode("\n", $bodyLines))
|
|
||||||
->warning()
|
|
||||||
->actions($actions)
|
|
||||||
->send();
|
|
||||||
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
OpsUxBrowserEvents::dispatchRunEnqueued($this);
|
OpsUxBrowserEvents::dispatchRunEnqueued($this);
|
||||||
|
|
||||||
OperationUxPresenter::queuedToast((string) $result->run->type)
|
$notification->send();
|
||||||
->actions([
|
|
||||||
Action::make('view_run')
|
|
||||||
->label(OperationRunLinks::openLabel())
|
|
||||||
->url($runUrl),
|
|
||||||
])
|
|
||||||
->send();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@ -121,6 +121,10 @@ public function handle(Request $request, Closure $next): Response
|
|||||||
reason: 'single_membership',
|
reason: 'single_membership',
|
||||||
);
|
);
|
||||||
|
|
||||||
|
if ($this->requestHasExplicitTenantContext($request)) {
|
||||||
|
return $next($request);
|
||||||
|
}
|
||||||
|
|
||||||
return $this->redirectViaTenantBranching($workspace, $user);
|
return $this->redirectViaTenantBranching($workspace, $user);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -144,6 +148,10 @@ public function handle(Request $request, Closure $next): Response
|
|||||||
reason: 'last_used',
|
reason: 'last_used',
|
||||||
);
|
);
|
||||||
|
|
||||||
|
if ($this->requestHasExplicitTenantContext($request)) {
|
||||||
|
return $next($request);
|
||||||
|
}
|
||||||
|
|
||||||
return $this->redirectViaTenantBranching($lastWorkspace, $user);
|
return $this->redirectViaTenantBranching($lastWorkspace, $user);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -203,6 +211,17 @@ private function isChooserFirstPath(string $path): bool
|
|||||||
return in_array($path, ['/admin', '/admin/choose-tenant'], true);
|
return in_array($path, ['/admin', '/admin/choose-tenant'], true);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private function requestHasExplicitTenantContext(Request $request): bool
|
||||||
|
{
|
||||||
|
if (filled($request->query('tenant')) || filled($request->query('tenant_id'))) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
$route = $request->route();
|
||||||
|
|
||||||
|
return $route?->hasParameter('tenant') && filled($route->parameter('tenant'));
|
||||||
|
}
|
||||||
|
|
||||||
private function redirectToChooser(): Response
|
private function redirectToChooser(): Response
|
||||||
{
|
{
|
||||||
return new \Illuminate\Http\Response('', 302, ['Location' => '/admin/choose-workspace']);
|
return new \Illuminate\Http\Response('', 302, ['Location' => '/admin/choose-workspace']);
|
||||||
|
|||||||
@ -31,6 +31,7 @@ public function __construct(
|
|||||||
public string $selectionKey,
|
public string $selectionKey,
|
||||||
public ?string $slotKey = null,
|
public ?string $slotKey = null,
|
||||||
public ?int $runId = null,
|
public ?int $runId = null,
|
||||||
|
public ?int $providerConnectionId = null,
|
||||||
?OperationRun $operationRun = null
|
?OperationRun $operationRun = null
|
||||||
) {
|
) {
|
||||||
$this->operationRun = $operationRun;
|
$this->operationRun = $operationRun;
|
||||||
@ -74,7 +75,7 @@ public function handle(EntraGroupSyncService $syncService, AuditLogger $auditLog
|
|||||||
resourceId: (string) $this->operationRun->getKey(),
|
resourceId: (string) $this->operationRun->getKey(),
|
||||||
);
|
);
|
||||||
|
|
||||||
$result = $syncService->sync($tenant, $this->selectionKey);
|
$result = $syncService->sync($tenant, $this->selectionKey, $this->providerConnectionId());
|
||||||
|
|
||||||
$terminalStatus = 'succeeded';
|
$terminalStatus = 'succeeded';
|
||||||
|
|
||||||
@ -133,4 +134,16 @@ public function handle(EntraGroupSyncService $syncService, AuditLogger $auditLog
|
|||||||
resourceId: (string) $this->operationRun->getKey(),
|
resourceId: (string) $this->operationRun->getKey(),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private function providerConnectionId(): ?int
|
||||||
|
{
|
||||||
|
if (is_int($this->providerConnectionId) && $this->providerConnectionId > 0) {
|
||||||
|
return $this->providerConnectionId;
|
||||||
|
}
|
||||||
|
|
||||||
|
$context = is_array($this->operationRun?->context ?? null) ? $this->operationRun->context : [];
|
||||||
|
$providerConnectionId = $context['provider_connection_id'] ?? null;
|
||||||
|
|
||||||
|
return is_numeric($providerConnectionId) ? (int) $providerConnectionId : null;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -36,6 +36,7 @@ public function __construct(
|
|||||||
public ?string $actorEmail = null,
|
public ?string $actorEmail = null,
|
||||||
public ?string $actorName = null,
|
public ?string $actorName = null,
|
||||||
?OperationRun $operationRun = null,
|
?OperationRun $operationRun = null,
|
||||||
|
public ?int $providerConnectionId = null,
|
||||||
) {
|
) {
|
||||||
$this->operationRun = $operationRun;
|
$this->operationRun = $operationRun;
|
||||||
}
|
}
|
||||||
@ -160,12 +161,15 @@ public function handle(RestoreService $restoreService, AuditLogger $auditLogger)
|
|||||||
);
|
);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
$providerConnectionId = $this->providerConnectionId();
|
||||||
|
|
||||||
$restoreService->executeForRun(
|
$restoreService->executeForRun(
|
||||||
restoreRun: $restoreRun,
|
restoreRun: $restoreRun,
|
||||||
tenant: $tenant,
|
tenant: $tenant,
|
||||||
backupSet: $backupSet,
|
backupSet: $backupSet,
|
||||||
actorEmail: $this->actorEmail,
|
actorEmail: $this->actorEmail,
|
||||||
actorName: $this->actorName,
|
actorName: $this->actorName,
|
||||||
|
providerConnectionId: $providerConnectionId,
|
||||||
);
|
);
|
||||||
|
|
||||||
app(SyncRestoreRunToOperationRun::class)->handle($restoreRun->refresh());
|
app(SyncRestoreRunToOperationRun::class)->handle($restoreRun->refresh());
|
||||||
@ -207,4 +211,16 @@ public function handle(RestoreService $restoreService, AuditLogger $auditLogger)
|
|||||||
throw $throwable;
|
throw $throwable;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private function providerConnectionId(): ?int
|
||||||
|
{
|
||||||
|
if (is_int($this->providerConnectionId) && $this->providerConnectionId > 0) {
|
||||||
|
return $this->providerConnectionId;
|
||||||
|
}
|
||||||
|
|
||||||
|
$context = is_array($this->operationRun?->context ?? null) ? $this->operationRun->context : [];
|
||||||
|
$providerConnectionId = $context['provider_connection_id'] ?? null;
|
||||||
|
|
||||||
|
return is_numeric($providerConnectionId) ? (int) $providerConnectionId : null;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -31,6 +31,7 @@ class SyncRoleDefinitionsJob implements ShouldQueue
|
|||||||
*/
|
*/
|
||||||
public function __construct(
|
public function __construct(
|
||||||
public int $tenantId,
|
public int $tenantId,
|
||||||
|
public ?int $providerConnectionId = null,
|
||||||
?OperationRun $operationRun = null,
|
?OperationRun $operationRun = null,
|
||||||
) {
|
) {
|
||||||
$this->operationRun = $operationRun;
|
$this->operationRun = $operationRun;
|
||||||
@ -69,7 +70,7 @@ public function handle(RoleDefinitionsSyncService $syncService, AuditLogger $aud
|
|||||||
resourceId: (string) $this->operationRun->getKey(),
|
resourceId: (string) $this->operationRun->getKey(),
|
||||||
);
|
);
|
||||||
|
|
||||||
$result = $syncService->sync($tenant);
|
$result = $syncService->sync($tenant, $this->providerConnectionId());
|
||||||
|
|
||||||
/** @var OperationRunService $opService */
|
/** @var OperationRunService $opService */
|
||||||
$opService = app(OperationRunService::class);
|
$opService = app(OperationRunService::class);
|
||||||
@ -124,4 +125,16 @@ public function handle(RoleDefinitionsSyncService $syncService, AuditLogger $aud
|
|||||||
resourceId: (string) $this->operationRun->getKey(),
|
resourceId: (string) $this->operationRun->getKey(),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private function providerConnectionId(): ?int
|
||||||
|
{
|
||||||
|
if (is_int($this->providerConnectionId) && $this->providerConnectionId > 0) {
|
||||||
|
return $this->providerConnectionId;
|
||||||
|
}
|
||||||
|
|
||||||
|
$context = is_array($this->operationRun?->context ?? null) ? $this->operationRun->context : [];
|
||||||
|
$providerConnectionId = $context['provider_connection_id'] ?? null;
|
||||||
|
|
||||||
|
return is_numeric($providerConnectionId) ? (int) $providerConnectionId : null;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -4,13 +4,16 @@
|
|||||||
|
|
||||||
namespace App\Livewire;
|
namespace App\Livewire;
|
||||||
|
|
||||||
use App\Filament\Resources\InventoryItemResource;
|
|
||||||
use App\Models\InventoryItem;
|
use App\Models\InventoryItem;
|
||||||
use App\Models\Tenant;
|
use App\Models\Tenant;
|
||||||
|
use App\Models\User;
|
||||||
|
use App\Services\Auth\CapabilityResolver;
|
||||||
use App\Services\Inventory\DependencyQueryService;
|
use App\Services\Inventory\DependencyQueryService;
|
||||||
use App\Services\Inventory\DependencyTargets\DependencyTargetResolver;
|
use App\Services\Inventory\DependencyTargets\DependencyTargetResolver;
|
||||||
|
use App\Support\Auth\Capabilities;
|
||||||
use App\Support\Enums\RelationshipType;
|
use App\Support\Enums\RelationshipType;
|
||||||
use App\Support\Filament\TablePaginationProfiles;
|
use App\Support\Filament\TablePaginationProfiles;
|
||||||
|
use App\Support\Workspaces\WorkspaceContext;
|
||||||
use Filament\Facades\Filament;
|
use Filament\Facades\Filament;
|
||||||
use Filament\Tables\Columns\TextColumn;
|
use Filament\Tables\Columns\TextColumn;
|
||||||
use Filament\Tables\Filters\SelectFilter;
|
use Filament\Tables\Filters\SelectFilter;
|
||||||
@ -235,7 +238,7 @@ private function resolveInventoryItem(): InventoryItem
|
|||||||
$inventoryItem = InventoryItem::query()->findOrFail($this->inventoryItemId);
|
$inventoryItem = InventoryItem::query()->findOrFail($this->inventoryItemId);
|
||||||
$tenant = $this->resolveCurrentTenant();
|
$tenant = $this->resolveCurrentTenant();
|
||||||
|
|
||||||
if ((int) $inventoryItem->tenant_id !== (int) $tenant->getKey() || ! InventoryItemResource::canView($inventoryItem)) {
|
if (! $this->canViewInventoryItem($inventoryItem, $tenant)) {
|
||||||
throw new NotFoundHttpException;
|
throw new NotFoundHttpException;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -246,6 +249,10 @@ private function resolveCurrentTenant(): Tenant
|
|||||||
{
|
{
|
||||||
$tenant = Filament::getTenant();
|
$tenant = Filament::getTenant();
|
||||||
|
|
||||||
|
if (! $tenant instanceof Tenant) {
|
||||||
|
$tenant = app(WorkspaceContext::class)->rememberedTenant(request());
|
||||||
|
}
|
||||||
|
|
||||||
if (! $tenant instanceof Tenant) {
|
if (! $tenant instanceof Tenant) {
|
||||||
throw new NotFoundHttpException;
|
throw new NotFoundHttpException;
|
||||||
}
|
}
|
||||||
@ -253,6 +260,21 @@ private function resolveCurrentTenant(): Tenant
|
|||||||
return $tenant;
|
return $tenant;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private function canViewInventoryItem(InventoryItem $inventoryItem, Tenant $tenant): bool
|
||||||
|
{
|
||||||
|
$user = auth()->user();
|
||||||
|
|
||||||
|
if (! $user instanceof User) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
$capabilityResolver = app(CapabilityResolver::class);
|
||||||
|
|
||||||
|
return (int) $inventoryItem->tenant_id === (int) $tenant->getKey()
|
||||||
|
&& $capabilityResolver->isMember($user, $tenant)
|
||||||
|
&& $capabilityResolver->can($user, $tenant, Capabilities::TENANT_VIEW);
|
||||||
|
}
|
||||||
|
|
||||||
private function normalizeRelationshipType(mixed $value): ?string
|
private function normalizeRelationshipType(mixed $value): ?string
|
||||||
{
|
{
|
||||||
if (! is_string($value) || $value === '' || $value === 'all') {
|
if (! is_string($value) || $value === '' || $value === 'all') {
|
||||||
|
|||||||
@ -41,6 +41,14 @@ private function assignmentReferences(): array
|
|||||||
|
|
||||||
$tenant = rescue(fn () => Tenant::current(), null);
|
$tenant = rescue(fn () => Tenant::current(), null);
|
||||||
|
|
||||||
|
if (! $tenant instanceof Tenant) {
|
||||||
|
$this->version->loadMissing('tenant');
|
||||||
|
|
||||||
|
$tenant = $this->version->tenant instanceof Tenant
|
||||||
|
? $this->version->tenant
|
||||||
|
: null;
|
||||||
|
}
|
||||||
|
|
||||||
$groupIds = [];
|
$groupIds = [];
|
||||||
$sourceNames = [];
|
$sourceNames = [];
|
||||||
|
|
||||||
|
|||||||
@ -225,6 +225,7 @@ public function presentEnterpriseDetail(BaselineSnapshot $snapshot, array $relat
|
|||||||
title: 'Coverage summary',
|
title: 'Coverage summary',
|
||||||
view: 'filament.infolists.entries.baseline-snapshot-summary-table',
|
view: 'filament.infolists.entries.baseline-snapshot-summary-table',
|
||||||
viewData: ['rows' => $rendered->summaryRows],
|
viewData: ['rows' => $rendered->summaryRows],
|
||||||
|
description: $rendered->fidelitySummary,
|
||||||
emptyState: $factory->emptyState('No captured governed subjects are available in this snapshot.'),
|
emptyState: $factory->emptyState('No captured governed subjects are available in this snapshot.'),
|
||||||
),
|
),
|
||||||
$factory->viewSection(
|
$factory->viewSection(
|
||||||
@ -262,6 +263,7 @@ public function presentEnterpriseDetail(BaselineSnapshot $snapshot, array $relat
|
|||||||
title: 'Coverage',
|
title: 'Coverage',
|
||||||
items: [
|
items: [
|
||||||
$factory->keyFact('Overall fidelity', $fidelitySpec->label, badge: $fidelityBadge),
|
$factory->keyFact('Overall fidelity', $fidelitySpec->label, badge: $fidelityBadge),
|
||||||
|
$factory->keyFact('Fidelity mix', $rendered->fidelitySummary),
|
||||||
$factory->keyFact('Evidence gaps', $rendered->overallGapCount),
|
$factory->keyFact('Evidence gaps', $rendered->overallGapCount),
|
||||||
$factory->keyFact('Captured items', $capturedItemCount),
|
$factory->keyFact('Captured items', $capturedItemCount),
|
||||||
],
|
],
|
||||||
|
|||||||
@ -10,8 +10,10 @@
|
|||||||
use App\Services\Graph\GraphClientInterface;
|
use App\Services\Graph\GraphClientInterface;
|
||||||
use App\Services\Graph\GraphContractRegistry;
|
use App\Services\Graph\GraphContractRegistry;
|
||||||
use App\Services\Graph\GraphResponse;
|
use App\Services\Graph\GraphResponse;
|
||||||
use App\Services\OperationRunService;
|
|
||||||
use App\Services\Providers\MicrosoftGraphOptionsResolver;
|
use App\Services\Providers\MicrosoftGraphOptionsResolver;
|
||||||
|
use App\Services\Providers\ProviderOperationStartGate;
|
||||||
|
use App\Services\Providers\ProviderOperationStartResult;
|
||||||
|
use App\Support\Auth\Capabilities;
|
||||||
use Carbon\CarbonImmutable;
|
use Carbon\CarbonImmutable;
|
||||||
|
|
||||||
class EntraGroupSyncService
|
class EntraGroupSyncService
|
||||||
@ -20,38 +22,38 @@ public function __construct(
|
|||||||
private readonly GraphClientInterface $graph,
|
private readonly GraphClientInterface $graph,
|
||||||
private readonly GraphContractRegistry $contracts,
|
private readonly GraphContractRegistry $contracts,
|
||||||
private readonly MicrosoftGraphOptionsResolver $graphOptionsResolver,
|
private readonly MicrosoftGraphOptionsResolver $graphOptionsResolver,
|
||||||
|
private readonly ProviderOperationStartGate $providerStarts,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
public function startManualSync(Tenant $tenant, User $user): OperationRun
|
public function startManualSync(Tenant $tenant, User $user): ProviderOperationStartResult
|
||||||
{
|
{
|
||||||
$selectionKey = EntraGroupSelection::allGroupsV1();
|
$selectionKey = EntraGroupSelection::allGroupsV1();
|
||||||
|
|
||||||
/** @var OperationRunService $opService */
|
return $this->providerStarts->start(
|
||||||
$opService = app(OperationRunService::class);
|
|
||||||
$opRun = $opService->ensureRunWithIdentity(
|
|
||||||
tenant: $tenant,
|
tenant: $tenant,
|
||||||
type: 'entra_group_sync',
|
connection: null,
|
||||||
identityInputs: ['selection_key' => $selectionKey],
|
operationType: 'entra_group_sync',
|
||||||
context: [
|
dispatcher: function (OperationRun $run) use ($tenant, $selectionKey): void {
|
||||||
'selection_key' => $selectionKey,
|
$providerConnectionId = is_numeric($run->context['provider_connection_id'] ?? null)
|
||||||
'trigger' => 'manual',
|
? (int) $run->context['provider_connection_id']
|
||||||
],
|
: null;
|
||||||
initiator: $user,
|
|
||||||
);
|
|
||||||
|
|
||||||
if (! $opRun->wasRecentlyCreated && in_array($opRun->status, ['queued', 'running'], true)) {
|
EntraGroupSyncJob::dispatch(
|
||||||
return $opRun;
|
|
||||||
}
|
|
||||||
|
|
||||||
dispatch(new EntraGroupSyncJob(
|
|
||||||
tenantId: (int) $tenant->getKey(),
|
tenantId: (int) $tenant->getKey(),
|
||||||
selectionKey: $selectionKey,
|
selectionKey: $selectionKey,
|
||||||
slotKey: null,
|
slotKey: null,
|
||||||
runId: null,
|
runId: null,
|
||||||
operationRun: $opRun,
|
providerConnectionId: $providerConnectionId,
|
||||||
));
|
operationRun: $run,
|
||||||
|
)->afterCommit();
|
||||||
return $opRun;
|
},
|
||||||
|
initiator: $user,
|
||||||
|
extraContext: [
|
||||||
|
'selection_key' => $selectionKey,
|
||||||
|
'trigger' => 'manual',
|
||||||
|
'required_capability' => Capabilities::TENANT_SYNC,
|
||||||
|
],
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -67,7 +69,7 @@ public function startManualSync(Tenant $tenant, User $user): OperationRun
|
|||||||
* error_summary:?string
|
* error_summary:?string
|
||||||
* }
|
* }
|
||||||
*/
|
*/
|
||||||
public function sync(Tenant $tenant, string $selectionKey): array
|
public function sync(Tenant $tenant, string $selectionKey, ?int $providerConnectionId = null): array
|
||||||
{
|
{
|
||||||
$nowUtc = CarbonImmutable::now('UTC');
|
$nowUtc = CarbonImmutable::now('UTC');
|
||||||
|
|
||||||
@ -105,7 +107,9 @@ public function sync(Tenant $tenant, string $selectionKey): array
|
|||||||
$errorSummary = null;
|
$errorSummary = null;
|
||||||
$errorCount = 0;
|
$errorCount = 0;
|
||||||
|
|
||||||
$options = $this->graphOptionsResolver->resolveForTenant($tenant);
|
$options = $providerConnectionId !== null
|
||||||
|
? $this->graphOptionsResolver->resolveForConnection($tenant, $providerConnectionId)
|
||||||
|
: $this->graphOptionsResolver->resolveForTenant($tenant);
|
||||||
$useQuery = $query;
|
$useQuery = $query;
|
||||||
$nextPath = $path;
|
$nextPath = $path;
|
||||||
|
|
||||||
|
|||||||
@ -10,8 +10,10 @@
|
|||||||
use App\Services\Graph\GraphClientInterface;
|
use App\Services\Graph\GraphClientInterface;
|
||||||
use App\Services\Graph\GraphContractRegistry;
|
use App\Services\Graph\GraphContractRegistry;
|
||||||
use App\Services\Graph\GraphResponse;
|
use App\Services\Graph\GraphResponse;
|
||||||
use App\Services\OperationRunService;
|
|
||||||
use App\Services\Providers\MicrosoftGraphOptionsResolver;
|
use App\Services\Providers\MicrosoftGraphOptionsResolver;
|
||||||
|
use App\Services\Providers\ProviderOperationStartGate;
|
||||||
|
use App\Services\Providers\ProviderOperationStartResult;
|
||||||
|
use App\Support\Auth\Capabilities;
|
||||||
use Carbon\CarbonImmutable;
|
use Carbon\CarbonImmutable;
|
||||||
|
|
||||||
class RoleDefinitionsSyncService
|
class RoleDefinitionsSyncService
|
||||||
@ -20,36 +22,35 @@ public function __construct(
|
|||||||
private readonly GraphClientInterface $graph,
|
private readonly GraphClientInterface $graph,
|
||||||
private readonly GraphContractRegistry $contracts,
|
private readonly GraphContractRegistry $contracts,
|
||||||
private readonly MicrosoftGraphOptionsResolver $graphOptionsResolver,
|
private readonly MicrosoftGraphOptionsResolver $graphOptionsResolver,
|
||||||
|
private readonly ProviderOperationStartGate $providerStarts,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
public function startManualSync(Tenant $tenant, User $user): OperationRun
|
public function startManualSync(Tenant $tenant, User $user): ProviderOperationStartResult
|
||||||
{
|
{
|
||||||
$selectionKey = 'role_definitions_v1';
|
$selectionKey = 'role_definitions_v1';
|
||||||
|
|
||||||
/** @var OperationRunService $opService */
|
return $this->providerStarts->start(
|
||||||
$opService = app(OperationRunService::class);
|
|
||||||
|
|
||||||
$opRun = $opService->ensureRunWithIdentity(
|
|
||||||
tenant: $tenant,
|
tenant: $tenant,
|
||||||
type: 'directory_role_definitions.sync',
|
connection: null,
|
||||||
identityInputs: ['selection_key' => $selectionKey],
|
operationType: 'directory_role_definitions.sync',
|
||||||
context: [
|
dispatcher: function (OperationRun $run) use ($tenant): void {
|
||||||
|
$providerConnectionId = is_numeric($run->context['provider_connection_id'] ?? null)
|
||||||
|
? (int) $run->context['provider_connection_id']
|
||||||
|
: null;
|
||||||
|
|
||||||
|
SyncRoleDefinitionsJob::dispatch(
|
||||||
|
tenantId: (int) $tenant->getKey(),
|
||||||
|
providerConnectionId: $providerConnectionId,
|
||||||
|
operationRun: $run,
|
||||||
|
)->afterCommit();
|
||||||
|
},
|
||||||
|
initiator: $user,
|
||||||
|
extraContext: [
|
||||||
'selection_key' => $selectionKey,
|
'selection_key' => $selectionKey,
|
||||||
'trigger' => 'manual',
|
'trigger' => 'manual',
|
||||||
|
'required_capability' => Capabilities::TENANT_MANAGE,
|
||||||
],
|
],
|
||||||
initiator: $user,
|
|
||||||
);
|
);
|
||||||
|
|
||||||
if (! $opRun->wasRecentlyCreated && in_array($opRun->status, ['queued', 'running'], true)) {
|
|
||||||
return $opRun;
|
|
||||||
}
|
|
||||||
|
|
||||||
dispatch(new SyncRoleDefinitionsJob(
|
|
||||||
tenantId: (int) $tenant->getKey(),
|
|
||||||
operationRun: $opRun,
|
|
||||||
));
|
|
||||||
|
|
||||||
return $opRun;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -65,7 +66,7 @@ public function startManualSync(Tenant $tenant, User $user): OperationRun
|
|||||||
* error_summary:?string
|
* error_summary:?string
|
||||||
* }
|
* }
|
||||||
*/
|
*/
|
||||||
public function sync(Tenant $tenant): array
|
public function sync(Tenant $tenant, ?int $providerConnectionId = null): array
|
||||||
{
|
{
|
||||||
$nowUtc = CarbonImmutable::now('UTC');
|
$nowUtc = CarbonImmutable::now('UTC');
|
||||||
|
|
||||||
@ -103,7 +104,9 @@ public function sync(Tenant $tenant): array
|
|||||||
$errorSummary = null;
|
$errorSummary = null;
|
||||||
$errorCount = 0;
|
$errorCount = 0;
|
||||||
|
|
||||||
$options = $this->graphOptionsResolver->resolveForTenant($tenant);
|
$options = $providerConnectionId !== null
|
||||||
|
? $this->graphOptionsResolver->resolveForConnection($tenant, $providerConnectionId)
|
||||||
|
: $this->graphOptionsResolver->resolveForTenant($tenant);
|
||||||
$useQuery = $query;
|
$useQuery = $query;
|
||||||
$nextPath = $path;
|
$nextPath = $path;
|
||||||
|
|
||||||
|
|||||||
@ -236,6 +236,7 @@ public function executeForRun(
|
|||||||
BackupSet $backupSet,
|
BackupSet $backupSet,
|
||||||
?string $actorEmail = null,
|
?string $actorEmail = null,
|
||||||
?string $actorName = null,
|
?string $actorName = null,
|
||||||
|
?int $providerConnectionId = null,
|
||||||
): RestoreRun {
|
): RestoreRun {
|
||||||
$this->assertActiveContext($tenant, $backupSet);
|
$this->assertActiveContext($tenant, $backupSet);
|
||||||
|
|
||||||
@ -266,6 +267,7 @@ public function executeForRun(
|
|||||||
actorName: $actorName,
|
actorName: $actorName,
|
||||||
groupMapping: $restoreRun->group_mapping ?? [],
|
groupMapping: $restoreRun->group_mapping ?? [],
|
||||||
existingRun: $restoreRun,
|
existingRun: $restoreRun,
|
||||||
|
providerConnectionId: $providerConnectionId,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -286,6 +288,7 @@ public function execute(
|
|||||||
?string $actorName = null,
|
?string $actorName = null,
|
||||||
array $groupMapping = [],
|
array $groupMapping = [],
|
||||||
?RestoreRun $existingRun = null,
|
?RestoreRun $existingRun = null,
|
||||||
|
?int $providerConnectionId = null,
|
||||||
): RestoreRun {
|
): RestoreRun {
|
||||||
$this->assertActiveContext($tenant, $backupSet);
|
$this->assertActiveContext($tenant, $backupSet);
|
||||||
|
|
||||||
@ -297,7 +300,7 @@ public function execute(
|
|||||||
$baseGraphOptions = [];
|
$baseGraphOptions = [];
|
||||||
|
|
||||||
if (! $dryRun) {
|
if (! $dryRun) {
|
||||||
$connection = $this->resolveProviderConnection($tenant);
|
$connection = $this->resolveProviderConnection($tenant, $providerConnectionId);
|
||||||
$tenantIdentifier = (string) $connection->entra_tenant_id;
|
$tenantIdentifier = (string) $connection->entra_tenant_id;
|
||||||
$baseGraphOptions = $this->providerGateway()->graphOptions($connection);
|
$baseGraphOptions = $this->providerGateway()->graphOptions($connection);
|
||||||
}
|
}
|
||||||
@ -2910,9 +2913,23 @@ private function buildScopeTagsForVersion(
|
|||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
private function resolveProviderConnection(Tenant $tenant): ProviderConnection
|
private function resolveProviderConnection(Tenant $tenant, ?int $providerConnectionId = null): ProviderConnection
|
||||||
{
|
{
|
||||||
|
if ($providerConnectionId !== null) {
|
||||||
|
$connection = ProviderConnection::query()->find($providerConnectionId);
|
||||||
|
|
||||||
|
if (! $connection instanceof ProviderConnection) {
|
||||||
|
throw new RuntimeException(sprintf(
|
||||||
|
'[%s] %s',
|
||||||
|
ProviderReasonCodes::ProviderConnectionInvalid,
|
||||||
|
'Provider connection is not configured.',
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
$resolution = $this->providerConnections()->validateConnection($tenant, 'microsoft', $connection);
|
||||||
|
} else {
|
||||||
$resolution = $this->providerConnections()->resolveDefault($tenant, 'microsoft');
|
$resolution = $this->providerConnections()->resolveDefault($tenant, 'microsoft');
|
||||||
|
}
|
||||||
|
|
||||||
if ($resolution->resolved && $resolution->connection instanceof ProviderConnection) {
|
if ($resolution->resolved && $resolution->connection instanceof ProviderConnection) {
|
||||||
return $resolution->connection;
|
return $resolution->connection;
|
||||||
|
|||||||
@ -72,7 +72,7 @@ public static function unresolvedFoundation(string $label, string $foundationTyp
|
|||||||
public static function resolvedFoundation(string $label, string $foundationType, string $targetId, string $displayName, ?int $inventoryItemId, Tenant $tenant): self
|
public static function resolvedFoundation(string $label, string $foundationType, string $targetId, string $displayName, ?int $inventoryItemId, Tenant $tenant): self
|
||||||
{
|
{
|
||||||
$maskedId = static::mask($targetId);
|
$maskedId = static::mask($targetId);
|
||||||
$url = $inventoryItemId ? InventoryItemResource::getUrl('view', ['record' => $inventoryItemId], tenant: $tenant) : null;
|
$url = $inventoryItemId ? InventoryItemResource::getUrl('view', ['record' => $inventoryItemId], panel: 'tenant', tenant: $tenant) : null;
|
||||||
|
|
||||||
return new self(
|
return new self(
|
||||||
targetLabel: $label,
|
targetLabel: $label,
|
||||||
|
|||||||
@ -2,7 +2,9 @@
|
|||||||
|
|
||||||
namespace App\Services\Providers;
|
namespace App\Services\Providers;
|
||||||
|
|
||||||
|
use App\Models\ProviderConnection;
|
||||||
use App\Models\Tenant;
|
use App\Models\Tenant;
|
||||||
|
use App\Support\Providers\ProviderReasonCodes;
|
||||||
|
|
||||||
final class MicrosoftGraphOptionsResolver
|
final class MicrosoftGraphOptionsResolver
|
||||||
{
|
{
|
||||||
@ -28,4 +30,37 @@ public function resolveForTenant(Tenant $tenant, array $overrides = []): array
|
|||||||
|
|
||||||
return $this->gateway->graphOptions($resolution->connection, $overrides);
|
return $this->gateway->graphOptions($resolution->connection, $overrides);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<string, mixed>
|
||||||
|
*/
|
||||||
|
public function resolveForConnection(Tenant $tenant, int|ProviderConnection $connection, array $overrides = []): array
|
||||||
|
{
|
||||||
|
$providerConnection = $connection instanceof ProviderConnection
|
||||||
|
? $connection
|
||||||
|
: ProviderConnection::query()->find($connection);
|
||||||
|
|
||||||
|
if (! $providerConnection instanceof ProviderConnection) {
|
||||||
|
throw ProviderConfigurationRequiredException::forTenant(
|
||||||
|
tenant: $tenant,
|
||||||
|
provider: 'microsoft',
|
||||||
|
resolution: ProviderConnectionResolution::blocked(
|
||||||
|
ProviderReasonCodes::ProviderConnectionInvalid,
|
||||||
|
'The selected provider connection could not be found.',
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
$resolution = $this->connections->validateConnection($tenant, 'microsoft', $providerConnection);
|
||||||
|
|
||||||
|
if (! $resolution->resolved || $resolution->connection === null) {
|
||||||
|
throw ProviderConfigurationRequiredException::forTenant(
|
||||||
|
tenant: $tenant,
|
||||||
|
provider: 'microsoft',
|
||||||
|
resolution: $resolution,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this->gateway->graphOptions($resolution->connection, $overrides);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -2,12 +2,13 @@
|
|||||||
|
|
||||||
namespace App\Services\Providers;
|
namespace App\Services\Providers;
|
||||||
|
|
||||||
|
use App\Support\Auth\Capabilities;
|
||||||
use InvalidArgumentException;
|
use InvalidArgumentException;
|
||||||
|
|
||||||
final class ProviderOperationRegistry
|
final class ProviderOperationRegistry
|
||||||
{
|
{
|
||||||
/**
|
/**
|
||||||
* @return array<string, array{provider: string, module: string, label: string}>
|
* @return array<string, array{provider: string, module: string, label: string, required_capability: string}>
|
||||||
*/
|
*/
|
||||||
public function all(): array
|
public function all(): array
|
||||||
{
|
{
|
||||||
@ -16,16 +17,37 @@ public function all(): array
|
|||||||
'provider' => 'microsoft',
|
'provider' => 'microsoft',
|
||||||
'module' => 'health_check',
|
'module' => 'health_check',
|
||||||
'label' => 'Provider connection check',
|
'label' => 'Provider connection check',
|
||||||
|
'required_capability' => Capabilities::PROVIDER_RUN,
|
||||||
],
|
],
|
||||||
'inventory_sync' => [
|
'inventory_sync' => [
|
||||||
'provider' => 'microsoft',
|
'provider' => 'microsoft',
|
||||||
'module' => 'inventory',
|
'module' => 'inventory',
|
||||||
'label' => 'Inventory sync',
|
'label' => 'Inventory sync',
|
||||||
|
'required_capability' => Capabilities::PROVIDER_RUN,
|
||||||
],
|
],
|
||||||
'compliance.snapshot' => [
|
'compliance.snapshot' => [
|
||||||
'provider' => 'microsoft',
|
'provider' => 'microsoft',
|
||||||
'module' => 'compliance',
|
'module' => 'compliance',
|
||||||
'label' => 'Compliance snapshot',
|
'label' => 'Compliance snapshot',
|
||||||
|
'required_capability' => Capabilities::PROVIDER_RUN,
|
||||||
|
],
|
||||||
|
'restore.execute' => [
|
||||||
|
'provider' => 'microsoft',
|
||||||
|
'module' => 'restore',
|
||||||
|
'label' => 'Restore execution',
|
||||||
|
'required_capability' => Capabilities::TENANT_MANAGE,
|
||||||
|
],
|
||||||
|
'entra_group_sync' => [
|
||||||
|
'provider' => 'microsoft',
|
||||||
|
'module' => 'directory_groups',
|
||||||
|
'label' => 'Directory groups sync',
|
||||||
|
'required_capability' => Capabilities::TENANT_SYNC,
|
||||||
|
],
|
||||||
|
'directory_role_definitions.sync' => [
|
||||||
|
'provider' => 'microsoft',
|
||||||
|
'module' => 'directory_role_definitions',
|
||||||
|
'label' => 'Role definitions sync',
|
||||||
|
'required_capability' => Capabilities::TENANT_MANAGE,
|
||||||
],
|
],
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
@ -36,7 +58,7 @@ public function isAllowed(string $operationType): bool
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @return array{provider: string, module: string, label: string}
|
* @return array{provider: string, module: string, label: string, required_capability: string}
|
||||||
*/
|
*/
|
||||||
public function get(string $operationType): array
|
public function get(string $operationType): array
|
||||||
{
|
{
|
||||||
|
|||||||
@ -244,6 +244,15 @@ private function resolveRequiredCapability(string $operationType, array $extraCo
|
|||||||
return trim((string) $extraContext['required_capability']);
|
return trim((string) $extraContext['required_capability']);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if ($this->registry->isAllowed($operationType)) {
|
||||||
|
$definition = $this->registry->get($operationType);
|
||||||
|
$requiredCapability = $definition['required_capability'] ?? null;
|
||||||
|
|
||||||
|
if (is_string($requiredCapability) && trim($requiredCapability) !== '') {
|
||||||
|
return trim($requiredCapability);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if ($this->registry->isAllowed($operationType)) {
|
if ($this->registry->isAllowed($operationType)) {
|
||||||
return Capabilities::PROVIDER_RUN;
|
return Capabilities::PROVIDER_RUN;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -86,6 +86,10 @@ public function handle(Request $request, Closure $next): Response
|
|||||||
! $resolvedContext->hasTenant()
|
! $resolvedContext->hasTenant()
|
||||||
&& $this->adminPathRequiresTenantSelection($path)
|
&& $this->adminPathRequiresTenantSelection($path)
|
||||||
) {
|
) {
|
||||||
|
if ($this->requestHasExplicitTenantHint($request)) {
|
||||||
|
abort(404);
|
||||||
|
}
|
||||||
|
|
||||||
return redirect()->route('filament.admin.pages.choose-tenant');
|
return redirect()->route('filament.admin.pages.choose-tenant');
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -232,12 +236,21 @@ private function isCanonicalWorkspaceRecordViewerPath(string $path): bool
|
|||||||
return TenantPageCategory::fromPath($path) === TenantPageCategory::CanonicalWorkspaceRecordViewer;
|
return TenantPageCategory::fromPath($path) === TenantPageCategory::CanonicalWorkspaceRecordViewer;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private function requestHasExplicitTenantHint(Request $request): bool
|
||||||
|
{
|
||||||
|
return filled($request->query('tenant')) || filled($request->query('tenant_id'));
|
||||||
|
}
|
||||||
|
|
||||||
private function adminPathRequiresTenantSelection(string $path): bool
|
private function adminPathRequiresTenantSelection(string $path): bool
|
||||||
{
|
{
|
||||||
if (! str_starts_with($path, '/admin/')) {
|
if (! str_starts_with($path, '/admin/')) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
return preg_match('#^/admin/(inventory|policies|policy-versions|backup-sets)(/|$)#', $path) === 1;
|
if (str_starts_with($path, '/admin/finding-exceptions/queue')) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return preg_match('#^/admin/(inventory|policies|policy-versions|backup-sets|backup-schedules|findings|finding-exceptions)(/|$)#', $path) === 1;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -119,7 +119,7 @@ private function buildResolvedContext(?Request $request = null): ResolvedShellCo
|
|||||||
$pageCategory = $this->pageCategory($request);
|
$pageCategory = $this->pageCategory($request);
|
||||||
$routeTenantCandidate = $this->resolveRouteTenantCandidate($request, $pageCategory);
|
$routeTenantCandidate = $this->resolveRouteTenantCandidate($request, $pageCategory);
|
||||||
$sessionWorkspace = $this->workspaceContext->currentWorkspace($request);
|
$sessionWorkspace = $this->workspaceContext->currentWorkspace($request);
|
||||||
$workspace = $this->workspaceContext->currentWorkspaceOrTenantWorkspace($routeTenantCandidate, $request);
|
$workspace = $this->resolveWorkspaceForPageCategory($routeTenantCandidate, $pageCategory, $request);
|
||||||
|
|
||||||
$workspaceSource = match (true) {
|
$workspaceSource = match (true) {
|
||||||
$workspace instanceof Workspace && $sessionWorkspace instanceof Workspace && $workspace->is($sessionWorkspace) => 'session_workspace',
|
$workspace instanceof Workspace && $sessionWorkspace instanceof Workspace && $workspace->is($sessionWorkspace) => 'session_workspace',
|
||||||
@ -185,6 +185,19 @@ private function buildResolvedContext(?Request $request = null): ResolvedShellCo
|
|||||||
|
|
||||||
$recoveryReason ??= $queryHintTenant['reason'];
|
$recoveryReason ??= $queryHintTenant['reason'];
|
||||||
|
|
||||||
|
if ($this->requiresStrictQueryTenantHintResolution($request)) {
|
||||||
|
return new ResolvedShellContext(
|
||||||
|
workspace: $workspace,
|
||||||
|
tenant: null,
|
||||||
|
pageCategory: $pageCategory,
|
||||||
|
state: 'invalid_tenant',
|
||||||
|
displayMode: 'recovery',
|
||||||
|
workspaceSource: $workspaceSource,
|
||||||
|
recoveryAction: 'abort_not_found',
|
||||||
|
recoveryReason: $recoveryReason ?? 'missing_tenant',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
$tenant = $this->resolveValidatedFilamentTenant($request, $pageCategory, $workspace);
|
$tenant = $this->resolveValidatedFilamentTenant($request, $pageCategory, $workspace);
|
||||||
|
|
||||||
if ($tenant instanceof Tenant) {
|
if ($tenant instanceof Tenant) {
|
||||||
@ -256,7 +269,7 @@ private function resolveValidatedFilamentTenant(
|
|||||||
}
|
}
|
||||||
|
|
||||||
$pageCategory ??= $this->pageCategory($request);
|
$pageCategory ??= $this->pageCategory($request);
|
||||||
$workspace ??= $this->workspaceContext->currentWorkspaceOrTenantWorkspace($tenant, $request);
|
$workspace ??= $this->resolveWorkspaceForPageCategory($tenant, $pageCategory, $request);
|
||||||
|
|
||||||
if ($workspace instanceof Workspace && $this->tenantValidationReason($tenant, $workspace, $request, $pageCategory) === null) {
|
if ($workspace instanceof Workspace && $this->tenantValidationReason($tenant, $workspace, $request, $pageCategory) === null) {
|
||||||
return $tenant;
|
return $tenant;
|
||||||
@ -288,6 +301,18 @@ private function resolveValidatedRouteTenant(
|
|||||||
return ['tenant' => $tenant, 'reason' => null];
|
return ['tenant' => $tenant, 'reason' => null];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private function resolveWorkspaceForPageCategory(
|
||||||
|
?Tenant $tenant,
|
||||||
|
TenantPageCategory $pageCategory,
|
||||||
|
?Request $request = null,
|
||||||
|
): ?Workspace {
|
||||||
|
return match ($pageCategory) {
|
||||||
|
TenantPageCategory::TenantScopedEvidence => $this->workspaceContext->currentWorkspace($request),
|
||||||
|
TenantPageCategory::TenantBound => $this->workspaceContext->currentWorkspaceOrTenantWorkspace($tenant, $request),
|
||||||
|
default => $this->workspaceContext->currentWorkspaceOrTenantWorkspace($tenant, $request),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
private function resolveValidatedQueryHintTenant(
|
private function resolveValidatedQueryHintTenant(
|
||||||
?Request $request,
|
?Request $request,
|
||||||
Workspace $workspace,
|
Workspace $workspace,
|
||||||
@ -349,6 +374,30 @@ private function resolveQueryTenantHint(?Request $request = null): ?Tenant
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private function hasExplicitQueryTenantHint(?Request $request = null): bool
|
||||||
|
{
|
||||||
|
return filled($request?->query('tenant')) || filled($request?->query('tenant_id'));
|
||||||
|
}
|
||||||
|
|
||||||
|
private function requiresStrictQueryTenantHintResolution(?Request $request = null): bool
|
||||||
|
{
|
||||||
|
if (! $this->hasExplicitQueryTenantHint($request)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
$path = '/'.ltrim((string) $request?->path(), '/');
|
||||||
|
|
||||||
|
if (! str_starts_with($path, '/admin/')) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (str_starts_with($path, '/admin/finding-exceptions/queue')) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return preg_match('#^/admin/(inventory|policies|policy-versions|backup-sets|backup-schedules|findings|finding-exceptions)(/|$)#', $path) === 1;
|
||||||
|
}
|
||||||
|
|
||||||
private function resolveTenantIdentifier(mixed $tenantIdentifier): ?Tenant
|
private function resolveTenantIdentifier(mixed $tenantIdentifier): ?Tenant
|
||||||
{
|
{
|
||||||
if ($tenantIdentifier instanceof Tenant) {
|
if ($tenantIdentifier instanceof Tenant) {
|
||||||
|
|||||||
@ -53,6 +53,34 @@ public static function alreadyQueuedToast(string $operationType): FilamentNotifi
|
|||||||
->duration(self::QUEUED_TOAST_DURATION_MS);
|
->duration(self::QUEUED_TOAST_DURATION_MS);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Canonical provider-backed dedupe feedback using the shared start vocabulary.
|
||||||
|
*/
|
||||||
|
public static function alreadyRunningToast(string $operationType): FilamentNotification
|
||||||
|
{
|
||||||
|
$operationLabel = OperationCatalog::label($operationType);
|
||||||
|
|
||||||
|
return FilamentNotification::make()
|
||||||
|
->title("{$operationLabel} already running")
|
||||||
|
->body('A matching operation is already queued or running. Open the operation for progress and next steps.')
|
||||||
|
->info()
|
||||||
|
->duration(self::QUEUED_TOAST_DURATION_MS);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Canonical provider-backed protected-scope conflict feedback.
|
||||||
|
*/
|
||||||
|
public static function scopeBusyToast(
|
||||||
|
string $title = 'Scope busy',
|
||||||
|
string $body = 'Another provider-backed operation is already running for this scope. Open the active operation for progress and next steps.',
|
||||||
|
): FilamentNotification {
|
||||||
|
return FilamentNotification::make()
|
||||||
|
->title($title)
|
||||||
|
->body($body)
|
||||||
|
->warning()
|
||||||
|
->duration(self::QUEUED_TOAST_DURATION_MS);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Terminal DB notification payload.
|
* Terminal DB notification payload.
|
||||||
*
|
*
|
||||||
|
|||||||
@ -0,0 +1,96 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Support\OpsUx;
|
||||||
|
|
||||||
|
use App\Services\Providers\ProviderOperationStartResult;
|
||||||
|
use App\Support\OperationRunLinks;
|
||||||
|
use App\Support\ReasonTranslation\NextStepOption;
|
||||||
|
use App\Support\ReasonTranslation\ReasonPresenter;
|
||||||
|
use Filament\Actions\Action;
|
||||||
|
use Filament\Notifications\Notification as FilamentNotification;
|
||||||
|
|
||||||
|
final class ProviderOperationStartResultPresenter
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
private readonly ReasonPresenter $reasonPresenter,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<int, Action> $extraActions
|
||||||
|
*/
|
||||||
|
public function notification(
|
||||||
|
ProviderOperationStartResult $result,
|
||||||
|
string $blockedTitle,
|
||||||
|
string $runUrl,
|
||||||
|
array $extraActions = [],
|
||||||
|
string $scopeBusyTitle = 'Scope busy',
|
||||||
|
string $scopeBusyBody = 'Another provider-backed operation is already running for this scope. Open the active operation for progress and next steps.',
|
||||||
|
): FilamentNotification {
|
||||||
|
$notification = match ($result->status) {
|
||||||
|
'started' => OperationUxPresenter::queuedToast((string) $result->run->type),
|
||||||
|
'deduped' => OperationUxPresenter::alreadyRunningToast((string) $result->run->type),
|
||||||
|
'scope_busy' => OperationUxPresenter::scopeBusyToast($scopeBusyTitle, $scopeBusyBody),
|
||||||
|
'blocked' => FilamentNotification::make()
|
||||||
|
->title($blockedTitle)
|
||||||
|
->body(implode("\n", $this->blockedBodyLines($result)))
|
||||||
|
->warning(),
|
||||||
|
default => OperationUxPresenter::queuedToast((string) $result->run->type),
|
||||||
|
};
|
||||||
|
|
||||||
|
return $notification->actions($this->actionsFor($result, $runUrl, $extraActions));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<int, Action> $extraActions
|
||||||
|
* @return array<int, Action>
|
||||||
|
*/
|
||||||
|
private function actionsFor(ProviderOperationStartResult $result, string $runUrl, array $extraActions): array
|
||||||
|
{
|
||||||
|
$actions = [
|
||||||
|
Action::make('view_run')
|
||||||
|
->label(OperationRunLinks::openLabel())
|
||||||
|
->url($runUrl),
|
||||||
|
];
|
||||||
|
|
||||||
|
if ($result->status === 'blocked') {
|
||||||
|
$nextStep = $this->firstNextStep($result);
|
||||||
|
|
||||||
|
if ($nextStep instanceof NextStepOption && $nextStep->kind === 'link' && is_string($nextStep->destination) && trim($nextStep->destination) !== '') {
|
||||||
|
$actions[] = Action::make('next_step_0')
|
||||||
|
->label($nextStep->label)
|
||||||
|
->url($nextStep->destination);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return [...$actions, ...$extraActions];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<int, string>
|
||||||
|
*/
|
||||||
|
private function blockedBodyLines(ProviderOperationStartResult $result): array
|
||||||
|
{
|
||||||
|
$reasonEnvelope = $this->reasonPresenter->forOperationRun($result->run, 'notification');
|
||||||
|
|
||||||
|
return $reasonEnvelope?->toBodyLines() ?? ['Blocked by provider configuration.'];
|
||||||
|
}
|
||||||
|
|
||||||
|
private function firstNextStep(ProviderOperationStartResult $result): ?NextStepOption
|
||||||
|
{
|
||||||
|
$nextSteps = is_array($result->run->context['next_steps'] ?? null)
|
||||||
|
? $result->run->context['next_steps']
|
||||||
|
: [];
|
||||||
|
|
||||||
|
$storedNextStep = NextStepOption::collect($nextSteps)[0] ?? null;
|
||||||
|
|
||||||
|
if ($storedNextStep instanceof NextStepOption) {
|
||||||
|
return $storedNextStep;
|
||||||
|
}
|
||||||
|
|
||||||
|
$reasonEnvelope = $this->reasonPresenter->forOperationRun($result->run, 'notification');
|
||||||
|
|
||||||
|
return $reasonEnvelope?->firstNextStep();
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -123,11 +123,11 @@ function seedSpec177InventoryItemFilterPaginationFixtures(Tenant $tenant): void
|
|||||||
]);
|
]);
|
||||||
session()->put(WorkspaceContext::SESSION_KEY, (int) $tenant->workspace_id);
|
session()->put(WorkspaceContext::SESSION_KEY, (int) $tenant->workspace_id);
|
||||||
|
|
||||||
$coverageUrl = InventoryCoverage::getUrl(tenant: $tenant);
|
$coverageUrl = InventoryCoverage::getUrl(panel: 'tenant', tenant: $tenant);
|
||||||
$basisRunUrl = OperationRunLinks::view($run, $tenant);
|
$basisRunUrl = OperationRunLinks::view($run, $tenant);
|
||||||
$inventoryItemsUrl = InventoryItemResource::getUrl('index', tenant: $tenant);
|
$inventoryItemsUrl = InventoryItemResource::getUrl('index', panel: 'tenant', tenant: $tenant);
|
||||||
|
|
||||||
$searchPage = visit(InventoryItemResource::getUrl('index', tenant: $tenant));
|
$searchPage = visit(InventoryItemResource::getUrl('index', panel: 'tenant', tenant: $tenant));
|
||||||
|
|
||||||
$searchPage
|
$searchPage
|
||||||
->waitForText('Inventory Items')
|
->waitForText('Inventory Items')
|
||||||
|
|||||||
@ -0,0 +1,77 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use App\Filament\Resources\EntraGroupResource\Pages\ListEntraGroups;
|
||||||
|
use App\Jobs\EntraGroupSyncJob;
|
||||||
|
use App\Jobs\SyncRoleDefinitionsJob;
|
||||||
|
use App\Models\OperationRun;
|
||||||
|
use App\Models\Tenant;
|
||||||
|
use App\Services\Directory\RoleDefinitionsSyncService;
|
||||||
|
use App\Services\Graph\GraphClientInterface;
|
||||||
|
use App\Support\Providers\ProviderReasonCodes;
|
||||||
|
use Filament\Facades\Filament;
|
||||||
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||||
|
use Illuminate\Support\Facades\Bus;
|
||||||
|
use Illuminate\Support\Facades\Queue;
|
||||||
|
use Livewire\Livewire;
|
||||||
|
|
||||||
|
uses(RefreshDatabase::class);
|
||||||
|
|
||||||
|
it('starts directory group sync with explicit provider connection context', function (): void {
|
||||||
|
Queue::fake();
|
||||||
|
|
||||||
|
$this->mock(GraphClientInterface::class, function ($mock): void {
|
||||||
|
$mock->shouldReceive('listPolicies')->never();
|
||||||
|
$mock->shouldReceive('getPolicy')->never();
|
||||||
|
$mock->shouldReceive('getOrganization')->never();
|
||||||
|
$mock->shouldReceive('applyPolicy')->never();
|
||||||
|
$mock->shouldReceive('getServicePrincipalPermissions')->never();
|
||||||
|
$mock->shouldReceive('request')->never();
|
||||||
|
});
|
||||||
|
|
||||||
|
[$user, $tenant] = createUserWithTenant(role: 'owner', fixtureProfile: 'credential-enabled');
|
||||||
|
$this->actingAs($user);
|
||||||
|
|
||||||
|
$tenant->makeCurrent();
|
||||||
|
Filament::setTenant($tenant, true);
|
||||||
|
|
||||||
|
Livewire::test(ListEntraGroups::class)
|
||||||
|
->callAction('sync_groups');
|
||||||
|
|
||||||
|
$run = OperationRun::query()
|
||||||
|
->where('tenant_id', (int) $tenant->getKey())
|
||||||
|
->where('type', 'entra_group_sync')
|
||||||
|
->latest('id')
|
||||||
|
->first();
|
||||||
|
|
||||||
|
expect($run)->not->toBeNull();
|
||||||
|
expect($run?->status)->toBe('queued');
|
||||||
|
expect($run?->context['provider_connection_id'] ?? null)->toBeInt();
|
||||||
|
|
||||||
|
Queue::assertPushed(EntraGroupSyncJob::class, function (EntraGroupSyncJob $job) use ($run): bool {
|
||||||
|
return $job->providerConnectionId === ($run?->context['provider_connection_id'] ?? null)
|
||||||
|
&& $job->operationRun?->is($run);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('blocks role definitions sync before queue when no provider connection is available', function (): void {
|
||||||
|
Bus::fake();
|
||||||
|
|
||||||
|
$tenant = Tenant::factory()->create([
|
||||||
|
'app_client_id' => 'client-123',
|
||||||
|
'app_client_secret' => 'secret',
|
||||||
|
'status' => 'active',
|
||||||
|
]);
|
||||||
|
|
||||||
|
[$user, $tenant] = createUserWithTenant(
|
||||||
|
tenant: $tenant,
|
||||||
|
role: 'owner',
|
||||||
|
ensureDefaultMicrosoftProviderConnection: false,
|
||||||
|
);
|
||||||
|
|
||||||
|
$result = app(RoleDefinitionsSyncService::class)->startManualSync($tenant, $user);
|
||||||
|
|
||||||
|
expect($result->status)->toBe('blocked');
|
||||||
|
expect($result->run->context['reason_code'] ?? null)->toBe(ProviderReasonCodes::ProviderConnectionMissing);
|
||||||
|
|
||||||
|
Bus::assertNotDispatched(SyncRoleDefinitionsJob::class);
|
||||||
|
});
|
||||||
@ -38,11 +38,13 @@
|
|||||||
expect($opRun)->not->toBeNull();
|
expect($opRun)->not->toBeNull();
|
||||||
expect($opRun?->status)->toBe('queued');
|
expect($opRun?->status)->toBe('queued');
|
||||||
expect($opRun?->context['selection_key'] ?? null)->toBe('groups-v1:all');
|
expect($opRun?->context['selection_key'] ?? null)->toBe('groups-v1:all');
|
||||||
|
expect($opRun?->context['provider_connection_id'] ?? null)->toBeInt();
|
||||||
|
|
||||||
Queue::assertPushed(EntraGroupSyncJob::class, function (EntraGroupSyncJob $job) use ($tenant, $opRun): bool {
|
Queue::assertPushed(EntraGroupSyncJob::class, function (EntraGroupSyncJob $job) use ($tenant, $opRun): bool {
|
||||||
return $job->tenantId === (int) $tenant->getKey()
|
return $job->tenantId === (int) $tenant->getKey()
|
||||||
&& $job->selectionKey === 'groups-v1:all'
|
&& $job->selectionKey === 'groups-v1:all'
|
||||||
&& $job->runId === null
|
&& $job->runId === null
|
||||||
|
&& $job->providerConnectionId === ($opRun?->context['provider_connection_id'] ?? null)
|
||||||
&& $job->operationRun instanceof OperationRun
|
&& $job->operationRun instanceof OperationRun
|
||||||
&& (int) $job->operationRun->getKey() === (int) $opRun?->getKey();
|
&& (int) $job->operationRun->getKey() === (int) $opRun?->getKey();
|
||||||
});
|
});
|
||||||
|
|||||||
@ -1,8 +1,8 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
use App\Jobs\EntraGroupSyncJob;
|
use App\Jobs\EntraGroupSyncJob;
|
||||||
use App\Models\OperationRun;
|
|
||||||
use App\Services\Directory\EntraGroupSyncService;
|
use App\Services\Directory\EntraGroupSyncService;
|
||||||
|
use App\Services\Providers\ProviderOperationStartResult;
|
||||||
use Illuminate\Support\Facades\Queue;
|
use Illuminate\Support\Facades\Queue;
|
||||||
|
|
||||||
it('starts a manual group sync by creating a run and dispatching a job', function () {
|
it('starts a manual group sync by creating a run and dispatching a job', function () {
|
||||||
@ -12,14 +12,19 @@
|
|||||||
|
|
||||||
$service = app(EntraGroupSyncService::class);
|
$service = app(EntraGroupSyncService::class);
|
||||||
|
|
||||||
$run = $service->startManualSync($tenant, $user);
|
$result = $service->startManualSync($tenant, $user);
|
||||||
|
$run = $result->run;
|
||||||
|
|
||||||
expect($run)->toBeInstanceOf(OperationRun::class)
|
expect($result)->toBeInstanceOf(ProviderOperationStartResult::class)
|
||||||
|
->and($result->status)->toBe('started');
|
||||||
|
|
||||||
|
expect($run)
|
||||||
->and($run->tenant_id)->toBe($tenant->getKey())
|
->and($run->tenant_id)->toBe($tenant->getKey())
|
||||||
->and($run->user_id)->toBe($user->getKey())
|
->and($run->user_id)->toBe($user->getKey())
|
||||||
->and($run->type)->toBe('entra_group_sync')
|
->and($run->type)->toBe('entra_group_sync')
|
||||||
->and($run->status)->toBe('queued')
|
->and($run->status)->toBe('queued')
|
||||||
->and($run->context['selection_key'] ?? null)->toBe('groups-v1:all');
|
->and($run->context['selection_key'] ?? null)->toBe('groups-v1:all')
|
||||||
|
->and($run->context['provider_connection_id'] ?? null)->toBeInt();
|
||||||
|
|
||||||
Queue::assertPushed(EntraGroupSyncJob::class);
|
Queue::assertPushed(EntraGroupSyncJob::class);
|
||||||
});
|
});
|
||||||
|
|||||||
@ -66,8 +66,8 @@ function seedInventoryCoverageBasis(Tenant $tenant): OperationRun
|
|||||||
|
|
||||||
$basisRun = seedInventoryCoverageBasis($tenant);
|
$basisRun = seedInventoryCoverageBasis($tenant);
|
||||||
|
|
||||||
$itemsUrl = InventoryItemResource::getUrl('index', tenant: $tenant);
|
$itemsUrl = InventoryItemResource::getUrl('index', panel: 'tenant', tenant: $tenant);
|
||||||
$coverageUrl = InventoryCoverage::getUrl(tenant: $tenant);
|
$coverageUrl = InventoryCoverage::getUrl(panel: 'tenant', tenant: $tenant);
|
||||||
|
|
||||||
$this->actingAs($user)
|
$this->actingAs($user)
|
||||||
->get($itemsUrl)
|
->get($itemsUrl)
|
||||||
@ -102,7 +102,7 @@ function seedInventoryCoverageBasis(Tenant $tenant): OperationRun
|
|||||||
[$user, $tenant] = createUserWithTenant(tenant: $tenant, role: 'owner');
|
[$user, $tenant] = createUserWithTenant(tenant: $tenant, role: 'owner');
|
||||||
|
|
||||||
$this->actingAs($user)
|
$this->actingAs($user)
|
||||||
->get(InventoryCoverage::getUrl(tenant: $tenant))
|
->get(InventoryCoverage::getUrl(panel: 'tenant', tenant: $tenant))
|
||||||
->assertOk()
|
->assertOk()
|
||||||
->assertSee('No current coverage basis')
|
->assertSee('No current coverage basis')
|
||||||
->assertSee('Run Inventory Sync from Inventory Items to establish current tenant coverage truth.')
|
->assertSee('Run Inventory Sync from Inventory Items to establish current tenant coverage truth.')
|
||||||
|
|||||||
@ -0,0 +1,157 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
use Tests\Support\OpsUx\SourceFileScanner;
|
||||||
|
|
||||||
|
function providerDispatchGateSlice(string $source, string $startAnchor, ?string $endAnchor = null): string
|
||||||
|
{
|
||||||
|
$start = strpos($source, $startAnchor);
|
||||||
|
|
||||||
|
expect($start)->not->toBeFalse();
|
||||||
|
|
||||||
|
$start = is_int($start) ? $start : 0;
|
||||||
|
|
||||||
|
if ($endAnchor === null) {
|
||||||
|
return substr($source, $start);
|
||||||
|
}
|
||||||
|
|
||||||
|
$end = strpos($source, $endAnchor, $start + strlen($startAnchor));
|
||||||
|
|
||||||
|
expect($end)->not->toBeFalse();
|
||||||
|
|
||||||
|
$end = is_int($end) ? $end : strlen($source);
|
||||||
|
|
||||||
|
return substr($source, $start, $end - $start);
|
||||||
|
}
|
||||||
|
|
||||||
|
it('keeps first-slice route-bounded provider starts on canonical gate-owned entry points', function (): void {
|
||||||
|
$root = SourceFileScanner::projectRoot();
|
||||||
|
|
||||||
|
$checks = [
|
||||||
|
[
|
||||||
|
'file' => $root.'/app/Services/Verification/StartVerification.php',
|
||||||
|
'start' => 'public function providerConnectionCheckForTenant(',
|
||||||
|
'end' => 'public function providerConnectionCheckUsingConnection(',
|
||||||
|
'required' => [
|
||||||
|
'return $this->providers->start(',
|
||||||
|
"operationType: 'provider.connection.check'",
|
||||||
|
"'required_capability' => Capabilities::PROVIDER_RUN",
|
||||||
|
],
|
||||||
|
'forbidden' => ['ensureRun(', 'dispatchOrFail('],
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'file' => $root.'/app/Services/Verification/StartVerification.php',
|
||||||
|
'start' => 'public function providerConnectionCheckUsingConnection(',
|
||||||
|
'end' => 'private function dispatchConnectionHealthCheck(',
|
||||||
|
'required' => [
|
||||||
|
'$result = $this->providers->start(',
|
||||||
|
"operationType: 'provider.connection.check'",
|
||||||
|
'ProviderVerificationStatus::Pending',
|
||||||
|
],
|
||||||
|
'forbidden' => ['ensureRun(', 'dispatchOrFail('],
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'file' => $root.'/app/Services/Directory/EntraGroupSyncService.php',
|
||||||
|
'start' => 'public function startManualSync(',
|
||||||
|
'end' => 'public function sync(',
|
||||||
|
'required' => [
|
||||||
|
'return $this->providerStarts->start(',
|
||||||
|
"operationType: 'entra_group_sync'",
|
||||||
|
'EntraGroupSyncJob::dispatch(',
|
||||||
|
'->afterCommit()',
|
||||||
|
],
|
||||||
|
'forbidden' => ['ensureRun(', 'dispatchOrFail('],
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'file' => $root.'/app/Services/Directory/RoleDefinitionsSyncService.php',
|
||||||
|
'start' => 'public function startManualSync(',
|
||||||
|
'end' => 'public function sync(',
|
||||||
|
'required' => [
|
||||||
|
'return $this->providerStarts->start(',
|
||||||
|
"operationType: 'directory_role_definitions.sync'",
|
||||||
|
'SyncRoleDefinitionsJob::dispatch(',
|
||||||
|
'->afterCommit()',
|
||||||
|
],
|
||||||
|
'forbidden' => ['ensureRun(', 'dispatchOrFail('],
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'file' => $root.'/app/Filament/Resources/RestoreRunResource.php',
|
||||||
|
'start' => 'private static function startQueuedRestoreExecution(',
|
||||||
|
'end' => 'private static function detailPreviewState(',
|
||||||
|
'required' => [
|
||||||
|
'app(ProviderOperationStartGate::class)->start(',
|
||||||
|
"operationType: 'restore.execute'",
|
||||||
|
'ExecuteRestoreRunJob::dispatch(',
|
||||||
|
'->afterCommit()',
|
||||||
|
],
|
||||||
|
'forbidden' => ['ensureRun(', 'dispatchOrFail('],
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'file' => $root.'/app/Filament/Resources/EntraGroupResource/Pages/ListEntraGroups.php',
|
||||||
|
'start' => "Action::make('sync_groups')",
|
||||||
|
'end' => '->requireCapability(Capabilities::TENANT_SYNC)',
|
||||||
|
'required' => [
|
||||||
|
'$syncService->startManualSync($tenant, $user)',
|
||||||
|
'ProviderOperationStartResultPresenter::class',
|
||||||
|
],
|
||||||
|
'forbidden' => ['ensureRun(', 'dispatchOrFail('],
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'file' => $root.'/app/Filament/Resources/TenantResource.php',
|
||||||
|
'start' => 'public static function syncRoleDefinitionsAction(): Actions\\Action',
|
||||||
|
'end' => null,
|
||||||
|
'required' => [
|
||||||
|
'$result = $syncService->startManualSync($record, $user);',
|
||||||
|
'ProviderOperationStartResultPresenter::class',
|
||||||
|
],
|
||||||
|
'forbidden' => ['ensureRun(', 'dispatchOrFail('],
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'file' => $root.'/app/Filament/Resources/ProviderConnectionResource.php',
|
||||||
|
'start' => 'private static function handleCheckConnectionAction(',
|
||||||
|
'end' => 'private static function handleProviderOperationAction(',
|
||||||
|
'required' => [
|
||||||
|
'$verification->providerConnectionCheck(',
|
||||||
|
'ProviderOperationStartResultPresenter::class',
|
||||||
|
'OperationRunLinks::view($result->run, $tenant)',
|
||||||
|
],
|
||||||
|
'forbidden' => ['ensureRun(', 'dispatchOrFail('],
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'file' => $root.'/app/Filament/Resources/ProviderConnectionResource.php',
|
||||||
|
'start' => 'private static function handleProviderOperationAction(',
|
||||||
|
'end' => 'public static function getEloquentQuery(): Builder',
|
||||||
|
'required' => [
|
||||||
|
'$result = $gate->start(',
|
||||||
|
'ProviderOperationStartResultPresenter::class',
|
||||||
|
'OperationRunLinks::view($result->run, $tenant)',
|
||||||
|
],
|
||||||
|
'forbidden' => ['ensureRun(', 'dispatchOrFail('],
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'file' => $root.'/app/Filament/Pages/Workspaces/ManagedTenantOnboardingWizard.php',
|
||||||
|
'start' => 'public function startBootstrap(array $operationTypes): void',
|
||||||
|
'end' => 'private function dispatchBootstrapJob(',
|
||||||
|
'required' => [
|
||||||
|
'app(ProviderOperationStartGate::class)->start(',
|
||||||
|
'ProviderOperationStartResultPresenter::class',
|
||||||
|
"'bootstrap_operation_types'",
|
||||||
|
],
|
||||||
|
'forbidden' => ['ensureRun(', 'dispatchOrFail('],
|
||||||
|
],
|
||||||
|
];
|
||||||
|
|
||||||
|
foreach ($checks as $check) {
|
||||||
|
$source = SourceFileScanner::read($check['file']);
|
||||||
|
$slice = providerDispatchGateSlice($source, $check['start'], $check['end']);
|
||||||
|
|
||||||
|
foreach ($check['required'] as $needle) {
|
||||||
|
expect($slice)->toContain($needle);
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach ($check['forbidden'] as $needle) {
|
||||||
|
expect($slice)->not->toContain($needle);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})->group('surface-guard');
|
||||||
@ -136,6 +136,7 @@
|
|||||||
->and($families->has('policy-resource-admin-search-parity'))->toBeTrue()
|
->and($families->has('policy-resource-admin-search-parity'))->toBeTrue()
|
||||||
->and($families->has('workspace-only-admin-surface-independence'))->toBeTrue()
|
->and($families->has('workspace-only-admin-surface-independence'))->toBeTrue()
|
||||||
->and($families->has('workspace-settings-slice-management'))->toBeTrue()
|
->and($families->has('workspace-settings-slice-management'))->toBeTrue()
|
||||||
|
->and($families->has('provider-dispatch-gate-coverage'))->toBeTrue()
|
||||||
->and($families->has('baseline-compare-matrix-workflow'))->toBeTrue()
|
->and($families->has('baseline-compare-matrix-workflow'))->toBeTrue()
|
||||||
->and($families->has('browser-smoke'))->toBeTrue();
|
->and($families->has('browser-smoke'))->toBeTrue();
|
||||||
|
|
||||||
@ -159,7 +160,7 @@
|
|||||||
expect($familyBudgets)->not->toBeEmpty()
|
expect($familyBudgets)->not->toBeEmpty()
|
||||||
->and($familyBudgets[0])->toHaveKeys(['familyId', 'targetType', 'targetId', 'selectors', 'thresholdSeconds'])
|
->and($familyBudgets[0])->toHaveKeys(['familyId', 'targetType', 'targetId', 'selectors', 'thresholdSeconds'])
|
||||||
->and(collect($familyBudgets)->pluck('familyId')->all())
|
->and(collect($familyBudgets)->pluck('familyId')->all())
|
||||||
->toContain('action-surface-contract', 'browser-smoke', 'baseline-compare-matrix-workflow', 'baseline-profile-start-surfaces', 'drift-bulk-triage-all-matching', 'finding-bulk-actions-workflow', 'findings-workflow-surfaces', 'workspace-only-admin-surface-independence', 'workspace-settings-slice-management');
|
->toContain('action-surface-contract', 'browser-smoke', 'baseline-compare-matrix-workflow', 'baseline-profile-start-surfaces', 'drift-bulk-triage-all-matching', 'finding-bulk-actions-workflow', 'findings-workflow-surfaces', 'provider-dispatch-gate-coverage', 'workspace-only-admin-surface-independence', 'workspace-settings-slice-management');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('publishes the heavy-governance contract, inventory, and guidance surfaces needed for honest rerun review', function (): void {
|
it('publishes the heavy-governance contract, inventory, and guidance surfaces needed for honest rerun review', function (): void {
|
||||||
|
|||||||
@ -5,11 +5,24 @@
|
|||||||
use App\Models\InventoryLink;
|
use App\Models\InventoryLink;
|
||||||
use App\Models\Tenant;
|
use App\Models\Tenant;
|
||||||
use App\Services\Graph\GraphClientInterface;
|
use App\Services\Graph\GraphClientInterface;
|
||||||
|
use App\Support\Workspaces\WorkspaceContext;
|
||||||
use Illuminate\Support\Str;
|
use Illuminate\Support\Str;
|
||||||
|
|
||||||
|
function inventoryItemAdminSession(Tenant $tenant): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
WorkspaceContext::SESSION_KEY => (int) $tenant->workspace_id,
|
||||||
|
WorkspaceContext::LAST_TENANT_IDS_SESSION_KEY => [
|
||||||
|
(string) $tenant->workspace_id => (int) $tenant->getKey(),
|
||||||
|
],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
it('shows zero-state when no dependencies and shows missing badge when applicable', function () {
|
it('shows zero-state when no dependencies and shows missing badge when applicable', function () {
|
||||||
[$user, $tenant] = createUserWithTenant();
|
[$user, $tenant] = createUserWithTenant();
|
||||||
$this->actingAs($user);
|
$this->actingAs($user);
|
||||||
|
setAdminPanelContext();
|
||||||
|
$session = inventoryItemAdminSession($tenant);
|
||||||
|
|
||||||
/** @var InventoryItem $item */
|
/** @var InventoryItem $item */
|
||||||
$item = InventoryItem::factory()->create([
|
$item = InventoryItem::factory()->create([
|
||||||
@ -19,7 +32,7 @@
|
|||||||
|
|
||||||
// Zero state
|
// Zero state
|
||||||
$url = InventoryItemResource::getUrl('view', ['record' => $item], panel: 'admin').'?tenant='.(string) $tenant->external_id;
|
$url = InventoryItemResource::getUrl('view', ['record' => $item], panel: 'admin').'?tenant='.(string) $tenant->external_id;
|
||||||
$this->get($url)->assertOk()->assertSee('No dependencies found');
|
$this->withSession($session)->get($url)->assertOk()->assertSee('No dependencies found');
|
||||||
|
|
||||||
// Create a missing edge and assert badge appears
|
// Create a missing edge and assert badge appears
|
||||||
InventoryLink::factory()->create([
|
InventoryLink::factory()->create([
|
||||||
@ -35,7 +48,7 @@
|
|||||||
],
|
],
|
||||||
]);
|
]);
|
||||||
|
|
||||||
$this->get($url)
|
$this->withSession($session)->get($url)
|
||||||
->assertOk()
|
->assertOk()
|
||||||
->assertSee('Missing')
|
->assertSee('Missing')
|
||||||
->assertSee('Last known: Ghost Target');
|
->assertSee('Last known: Ghost Target');
|
||||||
@ -44,6 +57,8 @@
|
|||||||
it('renders native dependency controls in place instead of a GET apply workflow', function () {
|
it('renders native dependency controls in place instead of a GET apply workflow', function () {
|
||||||
[$user, $tenant] = createUserWithTenant();
|
[$user, $tenant] = createUserWithTenant();
|
||||||
$this->actingAs($user);
|
$this->actingAs($user);
|
||||||
|
setAdminPanelContext();
|
||||||
|
$session = inventoryItemAdminSession($tenant);
|
||||||
|
|
||||||
/** @var InventoryItem $item */
|
/** @var InventoryItem $item */
|
||||||
$item = InventoryItem::factory()->create([
|
$item = InventoryItem::factory()->create([
|
||||||
@ -82,7 +97,7 @@
|
|||||||
|
|
||||||
$url = InventoryItemResource::getUrl('view', ['record' => $item], panel: 'admin').'?tenant='.(string) $tenant->external_id.'&direction=outbound';
|
$url = InventoryItemResource::getUrl('view', ['record' => $item], panel: 'admin').'?tenant='.(string) $tenant->external_id.'&direction=outbound';
|
||||||
|
|
||||||
$this->get($url)
|
$this->withSession($session)->get($url)
|
||||||
->assertOk()
|
->assertOk()
|
||||||
->assertSee('Direction')
|
->assertSee('Direction')
|
||||||
->assertSee('Inbound')
|
->assertSee('Inbound')
|
||||||
@ -95,6 +110,8 @@
|
|||||||
it('ignores legacy relationship query state while preserving visible target safety', function () {
|
it('ignores legacy relationship query state while preserving visible target safety', function () {
|
||||||
[$user, $tenant] = createUserWithTenant();
|
[$user, $tenant] = createUserWithTenant();
|
||||||
$this->actingAs($user);
|
$this->actingAs($user);
|
||||||
|
setAdminPanelContext();
|
||||||
|
$session = inventoryItemAdminSession($tenant);
|
||||||
|
|
||||||
/** @var InventoryItem $item */
|
/** @var InventoryItem $item */
|
||||||
$item = InventoryItem::factory()->create([
|
$item = InventoryItem::factory()->create([
|
||||||
@ -126,7 +143,7 @@
|
|||||||
$url = InventoryItemResource::getUrl('view', ['record' => $item], panel: 'admin')
|
$url = InventoryItemResource::getUrl('view', ['record' => $item], panel: 'admin')
|
||||||
.'?tenant='.(string) $tenant->external_id.'&direction=outbound&relationship_type=scoped_by';
|
.'?tenant='.(string) $tenant->external_id.'&direction=outbound&relationship_type=scoped_by';
|
||||||
|
|
||||||
$this->get($url)
|
$this->withSession($session)->get($url)
|
||||||
->assertOk()
|
->assertOk()
|
||||||
->assertSee('Scoped Target')
|
->assertSee('Scoped Target')
|
||||||
->assertSee('Assigned Target');
|
->assertSee('Assigned Target');
|
||||||
@ -135,6 +152,8 @@
|
|||||||
it('does not show edges from other tenants (tenant isolation)', function () {
|
it('does not show edges from other tenants (tenant isolation)', function () {
|
||||||
[$user, $tenant] = createUserWithTenant();
|
[$user, $tenant] = createUserWithTenant();
|
||||||
$this->actingAs($user);
|
$this->actingAs($user);
|
||||||
|
setAdminPanelContext();
|
||||||
|
$session = inventoryItemAdminSession($tenant);
|
||||||
|
|
||||||
/** @var InventoryItem $item */
|
/** @var InventoryItem $item */
|
||||||
$item = InventoryItem::factory()->create([
|
$item = InventoryItem::factory()->create([
|
||||||
@ -156,7 +175,7 @@
|
|||||||
]);
|
]);
|
||||||
|
|
||||||
$url = InventoryItemResource::getUrl('view', ['record' => $item], panel: 'admin').'?tenant='.(string) $tenant->external_id;
|
$url = InventoryItemResource::getUrl('view', ['record' => $item], panel: 'admin').'?tenant='.(string) $tenant->external_id;
|
||||||
$this->get($url)
|
$this->withSession($session)->get($url)
|
||||||
->assertOk()
|
->assertOk()
|
||||||
->assertDontSee('Other Tenant Edge');
|
->assertDontSee('Other Tenant Edge');
|
||||||
});
|
});
|
||||||
@ -164,6 +183,8 @@
|
|||||||
it('shows masked identifier when last known name is missing', function () {
|
it('shows masked identifier when last known name is missing', function () {
|
||||||
[$user, $tenant] = createUserWithTenant();
|
[$user, $tenant] = createUserWithTenant();
|
||||||
$this->actingAs($user);
|
$this->actingAs($user);
|
||||||
|
setAdminPanelContext();
|
||||||
|
$session = inventoryItemAdminSession($tenant);
|
||||||
|
|
||||||
/** @var InventoryItem $item */
|
/** @var InventoryItem $item */
|
||||||
$item = InventoryItem::factory()->create([
|
$item = InventoryItem::factory()->create([
|
||||||
@ -185,7 +206,7 @@
|
|||||||
]);
|
]);
|
||||||
|
|
||||||
$url = InventoryItemResource::getUrl('view', ['record' => $item], panel: 'admin').'?tenant='.(string) $tenant->external_id;
|
$url = InventoryItemResource::getUrl('view', ['record' => $item], panel: 'admin').'?tenant='.(string) $tenant->external_id;
|
||||||
$this->get($url)
|
$this->withSession($session)->get($url)
|
||||||
->assertOk()
|
->assertOk()
|
||||||
->assertSee('Group (external): 123456…');
|
->assertSee('Group (external): 123456…');
|
||||||
});
|
});
|
||||||
@ -193,6 +214,8 @@
|
|||||||
it('resolves scope tag and assignment filter names from local inventory when available and labels groups as external', function () {
|
it('resolves scope tag and assignment filter names from local inventory when available and labels groups as external', function () {
|
||||||
[$user, $tenant] = createUserWithTenant();
|
[$user, $tenant] = createUserWithTenant();
|
||||||
$this->actingAs($user);
|
$this->actingAs($user);
|
||||||
|
setAdminPanelContext();
|
||||||
|
$session = inventoryItemAdminSession($tenant);
|
||||||
|
|
||||||
/** @var InventoryItem $item */
|
/** @var InventoryItem $item */
|
||||||
$item = InventoryItem::factory()->create([
|
$item = InventoryItem::factory()->create([
|
||||||
@ -254,7 +277,7 @@
|
|||||||
]);
|
]);
|
||||||
|
|
||||||
$url = InventoryItemResource::getUrl('view', ['record' => $item], panel: 'admin').'?tenant='.(string) $tenant->external_id;
|
$url = InventoryItemResource::getUrl('view', ['record' => $item], panel: 'admin').'?tenant='.(string) $tenant->external_id;
|
||||||
$this->get($url)
|
$this->withSession($session)->get($url)
|
||||||
->assertOk()
|
->assertOk()
|
||||||
->assertSee('Scope Tag: Finance (6…)')
|
->assertSee('Scope Tag: Finance (6…)')
|
||||||
->assertSee('Assignment Filter: VIP Devices (62fb77…)')
|
->assertSee('Assignment Filter: VIP Devices (62fb77…)')
|
||||||
@ -264,6 +287,8 @@
|
|||||||
it('does not call Graph client while rendering inventory item dependencies view (FR-006 guard)', function () {
|
it('does not call Graph client while rendering inventory item dependencies view (FR-006 guard)', function () {
|
||||||
[$user, $tenant] = createUserWithTenant();
|
[$user, $tenant] = createUserWithTenant();
|
||||||
$this->actingAs($user);
|
$this->actingAs($user);
|
||||||
|
setAdminPanelContext();
|
||||||
|
$session = inventoryItemAdminSession($tenant);
|
||||||
|
|
||||||
$graph = \Mockery::mock(GraphClientInterface::class);
|
$graph = \Mockery::mock(GraphClientInterface::class);
|
||||||
$graph->shouldNotReceive('listPolicies');
|
$graph->shouldNotReceive('listPolicies');
|
||||||
@ -301,7 +326,7 @@
|
|||||||
]);
|
]);
|
||||||
|
|
||||||
$url = InventoryItemResource::getUrl('view', ['record' => $item], panel: 'admin').'?tenant='.(string) $tenant->external_id;
|
$url = InventoryItemResource::getUrl('view', ['record' => $item], panel: 'admin').'?tenant='.(string) $tenant->external_id;
|
||||||
$this->get($url)
|
$this->withSession($session)->get($url)
|
||||||
->assertOk()
|
->assertOk()
|
||||||
->assertSee('Scope Tag: Finance');
|
->assertSee('Scope Tag: Finance');
|
||||||
});
|
});
|
||||||
|
|||||||
@ -992,7 +992,7 @@
|
|||||||
expect($session->completed_at)->not->toBeNull();
|
expect($session->completed_at)->not->toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('starts selected bootstrap actions as separate operation runs and dispatches their jobs', function (): void {
|
it('starts one selected bootstrap action at a time and persists the remaining selections', function (): void {
|
||||||
Bus::fake();
|
Bus::fake();
|
||||||
|
|
||||||
$workspace = Workspace::factory()->create();
|
$workspace = Workspace::factory()->create();
|
||||||
@ -1048,17 +1048,105 @@
|
|||||||
$component->call('startBootstrap', ['inventory_sync', 'compliance.snapshot']);
|
$component->call('startBootstrap', ['inventory_sync', 'compliance.snapshot']);
|
||||||
|
|
||||||
Bus::assertDispatchedTimes(\App\Jobs\ProviderInventorySyncJob::class, 1);
|
Bus::assertDispatchedTimes(\App\Jobs\ProviderInventorySyncJob::class, 1);
|
||||||
Bus::assertDispatchedTimes(\App\Jobs\ProviderComplianceSnapshotJob::class, 1);
|
Bus::assertNotDispatched(\App\Jobs\ProviderComplianceSnapshotJob::class);
|
||||||
|
|
||||||
expect(OperationRun::query()
|
expect(OperationRun::query()
|
||||||
->where('tenant_id', (int) $tenant->getKey())
|
->where('tenant_id', (int) $tenant->getKey())
|
||||||
->whereIn('type', ['inventory_sync', 'compliance.snapshot'])
|
->where('type', 'inventory_sync')
|
||||||
->count())->toBe(2);
|
->count())->toBe(1);
|
||||||
|
|
||||||
|
expect(OperationRun::query()
|
||||||
|
->where('tenant_id', (int) $tenant->getKey())
|
||||||
|
->where('type', 'compliance.snapshot')
|
||||||
|
->count())->toBe(0);
|
||||||
|
|
||||||
$session->refresh();
|
$session->refresh();
|
||||||
$runs = $session->state['bootstrap_operation_runs'] ?? [];
|
$runs = $session->state['bootstrap_operation_runs'] ?? [];
|
||||||
expect($runs)->toBeArray();
|
expect($runs)->toBeArray();
|
||||||
expect($runs['inventory_sync'] ?? null)->toBeInt();
|
expect($runs['inventory_sync'] ?? null)->toBeInt();
|
||||||
|
expect($runs['compliance.snapshot'] ?? null)->toBeNull();
|
||||||
|
expect($session->state['bootstrap_operation_types'] ?? null)->toBe(['inventory_sync', 'compliance.snapshot']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('starts the next pending bootstrap action after the prior one completes successfully', function (): void {
|
||||||
|
Bus::fake();
|
||||||
|
|
||||||
|
$workspace = Workspace::factory()->create();
|
||||||
|
$user = User::factory()->create();
|
||||||
|
|
||||||
|
WorkspaceMembership::factory()->create([
|
||||||
|
'workspace_id' => $workspace->getKey(),
|
||||||
|
'user_id' => $user->getKey(),
|
||||||
|
'role' => 'owner',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->actingAs($user);
|
||||||
|
|
||||||
|
$tenantGuid = 'ffffffff-ffff-ffff-ffff-ffffffffffff';
|
||||||
|
|
||||||
|
$component = Livewire::test(\App\Filament\Pages\Workspaces\ManagedTenantOnboardingWizard::class, ['workspace' => $workspace]);
|
||||||
|
$component->call('identifyManagedTenant', ['tenant_id' => $tenantGuid, 'name' => 'Acme']);
|
||||||
|
|
||||||
|
$tenant = Tenant::query()->where('tenant_id', $tenantGuid)->firstOrFail();
|
||||||
|
|
||||||
|
$connection = ProviderConnection::factory()->create([
|
||||||
|
'tenant_id' => (int) $tenant->getKey(),
|
||||||
|
'provider' => 'microsoft',
|
||||||
|
'entra_tenant_id' => $tenantGuid,
|
||||||
|
'is_default' => true,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$verificationRun = OperationRun::query()->create([
|
||||||
|
'tenant_id' => (int) $tenant->getKey(),
|
||||||
|
'user_id' => (int) $user->getKey(),
|
||||||
|
'initiator_name' => $user->name,
|
||||||
|
'type' => 'provider.connection.check',
|
||||||
|
'status' => 'completed',
|
||||||
|
'outcome' => 'succeeded',
|
||||||
|
'run_identity_hash' => sha1('verify-ok-bootstrap-next-'.(string) $connection->getKey()),
|
||||||
|
'context' => [
|
||||||
|
'provider_connection_id' => (int) $connection->getKey(),
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
|
||||||
|
$session = TenantOnboardingSession::query()
|
||||||
|
->where('workspace_id', (int) $workspace->getKey())
|
||||||
|
->where('tenant_id', (int) $tenant->getKey())
|
||||||
|
->firstOrFail();
|
||||||
|
|
||||||
|
$session->update([
|
||||||
|
'state' => array_merge($session->state ?? [], [
|
||||||
|
'provider_connection_id' => (int) $connection->getKey(),
|
||||||
|
'verification_operation_run_id' => (int) $verificationRun->getKey(),
|
||||||
|
]),
|
||||||
|
]);
|
||||||
|
|
||||||
|
$component->call('startBootstrap', ['inventory_sync', 'compliance.snapshot']);
|
||||||
|
|
||||||
|
$inventoryRun = OperationRun::query()
|
||||||
|
->where('tenant_id', (int) $tenant->getKey())
|
||||||
|
->where('type', 'inventory_sync')
|
||||||
|
->latest('id')
|
||||||
|
->firstOrFail();
|
||||||
|
|
||||||
|
$inventoryRun->forceFill([
|
||||||
|
'status' => 'completed',
|
||||||
|
'outcome' => 'succeeded',
|
||||||
|
])->save();
|
||||||
|
|
||||||
|
$component->call('startBootstrap', ['inventory_sync', 'compliance.snapshot']);
|
||||||
|
|
||||||
|
Bus::assertDispatchedTimes(\App\Jobs\ProviderInventorySyncJob::class, 1);
|
||||||
|
Bus::assertDispatchedTimes(\App\Jobs\ProviderComplianceSnapshotJob::class, 1);
|
||||||
|
|
||||||
|
expect(OperationRun::query()
|
||||||
|
->where('tenant_id', (int) $tenant->getKey())
|
||||||
|
->where('type', 'compliance.snapshot')
|
||||||
|
->count())->toBe(1);
|
||||||
|
|
||||||
|
$session->refresh();
|
||||||
|
$runs = $session->state['bootstrap_operation_runs'] ?? [];
|
||||||
|
expect($runs['inventory_sync'] ?? null)->toBeInt();
|
||||||
expect($runs['compliance.snapshot'] ?? null)->toBeInt();
|
expect($runs['compliance.snapshot'] ?? null)->toBeInt();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@ -104,6 +104,6 @@
|
|||||||
->withSession([WorkspaceContext::SESSION_KEY => (int) $tenantA->workspace_id])
|
->withSession([WorkspaceContext::SESSION_KEY => (int) $tenantA->workspace_id])
|
||||||
->get(route('admin.evidence.overview'))
|
->get(route('admin.evidence.overview'))
|
||||||
->assertOk()
|
->assertOk()
|
||||||
->assertSee(EvidenceSnapshotResource::getUrl('view', ['record' => $allowedSnapshot], tenant: $tenantA))
|
->assertSee(EvidenceSnapshotResource::getUrl('view', ['record' => $allowedSnapshot], tenant: $tenantA, panel: 'tenant'))
|
||||||
->assertDontSee(EvidenceSnapshotResource::getUrl('view', ['record' => $deniedSnapshot], tenant: $tenantDenied));
|
->assertDontSee(EvidenceSnapshotResource::getUrl('view', ['record' => $deniedSnapshot], tenant: $tenantDenied, panel: 'tenant'));
|
||||||
});
|
});
|
||||||
|
|||||||
@ -397,8 +397,8 @@
|
|||||||
->assertSuccessful()
|
->assertSuccessful()
|
||||||
->assertSee('Complete onboarding')
|
->assertSee('Complete onboarding')
|
||||||
->assertDontSee('Activate tenant')
|
->assertDontSee('Activate tenant')
|
||||||
->assertDontSee('Restore')
|
->assertDontSeeText('Restore tenant')
|
||||||
->assertDontSee('Archive')
|
->assertDontSeeText('Archive tenant')
|
||||||
->assertSee('After completion');
|
->assertSee('After completion');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@ -0,0 +1,113 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
use App\Filament\Pages\Operations\TenantlessOperationRunViewer;
|
||||||
|
use App\Models\OperationRun;
|
||||||
|
use App\Notifications\OperationRunCompleted;
|
||||||
|
use App\Support\Providers\ProviderReasonCodes;
|
||||||
|
use Filament\Facades\Filament;
|
||||||
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||||
|
use Livewire\Livewire;
|
||||||
|
|
||||||
|
uses(RefreshDatabase::class);
|
||||||
|
|
||||||
|
function makeProviderBlockedRun(): array
|
||||||
|
{
|
||||||
|
[$user, $tenant] = createUserWithTenant(role: 'owner', ensureDefaultMicrosoftProviderConnection: false);
|
||||||
|
|
||||||
|
$tenant->makeCurrent();
|
||||||
|
Filament::setTenant($tenant, true);
|
||||||
|
|
||||||
|
$run = OperationRun::factory()->create([
|
||||||
|
'workspace_id' => (int) $tenant->workspace_id,
|
||||||
|
'tenant_id' => (int) $tenant->getKey(),
|
||||||
|
'user_id' => (int) $user->getKey(),
|
||||||
|
'initiator_name' => $user->name,
|
||||||
|
'type' => 'inventory_sync',
|
||||||
|
'status' => 'completed',
|
||||||
|
'outcome' => 'blocked',
|
||||||
|
'context' => [
|
||||||
|
'provider' => 'microsoft',
|
||||||
|
'module' => 'inventory',
|
||||||
|
'provider_connection_id' => 999999,
|
||||||
|
'reason_code' => ProviderReasonCodes::ProviderConnectionMissing,
|
||||||
|
'blocked_by' => 'provider_preflight',
|
||||||
|
'target_scope' => [
|
||||||
|
'entra_tenant_id' => $tenant->tenant_id,
|
||||||
|
],
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
|
||||||
|
return [$user, $tenant, $run];
|
||||||
|
}
|
||||||
|
|
||||||
|
it('reuses translated provider-backed blocker language on the canonical run detail page', function (): void {
|
||||||
|
[$user, , $run] = makeProviderBlockedRun();
|
||||||
|
$this->actingAs($user);
|
||||||
|
|
||||||
|
$component = Livewire::actingAs($user)->test(TenantlessOperationRunViewer::class, ['run' => $run]);
|
||||||
|
$banner = $component->instance()->blockedExecutionBanner();
|
||||||
|
|
||||||
|
expect($banner)->not->toBeNull();
|
||||||
|
expect($banner['title'] ?? null)->toBe('Blocked by prerequisite');
|
||||||
|
expect($banner['body'] ?? null)->toContain('Provider connection required');
|
||||||
|
expect($banner['body'] ?? null)->toContain('usable provider connection');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps terminal notification reason translation aligned with the canonical provider-backed run detail', function (): void {
|
||||||
|
[$user, , $run] = makeProviderBlockedRun();
|
||||||
|
$this->actingAs($user);
|
||||||
|
|
||||||
|
$banner = Livewire::actingAs($user)
|
||||||
|
->test(TenantlessOperationRunViewer::class, ['run' => $run])
|
||||||
|
->instance()
|
||||||
|
->blockedExecutionBanner();
|
||||||
|
|
||||||
|
$payload = (new OperationRunCompleted($run))->toDatabase($user);
|
||||||
|
|
||||||
|
expect($payload['title'] ?? null)->toBe('Inventory sync blocked by prerequisite');
|
||||||
|
expect($payload['body'] ?? null)->toContain('Provider connection required');
|
||||||
|
expect($payload['body'] ?? null)->toContain('usable provider connection');
|
||||||
|
expect($payload['reason_translation']['operator_label'] ?? null)->toContain('Provider connection required');
|
||||||
|
expect($payload['reason_translation']['short_explanation'] ?? null)->toContain('usable provider connection');
|
||||||
|
expect($payload['diagnostic_reason_code'] ?? null)->toBe(ProviderReasonCodes::ProviderConnectionMissing);
|
||||||
|
expect($banner['body'] ?? '')->toContain('Provider connection required');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps the same blocked provider-backed vocabulary for system-initiated runs on canonical detail', function (): void {
|
||||||
|
[$user, $tenant] = createUserWithTenant(role: 'owner', ensureDefaultMicrosoftProviderConnection: false);
|
||||||
|
|
||||||
|
$tenant->makeCurrent();
|
||||||
|
Filament::setTenant($tenant, true);
|
||||||
|
|
||||||
|
$run = OperationRun::factory()->create([
|
||||||
|
'workspace_id' => (int) $tenant->workspace_id,
|
||||||
|
'tenant_id' => (int) $tenant->getKey(),
|
||||||
|
'user_id' => null,
|
||||||
|
'initiator_name' => 'Scheduled automation',
|
||||||
|
'type' => 'inventory_sync',
|
||||||
|
'status' => 'completed',
|
||||||
|
'outcome' => 'blocked',
|
||||||
|
'context' => [
|
||||||
|
'provider' => 'microsoft',
|
||||||
|
'module' => 'inventory',
|
||||||
|
'provider_connection_id' => 999999,
|
||||||
|
'reason_code' => ProviderReasonCodes::ProviderConnectionMissing,
|
||||||
|
'blocked_by' => 'provider_preflight',
|
||||||
|
'target_scope' => [
|
||||||
|
'entra_tenant_id' => $tenant->tenant_id,
|
||||||
|
],
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
|
||||||
|
$banner = Livewire::actingAs($user)
|
||||||
|
->test(TenantlessOperationRunViewer::class, ['run' => $run])
|
||||||
|
->instance()
|
||||||
|
->blockedExecutionBanner();
|
||||||
|
|
||||||
|
expect($banner)->not->toBeNull();
|
||||||
|
expect($banner['title'] ?? null)->toBe('Blocked by prerequisite');
|
||||||
|
expect($banner['body'] ?? null)->toContain('Provider connection required');
|
||||||
|
expect($banner['body'] ?? null)->toContain('usable provider connection');
|
||||||
|
});
|
||||||
@ -0,0 +1,90 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use App\Filament\Resources\ProviderConnectionResource\Pages\ListProviderConnections;
|
||||||
|
use App\Jobs\ProviderComplianceSnapshotJob;
|
||||||
|
use App\Jobs\ProviderConnectionHealthCheckJob;
|
||||||
|
use App\Jobs\ProviderInventorySyncJob;
|
||||||
|
use App\Models\OperationRun;
|
||||||
|
use App\Models\ProviderConnection;
|
||||||
|
use App\Support\OperationRunLinks;
|
||||||
|
use App\Support\Providers\ProviderReasonCodes;
|
||||||
|
use Filament\Facades\Filament;
|
||||||
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||||
|
use Illuminate\Support\Facades\Queue;
|
||||||
|
use Livewire\Livewire;
|
||||||
|
|
||||||
|
uses(RefreshDatabase::class);
|
||||||
|
|
||||||
|
it('returns scope-busy semantics when a different provider operation is already active for the same connection', function (): void {
|
||||||
|
Queue::fake();
|
||||||
|
|
||||||
|
[$user, $tenant] = createUserWithTenant(role: 'operator', fixtureProfile: 'credential-enabled');
|
||||||
|
$this->actingAs($user);
|
||||||
|
|
||||||
|
$tenant->makeCurrent();
|
||||||
|
Filament::setTenant($tenant, true);
|
||||||
|
|
||||||
|
$connection = ProviderConnection::factory()->platform()->consentGranted()->create([
|
||||||
|
'tenant_id' => (int) $tenant->getKey(),
|
||||||
|
'provider' => 'microsoft',
|
||||||
|
'entra_tenant_id' => fake()->uuid(),
|
||||||
|
'consent_status' => 'granted',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$component = Livewire::test(ListProviderConnections::class);
|
||||||
|
$component->callTableAction('inventory_sync', $connection);
|
||||||
|
$component->callTableAction('compliance_snapshot', $connection);
|
||||||
|
|
||||||
|
$inventoryRun = OperationRun::query()
|
||||||
|
->where('tenant_id', (int) $tenant->getKey())
|
||||||
|
->where('type', 'inventory_sync')
|
||||||
|
->latest('id')
|
||||||
|
->first();
|
||||||
|
|
||||||
|
expect($inventoryRun)->not->toBeNull();
|
||||||
|
expect(OperationRun::query()
|
||||||
|
->where('tenant_id', (int) $tenant->getKey())
|
||||||
|
->where('type', 'compliance.snapshot')
|
||||||
|
->count())->toBe(0);
|
||||||
|
|
||||||
|
Queue::assertPushed(ProviderInventorySyncJob::class, 1);
|
||||||
|
Queue::assertPushed(ProviderComplianceSnapshotJob::class, 0);
|
||||||
|
|
||||||
|
$notifications = session('filament.notifications', []);
|
||||||
|
expect($notifications)->not->toBeEmpty();
|
||||||
|
expect(collect($notifications)->last()['actions'][0]['url'] ?? null)
|
||||||
|
->toBe(OperationRunLinks::view($inventoryRun, $tenant));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('blocks provider connection checks with shared guidance and does not enqueue work', function (): void {
|
||||||
|
Queue::fake();
|
||||||
|
|
||||||
|
[$user, $tenant] = createUserWithTenant(role: 'operator', ensureDefaultMicrosoftProviderConnection: false);
|
||||||
|
$this->actingAs($user);
|
||||||
|
|
||||||
|
$tenant->makeCurrent();
|
||||||
|
Filament::setTenant($tenant, true);
|
||||||
|
|
||||||
|
$connection = ProviderConnection::factory()->dedicated()->consentGranted()->create([
|
||||||
|
'tenant_id' => (int) $tenant->getKey(),
|
||||||
|
'provider' => 'microsoft',
|
||||||
|
'consent_status' => 'granted',
|
||||||
|
'is_default' => true,
|
||||||
|
]);
|
||||||
|
|
||||||
|
Livewire::test(ListProviderConnections::class)
|
||||||
|
->callTableAction('check_connection', $connection);
|
||||||
|
|
||||||
|
$run = OperationRun::query()
|
||||||
|
->where('tenant_id', (int) $tenant->getKey())
|
||||||
|
->where('type', 'provider.connection.check')
|
||||||
|
->latest('id')
|
||||||
|
->first();
|
||||||
|
|
||||||
|
expect($run)->not->toBeNull();
|
||||||
|
expect($run?->outcome)->toBe('blocked');
|
||||||
|
expect($run?->context['reason_code'] ?? null)->toBe(ProviderReasonCodes::DedicatedCredentialMissing);
|
||||||
|
|
||||||
|
Queue::assertNothingPushed();
|
||||||
|
Queue::assertNotPushed(ProviderConnectionHealthCheckJob::class);
|
||||||
|
});
|
||||||
@ -0,0 +1,163 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use App\Filament\Resources\RestoreRunResource\Pages\CreateRestoreRun;
|
||||||
|
use App\Filament\Resources\RestoreRunResource\Pages\ListRestoreRuns;
|
||||||
|
use App\Jobs\ExecuteRestoreRunJob;
|
||||||
|
use App\Models\BackupItem;
|
||||||
|
use App\Models\BackupSet;
|
||||||
|
use App\Models\OperationRun;
|
||||||
|
use App\Models\Policy;
|
||||||
|
use App\Models\RestoreRun;
|
||||||
|
use App\Models\Tenant;
|
||||||
|
use App\Models\User;
|
||||||
|
use App\Support\Providers\ProviderReasonCodes;
|
||||||
|
use App\Support\RestoreRunStatus;
|
||||||
|
use Filament\Facades\Filament;
|
||||||
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||||
|
use Illuminate\Support\Facades\Bus;
|
||||||
|
use Livewire\Livewire;
|
||||||
|
|
||||||
|
uses(RefreshDatabase::class);
|
||||||
|
|
||||||
|
beforeEach(function () {
|
||||||
|
putenv('INTUNE_TENANT_ID');
|
||||||
|
unset($_ENV['INTUNE_TENANT_ID'], $_SERVER['INTUNE_TENANT_ID']);
|
||||||
|
});
|
||||||
|
|
||||||
|
function seedRestoreStartContext(bool $withProviderConnection = true): array
|
||||||
|
{
|
||||||
|
$tenant = Tenant::create([
|
||||||
|
'tenant_id' => fake()->uuid(),
|
||||||
|
'name' => 'Restore Tenant',
|
||||||
|
'metadata' => [],
|
||||||
|
'rbac_status' => 'ok',
|
||||||
|
'rbac_last_checked_at' => now(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
$tenant->makeCurrent();
|
||||||
|
|
||||||
|
if ($withProviderConnection) {
|
||||||
|
ensureDefaultProviderConnection($tenant, 'microsoft');
|
||||||
|
}
|
||||||
|
|
||||||
|
$policy = Policy::create([
|
||||||
|
'tenant_id' => $tenant->id,
|
||||||
|
'external_id' => fake()->uuid(),
|
||||||
|
'policy_type' => 'deviceConfiguration',
|
||||||
|
'display_name' => 'Device Config Policy',
|
||||||
|
'platform' => 'windows',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$backupSet = BackupSet::create([
|
||||||
|
'tenant_id' => $tenant->id,
|
||||||
|
'name' => 'Backup',
|
||||||
|
'status' => 'completed',
|
||||||
|
'item_count' => 1,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$backupItem = BackupItem::create([
|
||||||
|
'tenant_id' => $tenant->id,
|
||||||
|
'backup_set_id' => $backupSet->id,
|
||||||
|
'policy_id' => $policy->id,
|
||||||
|
'policy_identifier' => $policy->external_id,
|
||||||
|
'policy_type' => $policy->policy_type,
|
||||||
|
'platform' => $policy->platform,
|
||||||
|
'payload' => ['id' => $policy->external_id],
|
||||||
|
'metadata' => [
|
||||||
|
'displayName' => 'Backup Policy',
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
|
||||||
|
$user = User::factory()->create([
|
||||||
|
'email' => 'restore@example.com',
|
||||||
|
'name' => 'Restore Operator',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$user->tenants()->syncWithoutDetaching([
|
||||||
|
$tenant->getKey() => ['role' => 'owner'],
|
||||||
|
]);
|
||||||
|
|
||||||
|
Filament::setTenant($tenant, true);
|
||||||
|
|
||||||
|
return [$tenant, $backupSet, $backupItem, $user];
|
||||||
|
}
|
||||||
|
|
||||||
|
it('starts restore execution with explicit provider connection context', function (): void {
|
||||||
|
Bus::fake();
|
||||||
|
|
||||||
|
[$tenant, $backupSet, $backupItem, $user] = seedRestoreStartContext();
|
||||||
|
$this->actingAs($user);
|
||||||
|
|
||||||
|
Livewire::test(CreateRestoreRun::class)
|
||||||
|
->fillForm([
|
||||||
|
'backup_set_id' => $backupSet->id,
|
||||||
|
])
|
||||||
|
->goToNextWizardStep()
|
||||||
|
->fillForm([
|
||||||
|
'scope_mode' => 'selected',
|
||||||
|
'backup_item_ids' => [$backupItem->id],
|
||||||
|
])
|
||||||
|
->goToNextWizardStep()
|
||||||
|
->callFormComponentAction('check_results', 'run_restore_checks')
|
||||||
|
->goToNextWizardStep()
|
||||||
|
->callFormComponentAction('preview_diffs', 'run_restore_preview')
|
||||||
|
->goToNextWizardStep()
|
||||||
|
->fillForm([
|
||||||
|
'is_dry_run' => false,
|
||||||
|
'acknowledged_impact' => true,
|
||||||
|
'tenant_confirm' => 'Restore Tenant',
|
||||||
|
])
|
||||||
|
->call('create')
|
||||||
|
->assertHasNoFormErrors();
|
||||||
|
|
||||||
|
$restoreRun = RestoreRun::query()->latest('id')->first();
|
||||||
|
$operationRun = OperationRun::query()
|
||||||
|
->where('tenant_id', (int) $tenant->getKey())
|
||||||
|
->where('type', 'restore.execute')
|
||||||
|
->latest('id')
|
||||||
|
->first();
|
||||||
|
|
||||||
|
expect($restoreRun)->not->toBeNull();
|
||||||
|
expect($restoreRun?->status)->toBe(RestoreRunStatus::Queued->value);
|
||||||
|
expect($operationRun)->not->toBeNull();
|
||||||
|
expect($operationRun?->context['provider_connection_id'] ?? null)->toBeInt();
|
||||||
|
|
||||||
|
Bus::assertDispatched(ExecuteRestoreRunJob::class, function (ExecuteRestoreRunJob $job) use ($restoreRun, $operationRun): bool {
|
||||||
|
return $job->restoreRunId === (int) $restoreRun?->getKey()
|
||||||
|
&& $job->providerConnectionId === ($operationRun?->context['provider_connection_id'] ?? null)
|
||||||
|
&& $job->operationRun?->is($operationRun);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('blocks restore reruns before queue when no provider connection is available', function (): void {
|
||||||
|
Bus::fake();
|
||||||
|
|
||||||
|
[$tenant, $backupSet, $backupItem, $user] = seedRestoreStartContext(withProviderConnection: false);
|
||||||
|
$this->actingAs($user);
|
||||||
|
|
||||||
|
$run = RestoreRun::create([
|
||||||
|
'tenant_id' => $tenant->id,
|
||||||
|
'backup_set_id' => $backupSet->id,
|
||||||
|
'status' => 'failed',
|
||||||
|
'is_dry_run' => false,
|
||||||
|
'requested_items' => [$backupItem->id],
|
||||||
|
'group_mapping' => [],
|
||||||
|
]);
|
||||||
|
|
||||||
|
Livewire::test(ListRestoreRuns::class)
|
||||||
|
->callTableAction('rerun', $run);
|
||||||
|
|
||||||
|
expect(RestoreRun::query()->where('tenant_id', (int) $tenant->getKey())->count())->toBe(1);
|
||||||
|
|
||||||
|
$operationRun = OperationRun::query()
|
||||||
|
->where('tenant_id', (int) $tenant->getKey())
|
||||||
|
->where('type', 'restore.execute')
|
||||||
|
->latest('id')
|
||||||
|
->first();
|
||||||
|
|
||||||
|
expect($operationRun)->not->toBeNull();
|
||||||
|
expect($operationRun?->outcome)->toBe('blocked');
|
||||||
|
expect($operationRun?->context['reason_code'] ?? null)->toBe(ProviderReasonCodes::ProviderConnectionMissing);
|
||||||
|
|
||||||
|
Bus::assertNotDispatched(ExecuteRestoreRunJob::class);
|
||||||
|
});
|
||||||
@ -189,10 +189,12 @@
|
|||||||
expect($operationRun)->not->toBeNull();
|
expect($operationRun)->not->toBeNull();
|
||||||
expect($operationRun?->status)->toBe('queued');
|
expect($operationRun?->status)->toBe('queued');
|
||||||
expect((int) ($operationRun?->context['restore_run_id'] ?? 0))->toBe((int) $run->getKey());
|
expect((int) ($operationRun?->context['restore_run_id'] ?? 0))->toBe((int) $run->getKey());
|
||||||
|
expect($operationRun?->context['provider_connection_id'] ?? null)->toBeInt();
|
||||||
expect((int) ($run->refresh()->operation_run_id ?? 0))->toBe((int) ($operationRun?->getKey() ?? 0));
|
expect((int) ($run->refresh()->operation_run_id ?? 0))->toBe((int) ($operationRun?->getKey() ?? 0));
|
||||||
|
|
||||||
Bus::assertDispatched(ExecuteRestoreRunJob::class, function (ExecuteRestoreRunJob $job) use ($run, $operationRun): bool {
|
Bus::assertDispatched(ExecuteRestoreRunJob::class, function (ExecuteRestoreRunJob $job) use ($run, $operationRun): bool {
|
||||||
return $job->restoreRunId === (int) $run->getKey()
|
return $job->restoreRunId === (int) $run->getKey()
|
||||||
|
&& $job->providerConnectionId === ($operationRun?->context['provider_connection_id'] ?? null)
|
||||||
&& $job->operationRun instanceof OperationRun
|
&& $job->operationRun instanceof OperationRun
|
||||||
&& $job->operationRun->getKey() === $operationRun?->getKey();
|
&& $job->operationRun->getKey() === $operationRun?->getKey();
|
||||||
});
|
});
|
||||||
|
|||||||
@ -3,6 +3,7 @@
|
|||||||
declare(strict_types=1);
|
declare(strict_types=1);
|
||||||
|
|
||||||
use App\Models\Tenant;
|
use App\Models\Tenant;
|
||||||
|
use App\Services\Providers\ProviderOperationStartResult;
|
||||||
use App\Services\Directory\RoleDefinitionsSyncService;
|
use App\Services\Directory\RoleDefinitionsSyncService;
|
||||||
use App\Support\OperationRunLinks;
|
use App\Support\OperationRunLinks;
|
||||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||||
@ -20,19 +21,31 @@
|
|||||||
'status' => 'active',
|
'status' => 'active',
|
||||||
]);
|
]);
|
||||||
|
|
||||||
[$user, $tenant] = createUserWithTenant($tenant, role: 'owner');
|
[$user, $tenant] = createUserWithTenant(
|
||||||
|
tenant: $tenant,
|
||||||
|
role: 'owner',
|
||||||
|
fixtureProfile: 'credential-enabled',
|
||||||
|
);
|
||||||
|
|
||||||
$service = app(RoleDefinitionsSyncService::class);
|
$service = app(RoleDefinitionsSyncService::class);
|
||||||
|
|
||||||
$run = $service->startManualSync($tenant, $user);
|
$result = $service->startManualSync($tenant, $user);
|
||||||
|
|
||||||
|
expect($result)->toBeInstanceOf(ProviderOperationStartResult::class);
|
||||||
|
expect($result->status)->toBe('started');
|
||||||
|
|
||||||
|
$run = $result->run;
|
||||||
|
|
||||||
expect($run->type)->toBe('directory_role_definitions.sync');
|
expect($run->type)->toBe('directory_role_definitions.sync');
|
||||||
|
expect($run->context['provider_connection_id'] ?? null)->toBeInt();
|
||||||
|
|
||||||
$url = OperationRunLinks::tenantlessView($run);
|
$url = OperationRunLinks::tenantlessView($run);
|
||||||
expect($url)->toContain('/admin/operations/');
|
expect($url)->toContain('/admin/operations/');
|
||||||
|
|
||||||
Bus::assertDispatched(
|
Bus::assertDispatched(
|
||||||
App\Jobs\SyncRoleDefinitionsJob::class,
|
App\Jobs\SyncRoleDefinitionsJob::class,
|
||||||
fn (App\Jobs\SyncRoleDefinitionsJob $job): bool => $job->tenantId === (int) $tenant->getKey() && $job->operationRun?->is($run)
|
fn (App\Jobs\SyncRoleDefinitionsJob $job): bool => $job->tenantId === (int) $tenant->getKey()
|
||||||
|
&& $job->providerConnectionId === ($run->context['provider_connection_id'] ?? null)
|
||||||
|
&& $job->operationRun?->is($run)
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|||||||
@ -0,0 +1,83 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use App\Filament\Resources\TenantResource\Pages\ViewTenant;
|
||||||
|
use App\Jobs\ProviderConnectionHealthCheckJob;
|
||||||
|
use App\Models\OperationRun;
|
||||||
|
use App\Models\ProviderConnection;
|
||||||
|
use App\Support\Providers\ProviderReasonCodes;
|
||||||
|
use Filament\Facades\Filament;
|
||||||
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||||
|
use Illuminate\Support\Facades\Queue;
|
||||||
|
use Livewire\Livewire;
|
||||||
|
|
||||||
|
uses(RefreshDatabase::class);
|
||||||
|
|
||||||
|
it('starts tenant verification with explicit connection context and dedupes repeat starts', function (): void {
|
||||||
|
Queue::fake();
|
||||||
|
|
||||||
|
[$user, $tenant] = createUserWithTenant(role: 'operator', fixtureProfile: 'credential-enabled');
|
||||||
|
$this->actingAs($user);
|
||||||
|
|
||||||
|
$tenant->makeCurrent();
|
||||||
|
Filament::setTenant($tenant, true);
|
||||||
|
|
||||||
|
$connection = ProviderConnection::query()
|
||||||
|
->where('tenant_id', (int) $tenant->getKey())
|
||||||
|
->where('provider', 'microsoft')
|
||||||
|
->where('is_default', true)
|
||||||
|
->firstOrFail();
|
||||||
|
|
||||||
|
$component = Livewire::test(ViewTenant::class, ['record' => $tenant->getRouteKey()]);
|
||||||
|
|
||||||
|
$component->callAction('verify');
|
||||||
|
$component->callAction('verify');
|
||||||
|
|
||||||
|
$run = OperationRun::query()
|
||||||
|
->where('tenant_id', (int) $tenant->getKey())
|
||||||
|
->where('type', 'provider.connection.check')
|
||||||
|
->latest('id')
|
||||||
|
->first();
|
||||||
|
|
||||||
|
expect($run)->not->toBeNull();
|
||||||
|
expect($run?->status)->toBe('queued');
|
||||||
|
expect($run?->context)->toMatchArray([
|
||||||
|
'provider' => 'microsoft',
|
||||||
|
'module' => 'health_check',
|
||||||
|
'provider_connection_id' => (int) $connection->getKey(),
|
||||||
|
'target_scope' => [
|
||||||
|
'entra_tenant_id' => $connection->entra_tenant_id,
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
|
||||||
|
expect(OperationRun::query()
|
||||||
|
->where('tenant_id', (int) $tenant->getKey())
|
||||||
|
->where('type', 'provider.connection.check')
|
||||||
|
->count())->toBe(1);
|
||||||
|
|
||||||
|
Queue::assertPushed(ProviderConnectionHealthCheckJob::class, 1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('blocks tenant verification before queue when no provider connection is available', function (): void {
|
||||||
|
Queue::fake();
|
||||||
|
|
||||||
|
[$user, $tenant] = createUserWithTenant(role: 'operator', ensureDefaultMicrosoftProviderConnection: false);
|
||||||
|
$this->actingAs($user);
|
||||||
|
|
||||||
|
$tenant->makeCurrent();
|
||||||
|
Filament::setTenant($tenant, true);
|
||||||
|
|
||||||
|
Livewire::test(ViewTenant::class, ['record' => $tenant->getRouteKey()])
|
||||||
|
->callAction('verify');
|
||||||
|
|
||||||
|
$run = OperationRun::query()
|
||||||
|
->where('tenant_id', (int) $tenant->getKey())
|
||||||
|
->where('type', 'provider.connection.check')
|
||||||
|
->latest('id')
|
||||||
|
->first();
|
||||||
|
|
||||||
|
expect($run)->not->toBeNull();
|
||||||
|
expect($run?->outcome)->toBe('blocked');
|
||||||
|
expect($run?->context['reason_code'] ?? null)->toBe(ProviderReasonCodes::ProviderConnectionMissing);
|
||||||
|
|
||||||
|
Queue::assertNothingPushed();
|
||||||
|
});
|
||||||
@ -0,0 +1,167 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use App\Filament\Pages\Workspaces\ManagedTenantOnboardingWizard;
|
||||||
|
use App\Jobs\ProviderComplianceSnapshotJob;
|
||||||
|
use App\Jobs\ProviderConnectionHealthCheckJob;
|
||||||
|
use App\Jobs\ProviderInventorySyncJob;
|
||||||
|
use App\Models\OperationRun;
|
||||||
|
use App\Models\ProviderConnection;
|
||||||
|
use App\Models\Tenant;
|
||||||
|
use App\Models\TenantOnboardingSession;
|
||||||
|
use App\Models\User;
|
||||||
|
use App\Models\Workspace;
|
||||||
|
use App\Models\WorkspaceMembership;
|
||||||
|
use App\Support\Workspaces\WorkspaceContext;
|
||||||
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||||
|
use Illuminate\Support\Facades\Bus;
|
||||||
|
use Livewire\Livewire;
|
||||||
|
|
||||||
|
uses(RefreshDatabase::class);
|
||||||
|
|
||||||
|
it('returns scope-busy semantics for onboarding verification when another run is active for the same connection scope', function (): void {
|
||||||
|
Bus::fake();
|
||||||
|
|
||||||
|
$workspace = Workspace::factory()->create();
|
||||||
|
$user = User::factory()->create();
|
||||||
|
|
||||||
|
WorkspaceMembership::factory()->create([
|
||||||
|
'workspace_id' => $workspace->getKey(),
|
||||||
|
'user_id' => $user->getKey(),
|
||||||
|
'role' => 'owner',
|
||||||
|
]);
|
||||||
|
|
||||||
|
session()->put(WorkspaceContext::SESSION_KEY, (int) $workspace->getKey());
|
||||||
|
$this->actingAs($user);
|
||||||
|
|
||||||
|
$tenantGuid = '10101010-1010-1010-1010-101010101010';
|
||||||
|
|
||||||
|
$component = Livewire::actingAs($user)->test(ManagedTenantOnboardingWizard::class);
|
||||||
|
$component->call('identifyManagedTenant', [
|
||||||
|
'entra_tenant_id' => $tenantGuid,
|
||||||
|
'environment' => 'prod',
|
||||||
|
'name' => 'Acme',
|
||||||
|
'primary_domain' => 'acme.example',
|
||||||
|
'notes' => 'Provider start test',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$tenant = Tenant::query()->where('tenant_id', $tenantGuid)->firstOrFail();
|
||||||
|
|
||||||
|
$connection = ProviderConnection::factory()->platform()->consentGranted()->create([
|
||||||
|
'tenant_id' => (int) $tenant->getKey(),
|
||||||
|
'provider' => 'microsoft',
|
||||||
|
'entra_tenant_id' => $tenantGuid,
|
||||||
|
'is_default' => true,
|
||||||
|
]);
|
||||||
|
|
||||||
|
OperationRun::query()->create([
|
||||||
|
'tenant_id' => (int) $tenant->getKey(),
|
||||||
|
'user_id' => (int) $user->getKey(),
|
||||||
|
'initiator_name' => $user->name,
|
||||||
|
'type' => 'inventory_sync',
|
||||||
|
'status' => 'queued',
|
||||||
|
'outcome' => 'pending',
|
||||||
|
'run_identity_hash' => sha1('busy-onboarding-'.(string) $connection->getKey()),
|
||||||
|
'context' => [
|
||||||
|
'provider_connection_id' => (int) $connection->getKey(),
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
|
||||||
|
$component->set('selectedProviderConnectionId', (int) $connection->getKey());
|
||||||
|
$component->call('startVerification');
|
||||||
|
|
||||||
|
expect(OperationRun::query()
|
||||||
|
->where('tenant_id', (int) $tenant->getKey())
|
||||||
|
->where('type', 'provider.connection.check')
|
||||||
|
->count())->toBe(0);
|
||||||
|
|
||||||
|
Bus::assertNotDispatched(ProviderConnectionHealthCheckJob::class);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('serializes onboarding bootstrap so only one selected provider-backed action starts at a time', function (): void {
|
||||||
|
Bus::fake();
|
||||||
|
|
||||||
|
$workspace = Workspace::factory()->create();
|
||||||
|
$user = User::factory()->create();
|
||||||
|
|
||||||
|
WorkspaceMembership::factory()->create([
|
||||||
|
'workspace_id' => $workspace->getKey(),
|
||||||
|
'user_id' => $user->getKey(),
|
||||||
|
'role' => 'owner',
|
||||||
|
]);
|
||||||
|
|
||||||
|
session()->put(WorkspaceContext::SESSION_KEY, (int) $workspace->getKey());
|
||||||
|
$this->actingAs($user);
|
||||||
|
|
||||||
|
$tenantGuid = '20202020-2020-2020-2020-202020202020';
|
||||||
|
|
||||||
|
$component = Livewire::actingAs($user)->test(ManagedTenantOnboardingWizard::class);
|
||||||
|
$component->call('identifyManagedTenant', [
|
||||||
|
'entra_tenant_id' => $tenantGuid,
|
||||||
|
'environment' => 'prod',
|
||||||
|
'name' => 'Acme',
|
||||||
|
'primary_domain' => 'acme.example',
|
||||||
|
'notes' => 'Provider start test',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$tenant = Tenant::query()->where('tenant_id', $tenantGuid)->firstOrFail();
|
||||||
|
|
||||||
|
$connection = ProviderConnection::factory()->platform()->consentGranted()->create([
|
||||||
|
'tenant_id' => (int) $tenant->getKey(),
|
||||||
|
'provider' => 'microsoft',
|
||||||
|
'entra_tenant_id' => $tenantGuid,
|
||||||
|
'is_default' => true,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$verificationRun = OperationRun::query()->create([
|
||||||
|
'tenant_id' => (int) $tenant->getKey(),
|
||||||
|
'user_id' => (int) $user->getKey(),
|
||||||
|
'initiator_name' => $user->name,
|
||||||
|
'type' => 'provider.connection.check',
|
||||||
|
'status' => 'completed',
|
||||||
|
'outcome' => 'succeeded',
|
||||||
|
'run_identity_hash' => sha1('verify-ok-bootstrap-provider-start-'.(string) $connection->getKey()),
|
||||||
|
'context' => [
|
||||||
|
'provider_connection_id' => (int) $connection->getKey(),
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
|
||||||
|
$session = TenantOnboardingSession::query()
|
||||||
|
->where('workspace_id', (int) $workspace->getKey())
|
||||||
|
->where('tenant_id', (int) $tenant->getKey())
|
||||||
|
->firstOrFail();
|
||||||
|
|
||||||
|
$session->update([
|
||||||
|
'state' => array_merge($session->state ?? [], [
|
||||||
|
'provider_connection_id' => (int) $connection->getKey(),
|
||||||
|
'verification_operation_run_id' => (int) $verificationRun->getKey(),
|
||||||
|
]),
|
||||||
|
]);
|
||||||
|
|
||||||
|
$component->call('startBootstrap', ['inventory_sync', 'compliance.snapshot']);
|
||||||
|
|
||||||
|
$inventoryRun = OperationRun::query()
|
||||||
|
->where('tenant_id', (int) $tenant->getKey())
|
||||||
|
->where('type', 'inventory_sync')
|
||||||
|
->latest('id')
|
||||||
|
->firstOrFail();
|
||||||
|
|
||||||
|
expect(OperationRun::query()
|
||||||
|
->where('tenant_id', (int) $tenant->getKey())
|
||||||
|
->where('type', 'compliance.snapshot')
|
||||||
|
->count())->toBe(0);
|
||||||
|
|
||||||
|
$inventoryRun->forceFill([
|
||||||
|
'status' => 'completed',
|
||||||
|
'outcome' => 'succeeded',
|
||||||
|
])->save();
|
||||||
|
|
||||||
|
$component->call('startBootstrap', ['inventory_sync', 'compliance.snapshot']);
|
||||||
|
|
||||||
|
expect(OperationRun::query()
|
||||||
|
->where('tenant_id', (int) $tenant->getKey())
|
||||||
|
->where('type', 'compliance.snapshot')
|
||||||
|
->count())->toBe(1);
|
||||||
|
|
||||||
|
Bus::assertDispatchedTimes(ProviderInventorySyncJob::class, 1);
|
||||||
|
Bus::assertDispatchedTimes(ProviderComplianceSnapshotJob::class, 1);
|
||||||
|
});
|
||||||
@ -71,7 +71,7 @@
|
|||||||
->assertDontSee('/admin/t/'.$tenantInOther->external_id, false);
|
->assertDontSee('/admin/t/'.$tenantInOther->external_id, false);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('returns 404 on tenant routes when workspace context is missing', function (): void {
|
it('uses the routed tenant workspace on tenant routes when workspace context is missing', function (): void {
|
||||||
$user = User::factory()->create();
|
$user = User::factory()->create();
|
||||||
|
|
||||||
$workspace = Workspace::factory()->create();
|
$workspace = Workspace::factory()->create();
|
||||||
@ -93,7 +93,7 @@
|
|||||||
|
|
||||||
$this->actingAs($user)
|
$this->actingAs($user)
|
||||||
->get(TenantDashboard::getUrl(tenant: $tenant))
|
->get(TenantDashboard::getUrl(tenant: $tenant))
|
||||||
->assertNotFound();
|
->assertSuccessful();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('returns 404 on tenant routes when tenant workspace mismatches current workspace', function (): void {
|
it('returns 404 on tenant routes when tenant workspace mismatches current workspace', function (): void {
|
||||||
|
|||||||
@ -535,6 +535,34 @@ public static function families(): array
|
|||||||
'costSignals' => ['resource discovery', 'surface-wide validation', 'broad assertion density'],
|
'costSignals' => ['resource discovery', 'surface-wide validation', 'broad assertion density'],
|
||||||
'validationStatus' => 'guarded',
|
'validationStatus' => 'guarded',
|
||||||
],
|
],
|
||||||
|
[
|
||||||
|
'familyId' => 'provider-dispatch-gate-coverage',
|
||||||
|
'classificationId' => 'surface-guard',
|
||||||
|
'purpose' => 'Keep the first-slice provider-backed start hosts on canonical ProviderOperationStartGate-owned entry points instead of route-bounded direct-dispatch bypasses.',
|
||||||
|
'currentLaneId' => 'heavy-governance',
|
||||||
|
'targetLaneId' => 'heavy-governance',
|
||||||
|
'selectors' => [
|
||||||
|
[
|
||||||
|
'selectorType' => 'group',
|
||||||
|
'selectorValue' => 'surface-guard',
|
||||||
|
'selectorRole' => 'include',
|
||||||
|
'sourceOfTruth' => 'pest-group',
|
||||||
|
'rationale' => 'The bypass guard spans multiple route-bounded start surfaces and belongs with heavy governance checks.',
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'selectorType' => 'file',
|
||||||
|
'selectorValue' => 'tests/Feature/Guards/ProviderDispatchGateCoverageTest.php',
|
||||||
|
'selectorRole' => 'inventory-only',
|
||||||
|
'sourceOfTruth' => 'manifest',
|
||||||
|
'rationale' => 'Canonical guard for first-slice provider-backed dispatch-gate coverage.',
|
||||||
|
],
|
||||||
|
],
|
||||||
|
'hotspotFiles' => [
|
||||||
|
'tests/Feature/Guards/ProviderDispatchGateCoverageTest.php',
|
||||||
|
],
|
||||||
|
'costSignals' => ['route-bounded surface scan', 'start-host governance breadth', 'gate adoption regression detection'],
|
||||||
|
'validationStatus' => 'guarded',
|
||||||
|
],
|
||||||
[
|
[
|
||||||
'familyId' => 'policy-resource-admin-search-parity',
|
'familyId' => 'policy-resource-admin-search-parity',
|
||||||
'classificationId' => 'discovery-heavy',
|
'classificationId' => 'discovery-heavy',
|
||||||
@ -1175,6 +1203,16 @@ public static function budgetTargets(): array
|
|||||||
'lifecycleState' => 'documented',
|
'lifecycleState' => 'documented',
|
||||||
'reviewCadence' => 'tighten after two stable heavy-governance runs',
|
'reviewCadence' => 'tighten after two stable heavy-governance runs',
|
||||||
],
|
],
|
||||||
|
[
|
||||||
|
'budgetId' => 'family-provider-dispatch-gate-coverage',
|
||||||
|
'targetType' => 'family',
|
||||||
|
'targetId' => 'provider-dispatch-gate-coverage',
|
||||||
|
'thresholdSeconds' => 20,
|
||||||
|
'baselineSource' => 'measured-current-suite',
|
||||||
|
'enforcement' => 'warn',
|
||||||
|
'lifecycleState' => 'documented',
|
||||||
|
'reviewCadence' => 'tighten after two stable heavy-governance runs',
|
||||||
|
],
|
||||||
[
|
[
|
||||||
'budgetId' => 'family-policy-resource-admin-search-parity',
|
'budgetId' => 'family-policy-resource-admin-search-parity',
|
||||||
'targetType' => 'family',
|
'targetType' => 'family',
|
||||||
|
|||||||
@ -5,6 +5,7 @@
|
|||||||
use App\Models\ProviderCredential;
|
use App\Models\ProviderCredential;
|
||||||
use App\Models\Tenant;
|
use App\Models\Tenant;
|
||||||
use App\Services\Providers\ProviderOperationStartGate;
|
use App\Services\Providers\ProviderOperationStartGate;
|
||||||
|
use App\Support\Auth\Capabilities;
|
||||||
use App\Support\OperationRunOutcome;
|
use App\Support\OperationRunOutcome;
|
||||||
use App\Support\OperationRunStatus;
|
use App\Support\OperationRunStatus;
|
||||||
use App\Support\Providers\ProviderReasonCodes;
|
use App\Support\Providers\ProviderReasonCodes;
|
||||||
@ -157,3 +158,123 @@
|
|||||||
expect($result->run->context['verification_report'] ?? null)->toBeArray();
|
expect($result->run->context['verification_report'] ?? null)->toBeArray();
|
||||||
expect(VerificationReportSchema::isValidReport($result->run->context['verification_report'] ?? []))->toBeTrue();
|
expect(VerificationReportSchema::isValidReport($result->run->context['verification_report'] ?? []))->toBeTrue();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('starts restore execution with explicit provider connection binding and operation capability metadata', function (): void {
|
||||||
|
$tenant = Tenant::factory()->create();
|
||||||
|
$connection = ProviderConnection::factory()->dedicated()->consentGranted()->create([
|
||||||
|
'tenant_id' => $tenant->getKey(),
|
||||||
|
'provider' => 'microsoft',
|
||||||
|
'entra_tenant_id' => 'restore-entra-tenant-id',
|
||||||
|
'consent_status' => 'granted',
|
||||||
|
]);
|
||||||
|
ProviderCredential::factory()->create([
|
||||||
|
'provider_connection_id' => (int) $connection->getKey(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
$dispatched = 0;
|
||||||
|
$gate = app(ProviderOperationStartGate::class);
|
||||||
|
|
||||||
|
$result = $gate->start(
|
||||||
|
tenant: $tenant,
|
||||||
|
connection: $connection,
|
||||||
|
operationType: 'restore.execute',
|
||||||
|
dispatcher: function (OperationRun $run) use (&$dispatched): void {
|
||||||
|
$dispatched++;
|
||||||
|
|
||||||
|
expect($run->type)->toBe('restore.execute');
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
expect($dispatched)->toBe(1);
|
||||||
|
expect($result->status)->toBe('started');
|
||||||
|
expect($result->dispatched)->toBeTrue();
|
||||||
|
|
||||||
|
$run = $result->run->fresh();
|
||||||
|
|
||||||
|
expect($run)->not->toBeNull();
|
||||||
|
expect($run->context)->toMatchArray([
|
||||||
|
'provider_connection_id' => (int) $connection->getKey(),
|
||||||
|
'required_capability' => Capabilities::TENANT_MANAGE,
|
||||||
|
'target_scope' => [
|
||||||
|
'entra_tenant_id' => 'restore-entra-tenant-id',
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('starts directory group sync with explicit provider connection binding and sync capability metadata', function (): void {
|
||||||
|
$tenant = Tenant::factory()->create();
|
||||||
|
$connection = ProviderConnection::factory()->dedicated()->consentGranted()->create([
|
||||||
|
'tenant_id' => $tenant->getKey(),
|
||||||
|
'provider' => 'microsoft',
|
||||||
|
'entra_tenant_id' => 'directory-entra-tenant-id',
|
||||||
|
'consent_status' => 'granted',
|
||||||
|
]);
|
||||||
|
ProviderCredential::factory()->create([
|
||||||
|
'provider_connection_id' => (int) $connection->getKey(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
$dispatched = 0;
|
||||||
|
$gate = app(ProviderOperationStartGate::class);
|
||||||
|
|
||||||
|
$result = $gate->start(
|
||||||
|
tenant: $tenant,
|
||||||
|
connection: $connection,
|
||||||
|
operationType: 'entra_group_sync',
|
||||||
|
dispatcher: function (OperationRun $run) use (&$dispatched): void {
|
||||||
|
$dispatched++;
|
||||||
|
|
||||||
|
expect($run->type)->toBe('entra_group_sync');
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
expect($dispatched)->toBe(1);
|
||||||
|
expect($result->status)->toBe('started');
|
||||||
|
expect($result->dispatched)->toBeTrue();
|
||||||
|
|
||||||
|
$run = $result->run->fresh();
|
||||||
|
|
||||||
|
expect($run)->not->toBeNull();
|
||||||
|
expect($run->context)->toMatchArray([
|
||||||
|
'provider_connection_id' => (int) $connection->getKey(),
|
||||||
|
'required_capability' => Capabilities::TENANT_SYNC,
|
||||||
|
'target_scope' => [
|
||||||
|
'entra_tenant_id' => 'directory-entra-tenant-id',
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('treats onboarding bootstrap provider starts as one protected scope', function (): void {
|
||||||
|
$tenant = Tenant::factory()->create();
|
||||||
|
$connection = ProviderConnection::factory()->dedicated()->consentGranted()->create([
|
||||||
|
'tenant_id' => $tenant->getKey(),
|
||||||
|
'consent_status' => 'granted',
|
||||||
|
]);
|
||||||
|
ProviderCredential::factory()->create([
|
||||||
|
'provider_connection_id' => (int) $connection->getKey(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
$blocking = OperationRun::factory()->create([
|
||||||
|
'tenant_id' => $tenant->getKey(),
|
||||||
|
'type' => 'inventory_sync',
|
||||||
|
'status' => 'running',
|
||||||
|
'context' => [
|
||||||
|
'provider_connection_id' => (int) $connection->getKey(),
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
|
||||||
|
$dispatched = 0;
|
||||||
|
$gate = app(ProviderOperationStartGate::class);
|
||||||
|
|
||||||
|
$result = $gate->start(
|
||||||
|
tenant: $tenant,
|
||||||
|
connection: $connection,
|
||||||
|
operationType: 'compliance.snapshot',
|
||||||
|
dispatcher: function () use (&$dispatched): void {
|
||||||
|
$dispatched++;
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
expect($dispatched)->toBe(0);
|
||||||
|
expect($result->status)->toBe('scope_busy');
|
||||||
|
expect($result->run->getKey())->toBe($blocking->getKey());
|
||||||
|
});
|
||||||
|
|||||||
@ -0,0 +1,149 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use App\Models\OperationRun;
|
||||||
|
use App\Models\Tenant;
|
||||||
|
use App\Services\Providers\ProviderOperationStartResult;
|
||||||
|
use App\Support\OperationRunLinks;
|
||||||
|
use App\Support\OpsUx\ProviderOperationStartResultPresenter;
|
||||||
|
use App\Support\ReasonTranslation\NextStepOption;
|
||||||
|
use App\Support\ReasonTranslation\ReasonResolutionEnvelope;
|
||||||
|
use Filament\Actions\Action;
|
||||||
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||||
|
|
||||||
|
uses(RefreshDatabase::class);
|
||||||
|
|
||||||
|
it('builds queued notifications for accepted provider-backed starts', function (): void {
|
||||||
|
$tenant = Tenant::factory()->create();
|
||||||
|
$run = OperationRun::factory()->create([
|
||||||
|
'tenant_id' => $tenant->getKey(),
|
||||||
|
'type' => 'provider.connection.check',
|
||||||
|
'status' => 'queued',
|
||||||
|
'context' => [
|
||||||
|
'provider_connection_id' => 123,
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
|
||||||
|
$presenter = app(ProviderOperationStartResultPresenter::class);
|
||||||
|
|
||||||
|
$notification = $presenter->notification(
|
||||||
|
result: ProviderOperationStartResult::started($run, true),
|
||||||
|
blockedTitle: 'Verification blocked',
|
||||||
|
runUrl: OperationRunLinks::tenantlessView($run),
|
||||||
|
extraActions: [
|
||||||
|
Action::make('manage_connections')
|
||||||
|
->label('Manage Provider Connections')
|
||||||
|
->url('/provider-connections'),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
|
$actions = collect($notification->getActions());
|
||||||
|
|
||||||
|
expect($notification->getTitle())->toBe('Provider connection check queued')
|
||||||
|
->and($notification->getBody())->toBe('Queued for execution. Open the operation for progress and next steps.')
|
||||||
|
->and($actions->map(fn (Action $action): string => (string) $action->getName())->all())->toBe([
|
||||||
|
'view_run',
|
||||||
|
'manage_connections',
|
||||||
|
])
|
||||||
|
->and($actions->map(fn (Action $action): string => (string) $action->getLabel())->all())->toBe([
|
||||||
|
'Open operation',
|
||||||
|
'Manage Provider Connections',
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('builds already-running notifications for deduped provider-backed starts', function (): void {
|
||||||
|
$tenant = Tenant::factory()->create();
|
||||||
|
$run = OperationRun::factory()->create([
|
||||||
|
'tenant_id' => $tenant->getKey(),
|
||||||
|
'type' => 'provider.connection.check',
|
||||||
|
'status' => 'running',
|
||||||
|
'context' => [
|
||||||
|
'provider_connection_id' => 123,
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
|
||||||
|
$presenter = app(ProviderOperationStartResultPresenter::class);
|
||||||
|
|
||||||
|
$notification = $presenter->notification(
|
||||||
|
result: ProviderOperationStartResult::deduped($run),
|
||||||
|
blockedTitle: 'Verification blocked',
|
||||||
|
runUrl: OperationRunLinks::tenantlessView($run),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect($notification->getTitle())->toBe('Provider connection check already running')
|
||||||
|
->and($notification->getBody())->toBe('A matching operation is already queued or running. Open the operation for progress and next steps.');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('builds scope-busy notifications for conflicting provider-backed starts', function (): void {
|
||||||
|
$tenant = Tenant::factory()->create();
|
||||||
|
$run = OperationRun::factory()->create([
|
||||||
|
'tenant_id' => $tenant->getKey(),
|
||||||
|
'type' => 'inventory_sync',
|
||||||
|
'status' => 'running',
|
||||||
|
'context' => [
|
||||||
|
'provider_connection_id' => 123,
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
|
||||||
|
$presenter = app(ProviderOperationStartResultPresenter::class);
|
||||||
|
|
||||||
|
$notification = $presenter->notification(
|
||||||
|
result: ProviderOperationStartResult::scopeBusy($run),
|
||||||
|
blockedTitle: 'Inventory sync blocked',
|
||||||
|
runUrl: OperationRunLinks::tenantlessView($run),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect($notification->getTitle())->toBe('Scope busy')
|
||||||
|
->and($notification->getBody())->toBe('Another provider-backed operation is already running for this scope. Open the active operation for progress and next steps.');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('builds blocked notifications from translated reason detail and first next step', function (): void {
|
||||||
|
$tenant = Tenant::factory()->create();
|
||||||
|
$reasonEnvelope = new ReasonResolutionEnvelope(
|
||||||
|
internalCode: 'provider_consent_missing',
|
||||||
|
operatorLabel: 'Admin consent required',
|
||||||
|
shortExplanation: 'Grant admin consent for this provider connection before retrying.',
|
||||||
|
actionability: 'prerequisite_missing',
|
||||||
|
nextSteps: [
|
||||||
|
NextStepOption::link('Grant admin consent', '/provider-connections/1/consent'),
|
||||||
|
NextStepOption::link('Open provider settings', '/provider-connections/1'),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
$run = OperationRun::factory()->create([
|
||||||
|
'tenant_id' => $tenant->getKey(),
|
||||||
|
'type' => 'provider.connection.check',
|
||||||
|
'status' => 'completed',
|
||||||
|
'outcome' => 'blocked',
|
||||||
|
'context' => [
|
||||||
|
'reason_code' => 'provider_consent_missing',
|
||||||
|
'reason_translation' => $reasonEnvelope->toArray(),
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
|
||||||
|
$presenter = app(ProviderOperationStartResultPresenter::class);
|
||||||
|
|
||||||
|
$notification = $presenter->notification(
|
||||||
|
result: ProviderOperationStartResult::blocked($run),
|
||||||
|
blockedTitle: 'Verification blocked',
|
||||||
|
runUrl: OperationRunLinks::tenantlessView($run),
|
||||||
|
extraActions: [
|
||||||
|
Action::make('manage_connections')
|
||||||
|
->label('Manage Provider Connections')
|
||||||
|
->url('/provider-connections'),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
|
$actions = collect($notification->getActions());
|
||||||
|
|
||||||
|
expect($notification->getTitle())->toBe('Verification blocked')
|
||||||
|
->and($notification->getBody())->toBe("Admin consent required\nGrant admin consent for this provider connection before retrying.\nNext step: Grant admin consent.")
|
||||||
|
->and($actions->map(fn (Action $action): string => (string) $action->getName())->all())->toBe([
|
||||||
|
'view_run',
|
||||||
|
'next_step_0',
|
||||||
|
'manage_connections',
|
||||||
|
])
|
||||||
|
->and($actions->map(fn (Action $action): string => (string) $action->getLabel())->all())->toBe([
|
||||||
|
'Open operation',
|
||||||
|
'Grant admin consent',
|
||||||
|
'Manage Provider Connections',
|
||||||
|
]);
|
||||||
|
});
|
||||||
81
apps/website/public/images/hero-product-visual.svg
Normal file
81
apps/website/public/images/hero-product-visual.svg
Normal file
@ -0,0 +1,81 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="800" height="500" viewBox="0 0 800 500">
|
||||||
|
<defs>
|
||||||
|
<linearGradient id="bg" x1="0%" y1="0%" x2="100%" y2="100%">
|
||||||
|
<stop offset="0%" style="stop-color:#f8fafc;stop-opacity:1" />
|
||||||
|
<stop offset="100%" style="stop-color:#e2e8f0;stop-opacity:1" />
|
||||||
|
</linearGradient>
|
||||||
|
<linearGradient id="header" x1="0%" y1="0%" x2="100%" y2="0%">
|
||||||
|
<stop offset="0%" style="stop-color:#2f6fb7;stop-opacity:1" />
|
||||||
|
<stop offset="100%" style="stop-color:#8eaed1;stop-opacity:1" />
|
||||||
|
</linearGradient>
|
||||||
|
</defs>
|
||||||
|
<rect width="800" height="500" rx="16" fill="url(#bg)" stroke="#e2e8f0" stroke-width="2"/>
|
||||||
|
<!-- Header bar -->
|
||||||
|
<rect x="0" y="0" width="800" height="48" rx="16" fill="url(#header)"/>
|
||||||
|
<rect x="0" y="16" width="800" height="32" fill="url(#header)"/>
|
||||||
|
<text x="24" y="30" font-family="system-ui" font-size="14" fill="white" font-weight="600">TenantAtlas</text>
|
||||||
|
<!-- Sidebar -->
|
||||||
|
<rect x="0" y="48" width="180" height="452" fill="#f1f5f9"/>
|
||||||
|
<rect x="16" y="68" width="148" height="32" rx="8" fill="#2f6fb7" opacity="0.12"/>
|
||||||
|
<text x="28" y="89" font-family="system-ui" font-size="12" fill="#2f6fb7" font-weight="600">Inventory</text>
|
||||||
|
<rect x="16" y="112" width="148" height="28" rx="8" fill="transparent"/>
|
||||||
|
<text x="28" y="130" font-family="system-ui" font-size="12" fill="#64748b">Backup History</text>
|
||||||
|
<rect x="16" y="148" width="148" height="28" rx="8" fill="transparent"/>
|
||||||
|
<text x="28" y="166" font-family="system-ui" font-size="12" fill="#64748b">Restore</text>
|
||||||
|
<rect x="16" y="184" width="148" height="28" rx="8" fill="transparent"/>
|
||||||
|
<text x="28" y="202" font-family="system-ui" font-size="12" fill="#64748b">Drift Detection</text>
|
||||||
|
<rect x="16" y="220" width="148" height="28" rx="8" fill="transparent"/>
|
||||||
|
<text x="28" y="238" font-family="system-ui" font-size="12" fill="#64748b">Governance</text>
|
||||||
|
<!-- Main content area -->
|
||||||
|
<rect x="196" y="64" width="588" height="420" rx="12" fill="white" stroke="#e2e8f0"/>
|
||||||
|
<!-- Stats row -->
|
||||||
|
<rect x="212" y="80" width="130" height="64" rx="8" fill="#f8fafc" stroke="#e2e8f0"/>
|
||||||
|
<text x="228" y="104" font-family="system-ui" font-size="22" fill="#1e293b" font-weight="700">247</text>
|
||||||
|
<text x="228" y="130" font-family="system-ui" font-size="11" fill="#64748b">Policies tracked</text>
|
||||||
|
<rect x="358" y="80" width="130" height="64" rx="8" fill="#f8fafc" stroke="#e2e8f0"/>
|
||||||
|
<text x="374" y="104" font-family="system-ui" font-size="22" fill="#16a34a" font-weight="700">98.4%</text>
|
||||||
|
<text x="374" y="130" font-family="system-ui" font-size="11" fill="#64748b">Compliance rate</text>
|
||||||
|
<rect x="504" y="80" width="130" height="64" rx="8" fill="#f8fafc" stroke="#e2e8f0"/>
|
||||||
|
<text x="520" y="104" font-family="system-ui" font-size="22" fill="#2f6fb7" font-weight="700">12</text>
|
||||||
|
<text x="520" y="130" font-family="system-ui" font-size="11" fill="#64748b">Versions stored</text>
|
||||||
|
<rect x="650" y="80" width="118" height="64" rx="8" fill="#f8fafc" stroke="#e2e8f0"/>
|
||||||
|
<text x="666" y="104" font-family="system-ui" font-size="22" fill="#f59e0b" font-weight="700">3</text>
|
||||||
|
<text x="666" y="130" font-family="system-ui" font-size="11" fill="#64748b">Drift alerts</text>
|
||||||
|
<!-- Table header -->
|
||||||
|
<rect x="212" y="160" width="556" height="36" rx="0" fill="#f8fafc"/>
|
||||||
|
<text x="228" y="183" font-family="system-ui" font-size="11" fill="#64748b" font-weight="600">POLICY NAME</text>
|
||||||
|
<text x="440" y="183" font-family="system-ui" font-size="11" fill="#64748b" font-weight="600">TYPE</text>
|
||||||
|
<text x="560" y="183" font-family="system-ui" font-size="11" fill="#64748b" font-weight="600">STATUS</text>
|
||||||
|
<text x="670" y="183" font-family="system-ui" font-size="11" fill="#64748b" font-weight="600">LAST BACKUP</text>
|
||||||
|
<!-- Table rows -->
|
||||||
|
<line x1="212" y1="196" x2="768" y2="196" stroke="#e2e8f0"/>
|
||||||
|
<text x="228" y="220" font-family="system-ui" font-size="12" fill="#1e293b">Windows Compliance Baseline</text>
|
||||||
|
<text x="440" y="220" font-family="system-ui" font-size="12" fill="#64748b">Compliance</text>
|
||||||
|
<rect x="560" y="208" width="56" height="20" rx="10" fill="#dcfce7"/>
|
||||||
|
<text x="572" y="222" font-family="system-ui" font-size="10" fill="#16a34a" font-weight="500">Synced</text>
|
||||||
|
<text x="670" y="220" font-family="system-ui" font-size="12" fill="#64748b">2 min ago</text>
|
||||||
|
<line x1="212" y1="236" x2="768" y2="236" stroke="#f1f5f9"/>
|
||||||
|
<text x="228" y="260" font-family="system-ui" font-size="12" fill="#1e293b">BitLocker Encryption Policy</text>
|
||||||
|
<text x="440" y="260" font-family="system-ui" font-size="12" fill="#64748b">Config</text>
|
||||||
|
<rect x="560" y="248" width="56" height="20" rx="10" fill="#dcfce7"/>
|
||||||
|
<text x="572" y="262" font-family="system-ui" font-size="10" fill="#16a34a" font-weight="500">Synced</text>
|
||||||
|
<text x="670" y="260" font-family="system-ui" font-size="12" fill="#64748b">15 min ago</text>
|
||||||
|
<line x1="212" y1="276" x2="768" y2="276" stroke="#f1f5f9"/>
|
||||||
|
<text x="228" y="300" font-family="system-ui" font-size="12" fill="#1e293b">Conditional Access – MFA</text>
|
||||||
|
<text x="440" y="300" font-family="system-ui" font-size="12" fill="#64748b">Access</text>
|
||||||
|
<rect x="560" y="288" width="48" height="20" rx="10" fill="#fef3c7"/>
|
||||||
|
<text x="569" y="302" font-family="system-ui" font-size="10" fill="#d97706" font-weight="500">Drift</text>
|
||||||
|
<text x="670" y="300" font-family="system-ui" font-size="12" fill="#64748b">1 hr ago</text>
|
||||||
|
<line x1="212" y1="316" x2="768" y2="316" stroke="#f1f5f9"/>
|
||||||
|
<text x="228" y="340" font-family="system-ui" font-size="12" fill="#1e293b">Autopilot Deployment Profile</text>
|
||||||
|
<text x="440" y="340" font-family="system-ui" font-size="12" fill="#64748b">Enrollment</text>
|
||||||
|
<rect x="560" y="328" width="56" height="20" rx="10" fill="#dcfce7"/>
|
||||||
|
<text x="572" y="342" font-family="system-ui" font-size="10" fill="#16a34a" font-weight="500">Synced</text>
|
||||||
|
<text x="670" y="340" font-family="system-ui" font-size="12" fill="#64748b">30 min ago</text>
|
||||||
|
<line x1="212" y1="356" x2="768" y2="356" stroke="#f1f5f9"/>
|
||||||
|
<text x="228" y="380" font-family="system-ui" font-size="12" fill="#1e293b">App Protection – iOS Managed</text>
|
||||||
|
<text x="440" y="380" font-family="system-ui" font-size="12" fill="#64748b">Protection</text>
|
||||||
|
<rect x="560" y="368" width="56" height="20" rx="10" fill="#dcfce7"/>
|
||||||
|
<text x="572" y="382" font-family="system-ui" font-size="10" fill="#16a34a" font-weight="500">Synced</text>
|
||||||
|
<text x="670" y="380" font-family="system-ui" font-size="12" fill="#64748b">45 min ago</text>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 6.4 KiB |
@ -3,9 +3,10 @@ interface Props {
|
|||||||
as?: keyof HTMLElementTagNameMap;
|
as?: keyof HTMLElementTagNameMap;
|
||||||
class?: string;
|
class?: string;
|
||||||
variant?: 'accent' | 'default' | 'subtle';
|
variant?: 'accent' | 'default' | 'subtle';
|
||||||
|
[key: string]: unknown;
|
||||||
}
|
}
|
||||||
|
|
||||||
const { as = 'article', class: className = '', variant = 'default' } = Astro.props;
|
const { as = 'article', class: className = '', variant = 'default', ...rest } = Astro.props;
|
||||||
|
|
||||||
const variantClasses = {
|
const variantClasses = {
|
||||||
default: 'surface-card',
|
default: 'surface-card',
|
||||||
@ -19,6 +20,7 @@ const Tag = as;
|
|||||||
<Tag
|
<Tag
|
||||||
class:list={['rounded-[1.65rem] p-6 sm:p-7', variantClasses[variant], className]}
|
class:list={['rounded-[1.65rem] p-6 sm:p-7', variantClasses[variant], className]}
|
||||||
data-surface={variant}
|
data-surface={variant}
|
||||||
|
{...rest}
|
||||||
>
|
>
|
||||||
<slot />
|
<slot />
|
||||||
</Tag>
|
</Tag>
|
||||||
|
|||||||
@ -6,6 +6,7 @@ interface Props {
|
|||||||
id?: string;
|
id?: string;
|
||||||
layer?: '1' | '2' | '3';
|
layer?: '1' | '2' | '3';
|
||||||
tone?: 'default' | 'emphasis' | 'muted';
|
tone?: 'default' | 'emphasis' | 'muted';
|
||||||
|
[key: string]: unknown;
|
||||||
}
|
}
|
||||||
|
|
||||||
const {
|
const {
|
||||||
@ -15,6 +16,7 @@ const {
|
|||||||
id,
|
id,
|
||||||
layer = '2',
|
layer = '2',
|
||||||
tone = 'default',
|
tone = 'default',
|
||||||
|
...rest
|
||||||
} = Astro.props;
|
} = Astro.props;
|
||||||
const Tag = as;
|
const Tag = as;
|
||||||
const densityClasses = {
|
const densityClasses = {
|
||||||
@ -37,6 +39,7 @@ const toneClasses = {
|
|||||||
toneClasses[tone],
|
toneClasses[tone],
|
||||||
className,
|
className,
|
||||||
]}
|
]}
|
||||||
|
{...rest}
|
||||||
>
|
>
|
||||||
<slot />
|
<slot />
|
||||||
</Tag>
|
</Tag>
|
||||||
|
|||||||
59
apps/website/src/components/sections/CapabilityGrid.astro
Normal file
59
apps/website/src/components/sections/CapabilityGrid.astro
Normal file
@ -0,0 +1,59 @@
|
|||||||
|
---
|
||||||
|
import Badge from '@/components/primitives/Badge.astro';
|
||||||
|
import Card from '@/components/primitives/Card.astro';
|
||||||
|
import Container from '@/components/primitives/Container.astro';
|
||||||
|
import Grid from '@/components/primitives/Grid.astro';
|
||||||
|
import Headline from '@/components/content/Headline.astro';
|
||||||
|
import Lead from '@/components/content/Lead.astro';
|
||||||
|
import Section from '@/components/primitives/Section.astro';
|
||||||
|
import SectionHeader from '@/components/primitives/SectionHeader.astro';
|
||||||
|
import type { CapabilityClusterContent } from '@/types/site';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
description?: string;
|
||||||
|
eyebrow?: string;
|
||||||
|
items: CapabilityClusterContent[];
|
||||||
|
title: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { description, eyebrow, items, title } = Astro.props;
|
||||||
|
---
|
||||||
|
|
||||||
|
<Section layer="2" data-section="capability">
|
||||||
|
<Container width="wide">
|
||||||
|
<div class="space-y-8">
|
||||||
|
<SectionHeader eyebrow={eyebrow} title={title} description={description} />
|
||||||
|
<Grid cols="2" gap="lg">
|
||||||
|
{items.map((cluster) => (
|
||||||
|
<Card class="h-full">
|
||||||
|
<div class="space-y-4">
|
||||||
|
<div class="flex items-start justify-between gap-3">
|
||||||
|
<Headline as="h3" size="card">
|
||||||
|
{cluster.title}
|
||||||
|
</Headline>
|
||||||
|
{cluster.meta && (
|
||||||
|
<Badge tone="signal">{cluster.meta}</Badge>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<Lead size="body">
|
||||||
|
{cluster.description}
|
||||||
|
</Lead>
|
||||||
|
<ul class="flex flex-wrap gap-2 p-0">
|
||||||
|
{cluster.capabilities.map((cap) => (
|
||||||
|
<li class="list-none rounded-full border border-[color:var(--color-line)] bg-white/60 px-3 py-1.5 text-sm text-[var(--color-ink-800)]">
|
||||||
|
{cap}
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
{cluster.href && (
|
||||||
|
<a class="text-link mt-2 inline-block text-sm font-semibold" href={cluster.href}>
|
||||||
|
Learn more →
|
||||||
|
</a>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
))}
|
||||||
|
</Grid>
|
||||||
|
</div>
|
||||||
|
</Container>
|
||||||
|
</Section>
|
||||||
40
apps/website/src/components/sections/OutcomeSection.astro
Normal file
40
apps/website/src/components/sections/OutcomeSection.astro
Normal file
@ -0,0 +1,40 @@
|
|||||||
|
---
|
||||||
|
import Card from '@/components/primitives/Card.astro';
|
||||||
|
import Container from '@/components/primitives/Container.astro';
|
||||||
|
import Grid from '@/components/primitives/Grid.astro';
|
||||||
|
import Headline from '@/components/content/Headline.astro';
|
||||||
|
import Lead from '@/components/content/Lead.astro';
|
||||||
|
import Section from '@/components/primitives/Section.astro';
|
||||||
|
import SectionHeader from '@/components/primitives/SectionHeader.astro';
|
||||||
|
import type { OutcomeSectionContent } from '@/types/site';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
content: OutcomeSectionContent;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { content } = Astro.props;
|
||||||
|
---
|
||||||
|
|
||||||
|
<Section layer="2" data-section="outcome">
|
||||||
|
<Container width="wide">
|
||||||
|
<div class="space-y-8">
|
||||||
|
<SectionHeader
|
||||||
|
eyebrow="Why it matters"
|
||||||
|
title={content.title}
|
||||||
|
description={content.description}
|
||||||
|
/>
|
||||||
|
<Grid cols="3">
|
||||||
|
{content.outcomes.map((outcome) => (
|
||||||
|
<Card class="h-full">
|
||||||
|
<Headline as="h3" size="card">
|
||||||
|
{outcome.title}
|
||||||
|
</Headline>
|
||||||
|
<Lead class="mt-3" size="body">
|
||||||
|
{outcome.description}
|
||||||
|
</Lead>
|
||||||
|
</Card>
|
||||||
|
))}
|
||||||
|
</Grid>
|
||||||
|
</div>
|
||||||
|
</Container>
|
||||||
|
</Section>
|
||||||
@ -42,7 +42,18 @@ const { calloutDescription, calloutTitle, hero, metrics = [] } = Astro.props;
|
|||||||
)}
|
)}
|
||||||
</Cluster>
|
</Cluster>
|
||||||
)}
|
)}
|
||||||
{hero.highlights && hero.highlights.length > 0 && (
|
{hero.trustSubclaims && hero.trustSubclaims.length > 0 && (
|
||||||
|
<ul class="grid gap-3 p-0 sm:grid-cols-3">
|
||||||
|
{
|
||||||
|
hero.trustSubclaims.map((claim) => (
|
||||||
|
<li class="list-none rounded-[1.1rem] border border-[color:var(--color-line)] bg-white/70 px-4 py-3 text-sm font-medium text-[var(--color-ink-800)]">
|
||||||
|
{claim}
|
||||||
|
</li>
|
||||||
|
))
|
||||||
|
}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
{hero.highlights && hero.highlights.length > 0 && !hero.trustSubclaims?.length && (
|
||||||
<ul class="grid gap-3 p-0 sm:grid-cols-3">
|
<ul class="grid gap-3 p-0 sm:grid-cols-3">
|
||||||
{
|
{
|
||||||
hero.highlights.map((highlight) => (
|
hero.highlights.map((highlight) => (
|
||||||
@ -57,7 +68,18 @@ const { calloutDescription, calloutTitle, hero, metrics = [] } = Astro.props;
|
|||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
<div class="grid gap-5">
|
<div class="grid gap-5">
|
||||||
{(calloutTitle || calloutDescription) && (
|
{hero.productVisual && (
|
||||||
|
<Card variant="accent" class="motion-rise overflow-hidden" data-hero-visual>
|
||||||
|
<img
|
||||||
|
src={hero.productVisual.src}
|
||||||
|
alt={hero.productVisual.alt}
|
||||||
|
class="w-full rounded-[var(--radius-lg)] object-cover"
|
||||||
|
loading="eager"
|
||||||
|
/>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!hero.productVisual && (calloutTitle || calloutDescription) && (
|
||||||
<Card variant="accent" class="motion-rise">
|
<Card variant="accent" class="motion-rise">
|
||||||
<p class="m-0 text-sm font-semibold uppercase tracking-[0.15em] text-[var(--color-brand)]">
|
<p class="m-0 text-sm font-semibold uppercase tracking-[0.15em] text-[var(--color-brand)]">
|
||||||
Trust-first launch surface
|
Trust-first launch surface
|
||||||
|
|||||||
53
apps/website/src/components/sections/ProgressTeaser.astro
Normal file
53
apps/website/src/components/sections/ProgressTeaser.astro
Normal file
@ -0,0 +1,53 @@
|
|||||||
|
---
|
||||||
|
import Badge from '@/components/primitives/Badge.astro';
|
||||||
|
import Card from '@/components/primitives/Card.astro';
|
||||||
|
import Container from '@/components/primitives/Container.astro';
|
||||||
|
import Headline from '@/components/content/Headline.astro';
|
||||||
|
import Lead from '@/components/content/Lead.astro';
|
||||||
|
import PrimaryCTA from '@/components/content/PrimaryCTA.astro';
|
||||||
|
import Section from '@/components/primitives/Section.astro';
|
||||||
|
import SectionHeader from '@/components/primitives/SectionHeader.astro';
|
||||||
|
import type { ProgressTeaserContent } from '@/types/site';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
content: ProgressTeaserContent;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { content } = Astro.props;
|
||||||
|
|
||||||
|
function formatDate(date: Date): string {
|
||||||
|
return date.toLocaleDateString('en-US', {
|
||||||
|
year: 'numeric',
|
||||||
|
month: 'short',
|
||||||
|
day: 'numeric',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
---
|
||||||
|
|
||||||
|
<Section layer="2" data-section="progress">
|
||||||
|
<Container width="wide">
|
||||||
|
<div class="space-y-8">
|
||||||
|
<SectionHeader
|
||||||
|
eyebrow="Visible progress"
|
||||||
|
title={content.title}
|
||||||
|
description={content.description}
|
||||||
|
/>
|
||||||
|
<div class="space-y-4">
|
||||||
|
{content.entries.map((entry) => (
|
||||||
|
<Card class="flex flex-col gap-3 sm:flex-row sm:items-start sm:gap-6">
|
||||||
|
<Badge tone="signal">{formatDate(entry.date)}</Badge>
|
||||||
|
<div>
|
||||||
|
<Headline as="h3" size="card">
|
||||||
|
{entry.title}
|
||||||
|
</Headline>
|
||||||
|
<Lead class="mt-2" size="body">
|
||||||
|
{entry.description}
|
||||||
|
</Lead>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<PrimaryCTA cta={content.cta} />
|
||||||
|
</div>
|
||||||
|
</Container>
|
||||||
|
</Section>
|
||||||
@ -1,10 +1,12 @@
|
|||||||
import type {
|
import type {
|
||||||
CalloutContent,
|
CapabilityClusterContent,
|
||||||
FeatureItemContent,
|
CtaLink,
|
||||||
HeroContent,
|
HeroContent,
|
||||||
IntegrationEntry,
|
IntegrationEntry,
|
||||||
MetricItem,
|
OutcomeSectionContent,
|
||||||
PageSeo,
|
PageSeo,
|
||||||
|
ProgressTeaserContent,
|
||||||
|
TrustSignalGroupContent,
|
||||||
} from '@/types/site';
|
} from '@/types/site';
|
||||||
|
|
||||||
export const homeSeo: PageSeo = {
|
export const homeSeo: PageSeo = {
|
||||||
@ -15,131 +17,155 @@ export const homeSeo: PageSeo = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export const homeHero: HeroContent = {
|
export const homeHero: HeroContent = {
|
||||||
eyebrow: 'Core public route set',
|
eyebrow: 'Governance of record',
|
||||||
title: 'TenantAtlas keeps Microsoft tenant change history, restore posture, and review context inside one operating record.',
|
title: 'TenantAtlas keeps Microsoft tenant change history, restore posture, and review context inside one operating record.',
|
||||||
description:
|
description:
|
||||||
'TenantAtlas gives MSP and enterprise teams a calmer way to understand what changed, what drifted, what can be restored, and what needs review without turning governance into a loose collection of disconnected screens.',
|
'MSP and enterprise teams use TenantAtlas to understand what changed, what drifted, what can be restored, and what needs review — without turning governance into disconnected screens.',
|
||||||
primaryCta: {
|
primaryCta: {
|
||||||
href: '/product',
|
href: '/contact',
|
||||||
label: 'See the product model',
|
label: 'Request a working session',
|
||||||
},
|
},
|
||||||
secondaryCta: {
|
secondaryCta: {
|
||||||
href: '/trust',
|
href: '/product',
|
||||||
label: 'Review the trust posture',
|
label: 'See the product model',
|
||||||
variant: 'secondary',
|
variant: 'secondary',
|
||||||
},
|
},
|
||||||
highlights: [
|
productVisual: {
|
||||||
'Product, trust, and changelog each do one explicit job in the buyer journey.',
|
src: '/images/hero-product-visual.svg',
|
||||||
'The public site stays static, readable, and separate from platform runtime concerns.',
|
alt: 'TenantAtlas governance dashboard showing tenant change history and restore posture',
|
||||||
'One clear contact path remains visible without pricing, docs, or placeholder routes taking over.',
|
},
|
||||||
|
trustSubclaims: [
|
||||||
|
'Tenant-isolated by design',
|
||||||
|
'Immutable change history',
|
||||||
|
'Bounded public claims',
|
||||||
],
|
],
|
||||||
};
|
};
|
||||||
|
|
||||||
export const homeMetrics: MetricItem[] = [
|
export const homeOutcome: OutcomeSectionContent = {
|
||||||
|
title: 'Calmer operations start with visible governance.',
|
||||||
|
description:
|
||||||
|
'TenantAtlas replaces fragmented spreadsheets and manual audit trails with one connected record of tenant change, restore safety, and review context.',
|
||||||
|
audienceBias: 'MSP and enterprise operations teams',
|
||||||
|
outcomes: [
|
||||||
{
|
{
|
||||||
value: '1',
|
title: 'Understand what changed and why it matters.',
|
||||||
label: 'Primary next step',
|
description:
|
||||||
description: 'Contact stays singular and obvious while product, trust, and changelog remain nearby.',
|
'Immutable version history gives every tenant configuration a traceable timeline, so drift and unexpected changes surface before they become incidents.',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
value: '4',
|
title: 'Restore with confidence, not guesswork.',
|
||||||
label: 'Core buyer questions',
|
description:
|
||||||
description: 'What is it, why it matters, why trust it, and what to do next each map to a named route.',
|
'Preview-first restore flows validate scope and impact before execution, turning risky rollbacks into reviewable operations.',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
value: '0',
|
title: 'Reduce the operational risk of governance gaps.',
|
||||||
label: 'Runtime coupling',
|
description:
|
||||||
description: 'The website stays independent from platform auth, session, and API behavior.',
|
'Connected findings, evidence, and review workflows keep audit context in one place instead of scattered across tools and memory.',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
export const homeCapabilities: CapabilityClusterContent[] = [
|
||||||
|
{
|
||||||
|
title: 'Backup & Version History',
|
||||||
|
description: 'Immutable snapshots of tenant configuration state with full version lineage.',
|
||||||
|
capabilities: ['Automated backup', 'Immutable snapshots', 'Version comparison', 'Change timeline'],
|
||||||
|
href: '/product',
|
||||||
|
meta: 'Core safety net',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Restore & Recovery',
|
||||||
|
description: 'Preview-first restore with selective scope and explicit confirmation.',
|
||||||
|
capabilities: ['Dry-run preview', 'Selective restore', 'Rollback safety', 'Conflict detection'],
|
||||||
|
href: '/product',
|
||||||
|
meta: 'Safer change operations',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Inventory & Drift Visibility',
|
||||||
|
description: 'Connected view of what exists, what drifted, and what needs attention.',
|
||||||
|
capabilities: ['Policy inventory', 'Drift detection', 'Assignment visibility', 'Cross-tenant comparison'],
|
||||||
|
href: '/product',
|
||||||
|
meta: 'Operational clarity',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Governance & Evidence',
|
||||||
|
description: 'Audit-ready evidence, findings, and review workflows for tenant operations.',
|
||||||
|
capabilities: ['Audit trails', 'Evidence linkage', 'Exception tracking', 'Review workflows'],
|
||||||
|
href: '/product',
|
||||||
|
meta: 'Accountability and review',
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
export const homePillars: FeatureItemContent[] = [
|
export const homeTrustSignals: TrustSignalGroupContent = {
|
||||||
{
|
title: 'Trust is a first-read concern, not a footnote.',
|
||||||
eyebrow: 'Product truth',
|
|
||||||
title: 'Show the connected governance model before any audience-specific detours.',
|
|
||||||
description:
|
description:
|
||||||
'The first pass should explain how inventory, immutable history, restore safety, findings, evidence, and reviews fit together before it asks a buyer to infer the model alone.',
|
'Tenant isolation, access boundaries, and operating discipline are product rules, not marketing language. Every public claim routes back to one bounded trust surface.',
|
||||||
meta: 'Product before route sprawl',
|
supportRoute: '/trust',
|
||||||
|
signals: [
|
||||||
|
{
|
||||||
|
title: 'Tenant isolation is a product boundary.',
|
||||||
|
description:
|
||||||
|
'Tenant-scoped data, access, and workflow boundaries are enforced as deliberate product rules.',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
eyebrow: 'Trust visibility',
|
title: 'Access stays bounded and purpose-specific.',
|
||||||
title: 'Make the credibility surface obvious while public claims stay bounded.',
|
|
||||||
description:
|
description:
|
||||||
'Trust belongs in top-level navigation because tenant isolation, access handling, and operating discipline are first-read questions for this category.',
|
'Microsoft tenant-facing access follows least-privilege scoping tied to the governance operations that require it.',
|
||||||
meta: 'Trust is top-level visible',
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
eyebrow: 'Visible progress',
|
title: 'Restore operations require preview and confirmation.',
|
||||||
title: 'Publish dated progress instead of implying motion through vague copy.',
|
|
||||||
description:
|
description:
|
||||||
'A changelog surface gives returning visitors one concrete route for current product movement without forcing a blog or resources hub into the first navigation layer.',
|
'High-risk changes go through validation, selective scope, and explicit confirmation instead of one-click execution.',
|
||||||
meta: 'Real changelog, no placeholder hub',
|
|
||||||
},
|
},
|
||||||
{
|
],
|
||||||
eyebrow: 'Clear action path',
|
};
|
||||||
title: 'Keep contact visible without turning the site into a demo funnel.',
|
|
||||||
description:
|
|
||||||
'The initial IA leads to one working-session route so serious buyers can move forward without guessing whether contact, demo, or sales are different flows.',
|
|
||||||
meta: 'One clear conversion route',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
eyebrow: 'Outcome explanation',
|
|
||||||
title: 'Explain who the product helps without forcing a dedicated Solutions hub into the header.',
|
|
||||||
description:
|
|
||||||
'Home and Product should carry the buyer-outcome explanation well enough that retained supporting pages stay secondary instead of becoming required orientation routes.',
|
|
||||||
meta: 'Outcome clarity without route inflation',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
eyebrow: 'Static-first delivery',
|
|
||||||
title: 'Preserve the website as a static public track with no platform coupling.',
|
|
||||||
description:
|
|
||||||
'The IA work stays local to the Astro website so trust, product, and legal surfaces remain reviewable without adding auth, API, or admin runtime obligations.',
|
|
||||||
meta: 'Website-only contract',
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
export const homeProofBlocks: CalloutContent[] = [
|
export const homeProgressTeaser: ProgressTeaserContent = {
|
||||||
{
|
title: 'Product movement you can verify.',
|
||||||
eyebrow: 'First read',
|
|
||||||
title: 'A serious buyer should understand the product and the route model in one pass.',
|
|
||||||
description:
|
description:
|
||||||
'The site needs a stable answer to what the product is, how trust is handled, and how to continue the evaluation before optional surfaces earn any prominence.',
|
'Real progress shows up as dated changelog entries, not vague promises. Check the changelog for the latest updates.',
|
||||||
tone: 'accent',
|
entries: [],
|
||||||
|
cta: {
|
||||||
|
href: '/changelog',
|
||||||
|
label: 'Read the full changelog',
|
||||||
},
|
},
|
||||||
{
|
};
|
||||||
eyebrow: 'Trust boundary',
|
|
||||||
title: 'Trust claims should live on one explicit surface instead of leaking across marketing copy.',
|
export const homeCtaSection = {
|
||||||
|
eyebrow: 'Next step',
|
||||||
|
title: 'Move from first read into a working session.',
|
||||||
description:
|
description:
|
||||||
'Claims about isolation, operating discipline, access handling, or hosting must route back to a dedicated trust page that supports and bounds them clearly.',
|
'Once you understand the product model, the trust posture, and the progress record, the next step is a conversation about your governance reality.',
|
||||||
},
|
primary: {
|
||||||
{
|
href: '/contact',
|
||||||
eyebrow: 'No placeholder prestige pages',
|
label: 'Request a working session',
|
||||||
title: 'Leave docs, pricing, and resources out of the spotlight until they are real.',
|
} as CtaLink,
|
||||||
description:
|
secondary: {
|
||||||
'The public story stays more credible when optional hubs remain unpublished and visible progress comes from the changelog instead of speculative route growth.',
|
href: '/product',
|
||||||
tone: 'subtle',
|
label: 'See the product model',
|
||||||
},
|
variant: 'secondary',
|
||||||
];
|
} as CtaLink,
|
||||||
|
};
|
||||||
|
|
||||||
export const homeEcosystem: IntegrationEntry[] = [
|
export const homeEcosystem: IntegrationEntry[] = [
|
||||||
{
|
{
|
||||||
category: 'Microsoft',
|
category: 'Microsoft',
|
||||||
name: 'Microsoft Graph',
|
name: 'Microsoft Graph',
|
||||||
summary: 'Graph-backed inventory and restore direction without implying that the public website depends on live tenant access.',
|
summary: 'Graph-backed inventory and restore without implying the public website depends on live tenant access.',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
category: 'Identity',
|
category: 'Identity',
|
||||||
name: 'Entra ID',
|
name: 'Entra ID',
|
||||||
summary: 'Identity context matters where change control, tenant access, and review posture intersect.',
|
summary: 'Identity context where change control, tenant access, and review posture intersect.',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
category: 'Endpoint',
|
category: 'Endpoint',
|
||||||
name: 'Intune',
|
name: 'Intune',
|
||||||
summary: 'Configuration state, backup, restore posture, and drift visibility stay central to the public product story.',
|
summary: 'Configuration state, backup, restore posture, and drift visibility stay central to the product story.',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
category: 'Governance',
|
category: 'Governance',
|
||||||
name: 'Review workflows',
|
name: 'Review workflows',
|
||||||
summary: 'Exceptions, evidence, and reviews stay connected to operational reality instead of becoming detached reporting artifacts.',
|
summary: 'Exceptions, evidence, and reviews stay connected to operational reality.',
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|||||||
17
apps/website/src/lib/changelog.ts
Normal file
17
apps/website/src/lib/changelog.ts
Normal file
@ -0,0 +1,17 @@
|
|||||||
|
import { getCollection } from 'astro:content';
|
||||||
|
|
||||||
|
import type { ProgressTeaserEntry } from '@/types/site';
|
||||||
|
|
||||||
|
export async function getRecentChangelogEntries(limit = 3): Promise<ProgressTeaserEntry[]> {
|
||||||
|
const changelog = await getCollection('changelog');
|
||||||
|
|
||||||
|
return changelog
|
||||||
|
.filter((entry) => entry.data.draft !== true)
|
||||||
|
.sort((a, b) => b.data.publishedAt.getTime() - a.data.publishedAt.getTime())
|
||||||
|
.slice(0, limit)
|
||||||
|
.map((entry) => ({
|
||||||
|
date: entry.data.publishedAt,
|
||||||
|
description: entry.data.description,
|
||||||
|
title: entry.data.title,
|
||||||
|
}));
|
||||||
|
}
|
||||||
@ -1,31 +1,36 @@
|
|||||||
---
|
---
|
||||||
import Callout from '@/components/content/Callout.astro';
|
|
||||||
import PageShell from '@/components/layout/PageShell.astro';
|
import PageShell from '@/components/layout/PageShell.astro';
|
||||||
import Container from '@/components/primitives/Container.astro';
|
import CapabilityGrid from '@/components/sections/CapabilityGrid.astro';
|
||||||
import Grid from '@/components/primitives/Grid.astro';
|
|
||||||
import Section from '@/components/primitives/Section.astro';
|
|
||||||
import SectionHeader from '@/components/primitives/SectionHeader.astro';
|
|
||||||
import CTASection from '@/components/sections/CTASection.astro';
|
import CTASection from '@/components/sections/CTASection.astro';
|
||||||
import FeatureGrid from '@/components/sections/FeatureGrid.astro';
|
|
||||||
import LogoStrip from '@/components/sections/LogoStrip.astro';
|
import LogoStrip from '@/components/sections/LogoStrip.astro';
|
||||||
|
import OutcomeSection from '@/components/sections/OutcomeSection.astro';
|
||||||
import PageHero from '@/components/sections/PageHero.astro';
|
import PageHero from '@/components/sections/PageHero.astro';
|
||||||
|
import ProgressTeaser from '@/components/sections/ProgressTeaser.astro';
|
||||||
|
import TrustGrid from '@/components/sections/TrustGrid.astro';
|
||||||
|
import PrimaryCTA from '@/components/content/PrimaryCTA.astro';
|
||||||
|
import Container from '@/components/primitives/Container.astro';
|
||||||
|
import Section from '@/components/primitives/Section.astro';
|
||||||
|
import { getRecentChangelogEntries } from '@/lib/changelog';
|
||||||
import {
|
import {
|
||||||
|
homeCapabilities,
|
||||||
|
homeCtaSection,
|
||||||
homeEcosystem,
|
homeEcosystem,
|
||||||
homeHero,
|
homeHero,
|
||||||
homeMetrics,
|
homeOutcome,
|
||||||
homePillars,
|
homeProgressTeaser,
|
||||||
homeProofBlocks,
|
|
||||||
homeSeo,
|
homeSeo,
|
||||||
|
homeTrustSignals,
|
||||||
} from '@/content/pages/home';
|
} from '@/content/pages/home';
|
||||||
|
|
||||||
|
const recentChangelog = await getRecentChangelogEntries(3);
|
||||||
|
const progressContent = {
|
||||||
|
...homeProgressTeaser,
|
||||||
|
entries: recentChangelog.length > 0 ? recentChangelog : homeProgressTeaser.entries,
|
||||||
|
};
|
||||||
---
|
---
|
||||||
|
|
||||||
<PageShell currentPath="/" title={homeSeo.title} description={homeSeo.description}>
|
<PageShell currentPath="/" title={homeSeo.title} description={homeSeo.description}>
|
||||||
<PageHero
|
<PageHero hero={homeHero} />
|
||||||
hero={homeHero}
|
|
||||||
metrics={homeMetrics}
|
|
||||||
calloutTitle="Governance of record for Microsoft tenant operations."
|
|
||||||
calloutDescription="The public story positions TenantAtlas as a trust-first system for version truth, safer restore posture, drift visibility, evidence, and review support."
|
|
||||||
/>
|
|
||||||
|
|
||||||
<LogoStrip
|
<LogoStrip
|
||||||
eyebrow="Ecosystem fit"
|
eyebrow="Ecosystem fit"
|
||||||
@ -33,33 +38,53 @@ import {
|
|||||||
items={homeEcosystem}
|
items={homeEcosystem}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<FeatureGrid
|
<OutcomeSection content={homeOutcome} />
|
||||||
eyebrow="Core route jobs"
|
|
||||||
title="Understand the product, the trust posture, and the next step without route sprawl."
|
<CapabilityGrid
|
||||||
description="Home should explain the operating model, show where trust lives, and surface visible product progress without pushing buyers into placeholder routes."
|
eyebrow="What TenantAtlas covers"
|
||||||
items={homePillars}
|
title="A connected product model, not a feature wall."
|
||||||
|
description="TenantAtlas groups backup, restore, inventory, drift, and governance into connected clusters instead of listing isolated features."
|
||||||
|
items={homeCapabilities}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<Section tone="muted" density="base" layer="3">
|
<div data-section="trust">
|
||||||
<Container width="wide">
|
<TrustGrid
|
||||||
<div class="space-y-8">
|
eyebrow="Trust posture"
|
||||||
<SectionHeader
|
title={homeTrustSignals.title}
|
||||||
eyebrow="Public proof"
|
description={homeTrustSignals.description}
|
||||||
title="Answer the next question before a visitor has to hunt for another top-level route."
|
items={homeTrustSignals.signals}
|
||||||
description="Why this category matters, where trust claims live, and why the changelog exists should all be obvious from the first read."
|
|
||||||
/>
|
/>
|
||||||
<Grid cols="3">
|
<Section density="compact">
|
||||||
{homeProofBlocks.map((block) => <Callout content={block} />)}
|
<Container width="wide">
|
||||||
</Grid>
|
<div class="flex justify-center">
|
||||||
|
<PrimaryCTA cta={{ href: '/trust', label: 'Review the full trust posture', variant: 'secondary' }} />
|
||||||
</div>
|
</div>
|
||||||
</Container>
|
</Container>
|
||||||
</Section>
|
</Section>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{progressContent.entries.length > 0 && (
|
||||||
|
<ProgressTeaser content={progressContent} />
|
||||||
|
)}
|
||||||
|
{progressContent.entries.length === 0 && (
|
||||||
|
<Section layer="2" data-section="progress">
|
||||||
|
<Container width="wide">
|
||||||
|
<div class="text-center">
|
||||||
|
<p class="text-[var(--color-copy)]">
|
||||||
|
Follow product progress on the <a href="/changelog" class="text-link font-semibold">changelog</a>.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</Container>
|
||||||
|
</Section>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div data-section="cta">
|
||||||
<CTASection
|
<CTASection
|
||||||
eyebrow="Next step"
|
eyebrow={homeCtaSection.eyebrow}
|
||||||
title="Move from first read into product detail, trust review, or a working session."
|
title={homeCtaSection.title}
|
||||||
description="From Home, a serious buyer should be able to inspect the product model, verify public progress, or reach the contact path without guessing where to go next."
|
description={homeCtaSection.description}
|
||||||
primary={{ href: '/changelog', label: 'Read the changelog' }}
|
primary={homeCtaSection.primary}
|
||||||
secondary={{ href: '/contact', label: 'Start the working session', variant: 'secondary' }}
|
secondary={homeCtaSection.secondary}
|
||||||
/>
|
/>
|
||||||
|
</div>
|
||||||
</PageShell>
|
</PageShell>
|
||||||
|
|||||||
@ -99,8 +99,10 @@ export interface HeroContent {
|
|||||||
eyebrow: string;
|
eyebrow: string;
|
||||||
highlights?: string[];
|
highlights?: string[];
|
||||||
primaryCta: CtaLink;
|
primaryCta: CtaLink;
|
||||||
|
productVisual?: HeroVisualContent;
|
||||||
secondaryCta?: CtaLink;
|
secondaryCta?: CtaLink;
|
||||||
title: string;
|
title: string;
|
||||||
|
trustSubclaims?: string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface MetricItem {
|
export interface MetricItem {
|
||||||
@ -156,6 +158,51 @@ export interface LegalSection {
|
|||||||
title: string;
|
title: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface HeroVisualContent {
|
||||||
|
alt: string;
|
||||||
|
src: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface OutcomeItemContent {
|
||||||
|
description: string;
|
||||||
|
title: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface OutcomeSectionContent {
|
||||||
|
audienceBias?: string;
|
||||||
|
description: string;
|
||||||
|
outcomes: OutcomeItemContent[];
|
||||||
|
title: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CapabilityClusterContent {
|
||||||
|
capabilities: string[];
|
||||||
|
description: string;
|
||||||
|
href?: string;
|
||||||
|
meta?: string;
|
||||||
|
title: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TrustSignalGroupContent {
|
||||||
|
description: string;
|
||||||
|
signals: TrustPrincipleContent[];
|
||||||
|
supportRoute: string;
|
||||||
|
title: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ProgressTeaserEntry {
|
||||||
|
date: Date;
|
||||||
|
description: string;
|
||||||
|
title: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ProgressTeaserContent {
|
||||||
|
cta: CtaLink;
|
||||||
|
description: string;
|
||||||
|
entries: ProgressTeaserEntry[];
|
||||||
|
title: string;
|
||||||
|
}
|
||||||
|
|
||||||
export interface VisualFoundationContract {
|
export interface VisualFoundationContract {
|
||||||
accessibilityBaseline: readonly string[];
|
accessibilityBaseline: readonly string[];
|
||||||
ctaHierarchy: readonly ButtonVariant[];
|
ctaHierarchy: readonly ButtonVariant[];
|
||||||
|
|||||||
@ -4,8 +4,12 @@ import {
|
|||||||
expectCtaHierarchy,
|
expectCtaHierarchy,
|
||||||
expectDisclosureLayer,
|
expectDisclosureLayer,
|
||||||
expectFooterLinks,
|
expectFooterLinks,
|
||||||
|
expectHomepageSectionOrder,
|
||||||
|
expectMobileReadability,
|
||||||
expectNavigationVsCtaDifferentiation,
|
expectNavigationVsCtaDifferentiation,
|
||||||
|
expectOnwardRouteReachable,
|
||||||
expectPageFamily,
|
expectPageFamily,
|
||||||
|
expectProductNearVisual,
|
||||||
expectPrimaryNavigation,
|
expectPrimaryNavigation,
|
||||||
expectShell,
|
expectShell,
|
||||||
visitPage,
|
visitPage,
|
||||||
@ -22,14 +26,8 @@ test('home uses the landing foundation to explain the product category with one
|
|||||||
await expectPrimaryNavigation(page);
|
await expectPrimaryNavigation(page);
|
||||||
await expectNavigationVsCtaDifferentiation(page);
|
await expectNavigationVsCtaDifferentiation(page);
|
||||||
await expectFooterLinks(page);
|
await expectFooterLinks(page);
|
||||||
await expect(
|
await expectCtaHierarchy(page, 'Request a working session', 'See the product model');
|
||||||
page.getByRole('heading', {
|
await expect(page.getByRole('main').getByRole('link', { name: 'Request a working session' }).first()).toBeVisible();
|
||||||
name: 'Understand the product, the trust posture, and the next step without route sprawl.',
|
|
||||||
}),
|
|
||||||
).toBeVisible();
|
|
||||||
await expectCtaHierarchy(page, 'See the product model', 'Review the trust posture');
|
|
||||||
await expect(page.getByRole('main').getByRole('link', { name: 'Read the changelog' }).first()).toBeVisible();
|
|
||||||
await expect(page.getByRole('main').getByRole('link', { name: 'Start the working session' }).first()).toBeVisible();
|
|
||||||
|
|
||||||
const skipLink = page.getByRole('link', { name: 'Skip to content' });
|
const skipLink = page.getByRole('link', { name: 'Skip to content' });
|
||||||
|
|
||||||
@ -37,6 +35,70 @@ test('home uses the landing foundation to explain the product category with one
|
|||||||
await expect(skipLink).toBeFocused();
|
await expect(skipLink).toBeFocused();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('homepage hero explains the product with a product-near visual and outcome framing', async ({
|
||||||
|
page,
|
||||||
|
}) => {
|
||||||
|
await visitPage(page, '/');
|
||||||
|
await expectProductNearVisual(page);
|
||||||
|
await expect(page.locator('[data-section="outcome"]')).toBeVisible();
|
||||||
|
await expect(
|
||||||
|
page.getByRole('heading', { name: /calmer operations|governance pain|operational risk/i }).first(),
|
||||||
|
).toBeVisible();
|
||||||
|
await expectCtaHierarchy(page, 'Request a working session', 'See the product model');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('homepage maintains required section order: outcome before capability before trust before progress before cta', async ({
|
||||||
|
page,
|
||||||
|
}) => {
|
||||||
|
await visitPage(page, '/');
|
||||||
|
await expectHomepageSectionOrder(page, ['outcome', 'capability', 'trust', 'progress', 'cta']);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('homepage shows grouped capability clusters instead of a feature wall', async ({
|
||||||
|
page,
|
||||||
|
}) => {
|
||||||
|
await visitPage(page, '/');
|
||||||
|
await expect(page.locator('[data-section="capability"]')).toBeVisible();
|
||||||
|
await expect(
|
||||||
|
page.getByRole('heading', { name: /product model|what TenantAtlas covers/i }),
|
||||||
|
).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('homepage shows explicit trust signals before the final CTA', async ({
|
||||||
|
page,
|
||||||
|
}) => {
|
||||||
|
await visitPage(page, '/');
|
||||||
|
await expect(page.locator('[data-section="trust"]')).toBeVisible();
|
||||||
|
await expectOnwardRouteReachable(page, ['/trust']);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('homepage shows dated progress signals before the final CTA', async ({
|
||||||
|
page,
|
||||||
|
}) => {
|
||||||
|
await visitPage(page, '/');
|
||||||
|
await expect(page.locator('[data-section="progress"]')).toBeVisible();
|
||||||
|
await expectOnwardRouteReachable(page, ['/changelog']);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('homepage routes into Product, Trust, Changelog, and Contact', async ({
|
||||||
|
page,
|
||||||
|
}) => {
|
||||||
|
await visitPage(page, '/');
|
||||||
|
await expectOnwardRouteReachable(page, ['/product', '/trust', '/changelog', '/contact']);
|
||||||
|
});
|
||||||
|
|
||||||
|
test.describe('homepage mobile', () => {
|
||||||
|
test.use({ viewport: { width: 390, height: 844 } });
|
||||||
|
|
||||||
|
test('homepage remains readable on narrow screens', async ({ page }) => {
|
||||||
|
await visitPage(page, '/');
|
||||||
|
await expectMobileReadability(page);
|
||||||
|
await expect(page.locator('[data-section="outcome"]')).toBeVisible();
|
||||||
|
await expect(page.locator('[data-section="capability"]')).toBeVisible();
|
||||||
|
await expect(page.locator('[data-section="trust"]')).toBeVisible();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
test('product keeps the connected operating model readable without collapsing into a feature list', async ({
|
test('product keeps the connected operating model readable without collapsing into a feature list', async ({
|
||||||
page,
|
page,
|
||||||
}) => {
|
}) => {
|
||||||
|
|||||||
@ -93,3 +93,54 @@ export async function expectNavigationVsCtaDifferentiation(page: Page): Promise<
|
|||||||
await expect(header.locator('[data-nav-link]').first()).toBeVisible();
|
await expect(header.locator('[data-nav-link]').first()).toBeVisible();
|
||||||
await expect(header.locator('[data-cta-weight="secondary"]').first()).toBeVisible();
|
await expect(header.locator('[data-cta-weight="secondary"]').first()).toBeVisible();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function expectHomepageSectionOrder(page: Page, sections: string[]): Promise<void> {
|
||||||
|
const main = page.getByRole('main');
|
||||||
|
const sectionElements = main.locator('[data-section]');
|
||||||
|
const count = await sectionElements.count();
|
||||||
|
const actual: string[] = [];
|
||||||
|
|
||||||
|
for (let i = 0; i < count; i++) {
|
||||||
|
const name = await sectionElements.nth(i).getAttribute('data-section');
|
||||||
|
|
||||||
|
if (name) {
|
||||||
|
actual.push(name);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (let i = 0; i < sections.length; i++) {
|
||||||
|
expect(actual.indexOf(sections[i]), `Section "${sections[i]}" should appear in order`).toBeGreaterThanOrEqual(0);
|
||||||
|
|
||||||
|
if (i > 0) {
|
||||||
|
expect(
|
||||||
|
actual.indexOf(sections[i]),
|
||||||
|
`Section "${sections[i]}" should appear after "${sections[i - 1]}"`,
|
||||||
|
).toBeGreaterThan(actual.indexOf(sections[i - 1]));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function expectProductNearVisual(page: Page): Promise<void> {
|
||||||
|
const main = page.getByRole('main');
|
||||||
|
|
||||||
|
await expect(main.locator('[data-hero-visual] img, [data-hero-visual]').first()).toBeVisible();
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function expectMobileReadability(page: Page): Promise<void> {
|
||||||
|
const main = page.getByRole('main');
|
||||||
|
|
||||||
|
await expect(main).toBeVisible();
|
||||||
|
await expect(page.getByRole('banner')).toBeVisible();
|
||||||
|
await expect(page.getByRole('contentinfo')).toBeVisible();
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function expectOnwardRouteReachable(page: Page, routes: string[]): Promise<void> {
|
||||||
|
const main = page.getByRole('main');
|
||||||
|
|
||||||
|
for (const route of routes) {
|
||||||
|
await expect(
|
||||||
|
main.locator(`a[href="${route}"]`).first(),
|
||||||
|
`Route "${route}" should be reachable from main content`,
|
||||||
|
).toBeVisible();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@ -17,8 +17,8 @@ test('representative pages route CTA, badge, surface, and input semantics throug
|
|||||||
await expectPageFamily(page, 'landing');
|
await expectPageFamily(page, 'landing');
|
||||||
await expectPrimaryNavigation(page);
|
await expectPrimaryNavigation(page);
|
||||||
await expectNavigationVsCtaDifferentiation(page);
|
await expectNavigationVsCtaDifferentiation(page);
|
||||||
await expectCtaHierarchy(page, 'See the product model', 'Review the trust posture');
|
await expectCtaHierarchy(page, 'Request a working session', 'See the product model');
|
||||||
await expect(page.locator('[data-interaction="button"]').filter({ hasText: 'See the product model' }).first()).toBeVisible();
|
await expect(page.locator('[data-interaction="button"]').filter({ hasText: 'Request a working session' }).first()).toBeVisible();
|
||||||
await expect(page.locator('[data-badge-tone]').first()).toBeVisible();
|
await expect(page.locator('[data-badge-tone]').first()).toBeVisible();
|
||||||
|
|
||||||
await visitPage(page, '/trust');
|
await visitPage(page, '/trust');
|
||||||
|
|||||||
@ -1432,6 +1432,37 @@ ### Run Log Inspect Affordance Alignment
|
|||||||
- **Why this boundary is right**: One resource, one anti-pattern, one fix. Expanding scope to "all run-log surfaces" or "all operation views" would turn a quick correction into a rollout spec and delay the most visible improvement.
|
- **Why this boundary is right**: One resource, one anti-pattern, one fix. Expanding scope to "all run-log surfaces" or "all operation views" would turn a quick correction into a rollout spec and delay the most visible improvement.
|
||||||
- **Priority**: medium
|
- **Priority**: medium
|
||||||
|
|
||||||
|
### Selected-Record Monitoring Host Alignment
|
||||||
|
- **Type**: workflow compression
|
||||||
|
- **Source**: enterprise UX review 2026-04-19 — Finding Exceptions Queue and Audit Log selected-record monitoring surfaces
|
||||||
|
- **Problem**: Specs 193 and 198 correctly established the semantics for `queue_workbench` and `selected_record_monitoring`, but they intentionally stopped at action hierarchy and page-state transport. The remaining gap is the active review host shape. `FindingExceptionsQueue` and `AuditLog` both preserve selection via query parameter and `inspect_action`, yet the current host experience still sits awkwardly between a list page, an inline expanded detail block, and a modal-style inspect affordance. That is technically valid, but it does not read as an enterprise-grade workbench. Operators get shareable URLs and refresh-safe state, but not a clearly expressed review mode with one deliberate place for context, next step, and close/return behavior.
|
||||||
|
- **Why it matters**: Enterprise operators working through queues or history need one of two unmistakable behaviors: either remain in a stable workbench where list context and active record review coexist intentionally, or leave the list for a canonical detail route with explicit return continuity. The current halfway pattern preserves state better than a slide-over, but it still weakens scanability, makes the active review lane feel bolted on, and leaves too much room for future local variations across monitoring surfaces.
|
||||||
|
- **Proposed direction**:
|
||||||
|
- Define two allowed enterprise host models for `selected_record_monitoring` surfaces:
|
||||||
|
- **Split-pane workbench**: the list, filters, and queue context remain continuously visible while the selected record occupies a dedicated persistent review pane
|
||||||
|
- **Canonical detail route**: the list remains list-first, and inspect opens a standalone detail page with explicit back/return continuity and optional preserved filter state
|
||||||
|
- Allow **quick-peek overlays** only as optional preview affordances, never as the sole canonical inspect or deep-link contract
|
||||||
|
- Add host-selection criteria so surfaces choose deliberately between split-pane and canonical detail route instead of drifting into full-page inline "focused lane above the table" patterns
|
||||||
|
- Pilot the rule on `FindingExceptionsQueue` and `AuditLog`, keeping current query-param addressability while upgrading the actual review host ergonomics
|
||||||
|
- Codify close/back/new-tab/reload semantics and invalid-selection fallback per host model so URL durability and review ergonomics are aligned rather than accidental
|
||||||
|
- **Smallest enterprise-capable version**: Limit the first slice to the two already-real `selected_record_monitoring` surfaces in Monitoring: `FindingExceptionsQueue` and `AuditLog`. The spec should choose and implement one clear host model per surface, document the decision rule, and stop there. No generic pane framework, no broad monitoring IA rewrite, and no rollout to unrelated list/detail pages.
|
||||||
|
- **Explicit non-goals**: Not a full Monitoring redesign, not a new modal framework, not a replacement for Spec 198 page-state semantics, not a generic shared-detail engine, not a broad action-surface retrofit outside `selected_record_monitoring`, and not a rewrite of finding or audit domain truth.
|
||||||
|
- **Permanent complexity imported**: One small host-pattern contract for `selected_record_monitoring`, explicit decision criteria for split-pane vs canonical detail route, focused regression coverage for two surfaces, and a small amount of new vocabulary around host model choice. No new persisted truth, no new provider/runtime architecture, and no new generalized UI platform are justified.
|
||||||
|
- **Why now**: The product already has at least two real consumers of the same selected-record monitoring pattern, and one of them is visible enough that the UX gap is now obvious. Leaving the gap open means future monitoring surfaces will keep re-solving the same question locally, and the currently correct page-state work will continue to feel less enterprise than it should.
|
||||||
|
- **Why not local**: A one-off polish pass on `FindingExceptionsQueue` would not answer what `AuditLog` should do, nor would it define when a selected-record monitoring surface should stay list-first versus move to canonical detail. The missing artifact is not just layout polish; it is the host decision rule for a small but real surface family.
|
||||||
|
- **Approval class**: Workflow Compression
|
||||||
|
- **Red flags triggered**: One red flag: this introduces a cross-surface host-model rule. The scope must stay bounded to the already-real `selected_record_monitoring` family and must not grow into a general monitoring-shell framework.
|
||||||
|
- **Score**: Nutzen: 2 | Dringlichkeit: 1 | Scope: 2 | Komplexität: 1 | Produktnähe: 2 | Wiederverwendung: 2 | **Gesamt: 10/12**
|
||||||
|
- **Decision**: approve
|
||||||
|
- **Acceptance points**:
|
||||||
|
- Each `selected_record_monitoring` surface declares one deliberate host model instead of expressing active review as an ad hoc inline expansion
|
||||||
|
- Deep links, refresh, and invalid-selection fallback remain stable after the host upgrade
|
||||||
|
- Operators can keep queue/history context while reviewing a record, or return to it predictably when the chosen host model uses a dedicated detail route
|
||||||
|
- Close, back, related drilldowns, and "open in full detail" semantics become consistent enough that selected-record monitoring feels like a product pattern instead of a local layout choice
|
||||||
|
- **Dependencies**: Spec 193 (`monitoring-action-hierarchy`), Spec 198 (`monitoring-page-state`), and the existing Monitoring page-state guards already in the repo
|
||||||
|
- **Related specs / candidates**: Spec 197 (`shared-detail-contract`), Action Surface Contract v1.1, Admin Visual Language Canon, Record Page Header Discipline & Contextual Navigation (for return semantics only; not a direct dependency)
|
||||||
|
- **Priority**: medium
|
||||||
|
|
||||||
### Admin Visual Language Canon — First-Party UI Convention Codification and Drift Prevention
|
### Admin Visual Language Canon — First-Party UI Convention Codification and Drift Prevention
|
||||||
- **Type**: foundation
|
- **Type**: foundation
|
||||||
- **Source**: admin UI consistency analysis 2026-03-17
|
- **Source**: admin UI consistency analysis 2026-03-17
|
||||||
|
|||||||
@ -6,10 +6,10 @@
|
|||||||
"node": ">=20.0.0"
|
"node": ">=20.0.0"
|
||||||
},
|
},
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "bash ./scripts/dev-workspace",
|
"dev": "corepack pnpm dev:platform && (corepack pnpm --filter @tenantatlas/platform dev &) && corepack pnpm dev:website",
|
||||||
"dev:platform": "bash ./scripts/dev-platform",
|
"dev:platform": "./scripts/platform-sail up -d",
|
||||||
"dev:website": "WEBSITE_PORT=${WEBSITE_PORT:-4321} corepack pnpm --filter @tenantatlas/website dev",
|
"dev:website": "WEBSITE_PORT=${WEBSITE_PORT:-4321} corepack pnpm --filter @tenantatlas/website dev",
|
||||||
"build:platform": "./scripts/platform-sail pnpm build",
|
"build:platform": "corepack pnpm --filter @tenantatlas/platform build",
|
||||||
"build:website": "corepack pnpm --filter @tenantatlas/website build"
|
"build:website": "corepack pnpm --filter @tenantatlas/website build"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
35
specs/216-homepage-structure/checklists/requirements.md
Normal file
35
specs/216-homepage-structure/checklists/requirements.md
Normal file
@ -0,0 +1,35 @@
|
|||||||
|
# Specification Quality Checklist: Website Homepage Structure & Section Model
|
||||||
|
|
||||||
|
**Purpose**: Validate specification completeness and quality before proceeding to planning
|
||||||
|
**Created**: 2026-04-19
|
||||||
|
**Feature**: [spec.md](../spec.md)
|
||||||
|
|
||||||
|
## Content Quality
|
||||||
|
|
||||||
|
- [x] No implementation details (languages, frameworks, APIs)
|
||||||
|
- [x] Focused on user value and business needs
|
||||||
|
- [x] Written for non-technical stakeholders
|
||||||
|
- [x] All mandatory sections completed
|
||||||
|
|
||||||
|
## Requirement Completeness
|
||||||
|
|
||||||
|
- [x] No [NEEDS CLARIFICATION] markers remain
|
||||||
|
- [x] Requirements are testable and unambiguous
|
||||||
|
- [x] Success criteria are measurable
|
||||||
|
- [x] Success criteria are technology-agnostic (no implementation details)
|
||||||
|
- [x] All acceptance scenarios are defined
|
||||||
|
- [x] Edge cases are identified
|
||||||
|
- [x] Scope is clearly bounded
|
||||||
|
- [x] Dependencies and assumptions identified
|
||||||
|
|
||||||
|
## Feature Readiness
|
||||||
|
|
||||||
|
- [x] All functional requirements have clear acceptance criteria
|
||||||
|
- [x] User scenarios cover primary flows
|
||||||
|
- [x] Feature meets measurable outcomes defined in Success Criteria
|
||||||
|
- [x] No implementation details leak into specification
|
||||||
|
|
||||||
|
## Notes
|
||||||
|
|
||||||
|
- Validation pass 1 completed successfully.
|
||||||
|
- No clarification questions were required because scope, required sections, routing, and exclusions were explicitly defined in the input.
|
||||||
@ -0,0 +1,128 @@
|
|||||||
|
openapi: 3.1.0
|
||||||
|
info:
|
||||||
|
title: TenantAtlas Homepage Surface Contract
|
||||||
|
version: 0.1.0
|
||||||
|
summary: Structural contract for the `apps/website` homepage in Spec 216.
|
||||||
|
description: >-
|
||||||
|
This contract defines the public HTML routes that participate in the
|
||||||
|
homepage journey for Spec 216. The homepage remains a static Astro surface
|
||||||
|
and must route visitors into Product, Trust, Changelog, and Contact while
|
||||||
|
satisfying the required homepage section model.
|
||||||
|
servers:
|
||||||
|
- url: http://localhost:{port}
|
||||||
|
description: Local Astro development or preview server
|
||||||
|
variables:
|
||||||
|
port:
|
||||||
|
default: '4321'
|
||||||
|
tags:
|
||||||
|
- name: Homepage Journey
|
||||||
|
description: Public HTML routes used by the homepage structure contract
|
||||||
|
paths:
|
||||||
|
/:
|
||||||
|
get:
|
||||||
|
tags: [Homepage Journey]
|
||||||
|
operationId: getHomepage
|
||||||
|
summary: Homepage
|
||||||
|
description: >-
|
||||||
|
Product-near homepage that positions the product, explains outcomes,
|
||||||
|
groups the product model, shows bounded trust and dated progress
|
||||||
|
signals, and ends in one clear next-step CTA.
|
||||||
|
x-tenantatlas-homepage-structure:
|
||||||
|
requiredBlocks:
|
||||||
|
- header
|
||||||
|
- hero
|
||||||
|
- outcome
|
||||||
|
- capability
|
||||||
|
- trust
|
||||||
|
- progress
|
||||||
|
- cta
|
||||||
|
- footer
|
||||||
|
orderedNarrative:
|
||||||
|
- hero
|
||||||
|
- outcome
|
||||||
|
- capability
|
||||||
|
- trust
|
||||||
|
- progress
|
||||||
|
- cta
|
||||||
|
primaryCtaTargets:
|
||||||
|
- /contact
|
||||||
|
- /demo
|
||||||
|
secondaryCtaTargets:
|
||||||
|
- /product
|
||||||
|
- /trust
|
||||||
|
- /changelog
|
||||||
|
onwardRoutes:
|
||||||
|
- /product
|
||||||
|
- /trust
|
||||||
|
- /changelog
|
||||||
|
- /contact
|
||||||
|
forbiddenPatterns:
|
||||||
|
- template-first-saas
|
||||||
|
- abstract-only-homepage
|
||||||
|
- feature-wall
|
||||||
|
- fake-trust
|
||||||
|
- multi-primary-cta
|
||||||
|
responses:
|
||||||
|
'200':
|
||||||
|
description: Homepage HTML
|
||||||
|
content:
|
||||||
|
text/html:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/HtmlDocument'
|
||||||
|
/product:
|
||||||
|
get:
|
||||||
|
tags: [Homepage Journey]
|
||||||
|
operationId: getHomepageProductTarget
|
||||||
|
summary: Product target route
|
||||||
|
description: Deeper product-model route linked from homepage hero or capability sections.
|
||||||
|
responses:
|
||||||
|
'200':
|
||||||
|
description: Product page HTML
|
||||||
|
content:
|
||||||
|
text/html:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/HtmlDocument'
|
||||||
|
/trust:
|
||||||
|
get:
|
||||||
|
tags: [Homepage Journey]
|
||||||
|
operationId: getHomepageTrustTarget
|
||||||
|
summary: Trust target route
|
||||||
|
description: Bounded trust and credibility route linked from homepage trust cues and CTA paths.
|
||||||
|
responses:
|
||||||
|
'200':
|
||||||
|
description: Trust page HTML
|
||||||
|
content:
|
||||||
|
text/html:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/HtmlDocument'
|
||||||
|
/changelog:
|
||||||
|
get:
|
||||||
|
tags: [Homepage Journey]
|
||||||
|
operationId: getHomepageChangelogTarget
|
||||||
|
summary: Changelog target route
|
||||||
|
description: Dated progress route linked from the homepage progress section.
|
||||||
|
responses:
|
||||||
|
'200':
|
||||||
|
description: Changelog page HTML
|
||||||
|
content:
|
||||||
|
text/html:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/HtmlDocument'
|
||||||
|
/contact:
|
||||||
|
get:
|
||||||
|
tags: [Homepage Journey]
|
||||||
|
operationId: getHomepageContactTarget
|
||||||
|
summary: Contact target route
|
||||||
|
description: Primary conversion route used by the homepage until a distinct public `/demo` route exists.
|
||||||
|
responses:
|
||||||
|
'200':
|
||||||
|
description: Contact page HTML
|
||||||
|
content:
|
||||||
|
text/html:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/HtmlDocument'
|
||||||
|
components:
|
||||||
|
schemas:
|
||||||
|
HtmlDocument:
|
||||||
|
type: string
|
||||||
|
description: Server-rendered static HTML document
|
||||||
138
specs/216-homepage-structure/data-model.md
Normal file
138
specs/216-homepage-structure/data-model.md
Normal file
@ -0,0 +1,138 @@
|
|||||||
|
# Data Model: Website Homepage Structure & Section Model
|
||||||
|
|
||||||
|
This feature introduces no database schema and no platform-side persistence. The model is file- and route-based inside `apps/website` and defines how the homepage composes required narrative blocks, proof signals, and route targets.
|
||||||
|
|
||||||
|
## 1. Homepage Composition
|
||||||
|
|
||||||
|
Represents the public homepage at `/` as one ordered narrative contract.
|
||||||
|
|
||||||
|
| Field | Type | Description | Rules |
|
||||||
|
|---|---|---|---|
|
||||||
|
| `path` | static string | Canonical homepage route | Must remain `/` |
|
||||||
|
| `seo` | `PageSeo` | Title, description, canonical path metadata | Required |
|
||||||
|
| `requiredBlocks` | ordered list | Functional homepage blocks | Must include header, hero, outcome, capability, trust, progress, CTA, footer |
|
||||||
|
| `primaryConversionTarget` | route | Dominant next-step route | Defaults to `/contact` until a distinct public `/demo` route exists |
|
||||||
|
| `secondaryRoutes` | route list | Deeper exploration targets | Limited to `/product`, `/trust`, `/changelog` |
|
||||||
|
| `optionalBlocks` | list | Supporting context such as ecosystem, FAQ, or use-case spotlight | May appear only if they do not displace required blocks |
|
||||||
|
|
||||||
|
**Relationships**:
|
||||||
|
- Composed from homepage content exports in `src/content/pages/home.ts`
|
||||||
|
- Uses canonical route/navigation truth from `src/lib/site.ts`
|
||||||
|
- May derive proof content from `src/content/pages/trust.ts` and the changelog collection
|
||||||
|
|
||||||
|
**Validation rules**:
|
||||||
|
- All required blocks must be present exactly once as functions, even if visually combined
|
||||||
|
- Trust and progress must appear before the lower-page CTA block
|
||||||
|
- Optional blocks must not replace or bury required blocks
|
||||||
|
|
||||||
|
## 2. Hero Block
|
||||||
|
|
||||||
|
Represents the first explanatory block on the homepage.
|
||||||
|
|
||||||
|
| Field | Type | Description | Rules |
|
||||||
|
|---|---|---|---|
|
||||||
|
| `eyebrow` | string | Short orienting label | Required |
|
||||||
|
| `title` | string | Positioning headline | Required |
|
||||||
|
| `description` | string | Supporting problem and product framing | Required |
|
||||||
|
| `primaryCta` | `CtaLink` | Main action from the hero | Exactly one primary CTA |
|
||||||
|
| `secondaryCta` | `CtaLink` | Deeper exploration route | At least one secondary deepening CTA on the homepage |
|
||||||
|
| `highlights` | string list | Short proof or focus points | Optional |
|
||||||
|
| `productVisual` | logical asset reference | Product-near screenshot or UI-adjacent visual | Must be product-near, not abstract-only |
|
||||||
|
| `trustSubclaims` | string list | Narrow supporting trust cues | Optional and only allowed when supportable by `/trust` |
|
||||||
|
|
||||||
|
**Validation rules**:
|
||||||
|
- Primary and secondary CTAs must not compete as equal primary actions
|
||||||
|
- Visual must communicate product truth or credible UI structure
|
||||||
|
- Trust subclaims cannot overstate compliance or hosting claims
|
||||||
|
|
||||||
|
## 3. Outcome Section
|
||||||
|
|
||||||
|
Explains why the product matters in buyer-oriented language.
|
||||||
|
|
||||||
|
| Field | Type | Description | Rules |
|
||||||
|
|---|---|---|---|
|
||||||
|
| `title` | string | Outcome framing headline | Required |
|
||||||
|
| `description` | string | Problem relevance and operational impact | Required |
|
||||||
|
| `outcomes` | item list | Buyer-visible improvements, frictions removed, or use-case outcomes | Required |
|
||||||
|
| `audienceBias` | label or note | Primary audience signal such as MSP, governance, or enterprise operations | Optional |
|
||||||
|
|
||||||
|
**Validation rules**:
|
||||||
|
- Must talk in outcomes, operational improvements, or frictions reduced
|
||||||
|
- Must not devolve into feature naming or route explanations
|
||||||
|
|
||||||
|
## 4. Capability Cluster
|
||||||
|
|
||||||
|
Represents grouped product-model coverage on the homepage.
|
||||||
|
|
||||||
|
| Field | Type | Description | Rules |
|
||||||
|
|---|---|---|---|
|
||||||
|
| `title` | string | Cluster title | Required |
|
||||||
|
| `description` | string | Short explanation of the cluster | Required |
|
||||||
|
| `capabilities` | string list | Covered product areas | Must express grouped capabilities, not one-card-per-feature sprawl |
|
||||||
|
| `href` | route | Deeper route for details | Normally `/product` |
|
||||||
|
| `meta` | string | Compact supporting label | Optional |
|
||||||
|
|
||||||
|
**Required coverage across all clusters**:
|
||||||
|
- Backup
|
||||||
|
- Restore
|
||||||
|
- Version history
|
||||||
|
- Auditability and evidence
|
||||||
|
- Inventory and drift visibility
|
||||||
|
- Governance, findings, exceptions, or review work
|
||||||
|
|
||||||
|
**Validation rules**:
|
||||||
|
- Clusters must create a readable hierarchy
|
||||||
|
- The homepage must explain the product model without acting as full documentation
|
||||||
|
|
||||||
|
## 5. Trust Signal Group
|
||||||
|
|
||||||
|
Represents the explicit homepage trust block.
|
||||||
|
|
||||||
|
| Field | Type | Description | Rules |
|
||||||
|
|---|---|---|---|
|
||||||
|
| `title` | string | Trust block heading | Required |
|
||||||
|
| `description` | string | Why trust matters on the homepage | Required |
|
||||||
|
| `signals` | `TrustPrincipleContent[]` or equivalent | Bounded trust principles or proof cards | Required |
|
||||||
|
| `supportRoute` | route | Deeper trust destination | Must be `/trust` |
|
||||||
|
|
||||||
|
**Validation rules**:
|
||||||
|
- Signals must be substantiated and bounded
|
||||||
|
- No invented badges, fake certifications, or sweeping compliance promises
|
||||||
|
- Must appear before the final CTA block
|
||||||
|
|
||||||
|
## 6. Progress Teaser
|
||||||
|
|
||||||
|
Represents visible product movement on the homepage.
|
||||||
|
|
||||||
|
| Field | Type | Description | Rules |
|
||||||
|
|---|---|---|---|
|
||||||
|
| `entries` | changelog entry list | Recent dated product updates | Derived from published changelog collection entries |
|
||||||
|
| `title` | string | Progress block title | Required |
|
||||||
|
| `description` | string | Explains why progress is shown | Required |
|
||||||
|
| `cta` | `CtaLink` | Route to full changelog | Must target `/changelog` |
|
||||||
|
|
||||||
|
**Validation rules**:
|
||||||
|
- Entries must be dated and publicly visible
|
||||||
|
- The block must feel factual, not like a marketing news feed
|
||||||
|
|
||||||
|
## 7. CTA Transition Block
|
||||||
|
|
||||||
|
Represents the lower-page call to action after clarity, trust, and progress have been established.
|
||||||
|
|
||||||
|
| Field | Type | Description | Rules |
|
||||||
|
|---|---|---|---|
|
||||||
|
| `title` | string | Final CTA heading | Required |
|
||||||
|
| `description` | string | What the next step is and why | Required |
|
||||||
|
| `primaryCta` | `CtaLink` | Dominant next-step action | Must target `/contact` or `/demo`; defaults to `/contact` today |
|
||||||
|
| `secondaryCta` | `CtaLink` | Optional deeper route | Must not compete as another primary action |
|
||||||
|
|
||||||
|
**Validation rules**:
|
||||||
|
- Must not introduce multiple equally strong primary conversion actions
|
||||||
|
- Must follow trust and progress blocks in the homepage order
|
||||||
|
|
||||||
|
## Derived State and Availability
|
||||||
|
|
||||||
|
- No independent state machine is added by this feature.
|
||||||
|
- Changelog teaser visibility is derived from the published changelog collection (`draft: false`).
|
||||||
|
- Optional route visibility remains derived from `getSurfaceAvailability()` and canonical route definitions in `src/lib/site.ts`.
|
||||||
|
- If a real screenshot is unavailable, hero visual readiness falls back to a credible UI-near visual, but the hero still must satisfy the product-near rule.
|
||||||
256
specs/216-homepage-structure/plan.md
Normal file
256
specs/216-homepage-structure/plan.md
Normal file
@ -0,0 +1,256 @@
|
|||||||
|
# Implementation Plan: Website Homepage Structure & Section Model
|
||||||
|
|
||||||
|
**Branch**: `216-homepage-structure` | **Date**: 2026-04-19 | **Spec**: `specs/216-homepage-structure/spec.md`
|
||||||
|
**Input**: Feature specification from `specs/216-homepage-structure/spec.md`
|
||||||
|
|
||||||
|
**Note**: This template is filled in by the `/speckit.plan` command. See `.specify/scripts/` for helper scripts.
|
||||||
|
|
||||||
|
## Summary
|
||||||
|
|
||||||
|
- Rework the `apps/website` homepage into the explicit section flow required by Spec 216: hero, outcome framing, capability model, trust, progress, CTA, while preserving existing header and footer shells.
|
||||||
|
- Implement the change by extending the current Astro content-driven homepage model (`src/content/pages/home.ts`) and existing section primitives instead of adding a new section registry or CMS-like composition layer.
|
||||||
|
- Reuse existing Trust and Changelog truth for homepage proof signals, and validate the result with the current website build proof plus focused Playwright smoke coverage.
|
||||||
|
|
||||||
|
## Technical Context
|
||||||
|
|
||||||
|
**Language/Version**: Astro 6.0.0 templates + TypeScript 5.9.x
|
||||||
|
**Primary Dependencies**: Astro 6, Tailwind CSS v4, local Astro layout/section primitives, Astro content collections, Playwright browser smoke tests
|
||||||
|
**Storage**: Static filesystem content, Astro content collections, and assets under `apps/website/src` and `apps/website/public`; no database
|
||||||
|
**Testing**: `corepack pnpm build:website` plus Playwright smoke coverage in `apps/website/tests/smoke`
|
||||||
|
**Validation Lanes**: fast-feedback
|
||||||
|
**Target Platform**: Static public website for modern desktop and mobile browsers
|
||||||
|
**Project Type**: Web application in a monorepo (`apps/platform` plus `apps/website`)
|
||||||
|
**Performance Goals**: Preserve static HTML output for homepage and linked public routes, keep reading/navigation flows usable without required client-side framework hydration, and keep the primary homepage narrative legible on desktop and mobile
|
||||||
|
**Constraints**: Stay strictly inside `apps/website`; preserve canonical core routes (`/`, `/product`, `/trust`, `/changelog`, `/contact`); keep one dominant primary CTA; hide unsubstantial optional routes; avoid unsupported trust claims and platform runtime coupling
|
||||||
|
**Scale/Scope**: One homepage route, eight required functional blocks, four canonical onward routes, existing shared section components, and a small extension to the current browser smoke suite
|
||||||
|
|
||||||
|
## UI / Surface Guardrail Plan
|
||||||
|
|
||||||
|
- **Guardrail scope**: no operator-facing surface change
|
||||||
|
- **Native vs custom classification summary**: N/A - public Astro website surface only
|
||||||
|
- **Shared-family relevance**: none
|
||||||
|
- **State layers in scope**: page
|
||||||
|
- **Handling modes by drift class or surface**: N/A
|
||||||
|
- **Repository-signal treatment**: report-only
|
||||||
|
- **Special surface test profiles**: N/A
|
||||||
|
- **Required tests or manual smoke**: manual-smoke plus homepage-focused browser smoke coverage
|
||||||
|
- **Exception path and spread control**: none
|
||||||
|
- **Active feature PR close-out entry**: Smoke Coverage
|
||||||
|
|
||||||
|
## Constitution Check
|
||||||
|
|
||||||
|
*GATE: Must pass before Phase 0 research. Re-check after Phase 1 design.*
|
||||||
|
|
||||||
|
- Inventory-first / snapshots / Graph contract / deterministic capabilities / RBAC-UX / Filament guardrails: N/A for this feature because all work stays on the public Astro website and changes no `/admin`, `/admin/t/{tenant}/...`, or `/system` runtime surfaces.
|
||||||
|
- Read/write separation: Pass. The homepage remains a static public read surface. No writes, remote calls, queued work, or contact submission backend are introduced in this feature.
|
||||||
|
- Workspace and tenant isolation: Pass. The homepage stays runtime-independent from `apps/platform`, with no shared auth, session, tenant data, or tenant-scoped route behavior.
|
||||||
|
- Data minimization: Pass. The feature only rearranges and extends public content, navigation, and collection-backed teasers inside `apps/website`.
|
||||||
|
- Test governance: Pass. Proof remains in `fast-feedback` through static build output and focused browser smoke coverage, with no database, membership, provider, or heavy-suite defaults.
|
||||||
|
- Proportionality / no premature abstraction: Pass. The implementation extends the existing `home.ts` content module and current section primitives instead of introducing a generic section registry, CMS abstraction, or separate homepage framework.
|
||||||
|
- Persisted truth / new state: Pass. No database artifacts, queues, or independent state machines are added. Homepage progress visibility stays derived from the existing changelog collection.
|
||||||
|
- UI semantics / few layers: Pass. The homepage contract maps directly to existing content objects and section components, with thin additions for missing blocks rather than a presentation meta-layer.
|
||||||
|
|
||||||
|
Status: ✅ No constitution violations identified before research.
|
||||||
|
|
||||||
|
## Test Governance Check
|
||||||
|
|
||||||
|
- **Test purpose / classification by changed surface**: Browser
|
||||||
|
- **Affected validation lanes**: fast-feedback
|
||||||
|
- **Why this lane mix is the narrowest sufficient proof**: The feature changes only public HTML composition, route discoverability, CTA hierarchy, and responsive section visibility. Browser smoke coverage is the narrowest layer that can prove those concerns without introducing backend or heavy end-to-end cost.
|
||||||
|
- **Narrowest proving command(s)**: `corepack pnpm build:website` and `cd apps/website && corepack pnpm exec playwright test`
|
||||||
|
- **Fixture / helper / factory / seed / context cost risks**: none; public routes do not require database, auth, provider, workspace, or tenant setup
|
||||||
|
- **Expensive defaults or shared helper growth introduced?**: no; test updates stay inside the existing `apps/website/tests/smoke` harness
|
||||||
|
- **Heavy-family additions, promotions, or visibility changes**: none
|
||||||
|
- **Surface-class relief / special coverage rule**: N/A
|
||||||
|
- **Closing validation and reviewer handoff**: Re-run the website build and smoke suite after homepage/content changes. Reviewers should verify required homepage block order, clear CTA hierarchy, reachability of `/product`, `/trust`, `/changelog`, and `/contact`, and continued mobile readability without optional-route leakage.
|
||||||
|
- **Budget / baseline / trend follow-up**: none beyond the existing lightweight website smoke runtime
|
||||||
|
- **Review-stop questions**: Does proof stay browser-focused and route-focused? Did the feature accidentally add shared helper cost or client-side runtime coupling? Are optional unpublished routes still hidden from homepage prominence?
|
||||||
|
- **Escalation path**: document-in-feature
|
||||||
|
- **Active feature PR close-out entry**: Smoke Coverage
|
||||||
|
- **Why no dedicated follow-up spec is needed**: Validation remains feature-local to the homepage and the existing website smoke harness. A separate test-governance spec is only needed if the public site later grows interactive flows or broader browser coverage families.
|
||||||
|
|
||||||
|
## Project Structure
|
||||||
|
|
||||||
|
### Documentation (this feature)
|
||||||
|
|
||||||
|
```text
|
||||||
|
specs/216-homepage-structure/
|
||||||
|
├── plan.md
|
||||||
|
├── research.md
|
||||||
|
├── data-model.md
|
||||||
|
├── quickstart.md
|
||||||
|
├── contracts/
|
||||||
|
│ └── homepage-surface.openapi.yaml
|
||||||
|
└── tasks.md
|
||||||
|
```
|
||||||
|
|
||||||
|
### Source Code (repository root)
|
||||||
|
|
||||||
|
```text
|
||||||
|
apps/website/
|
||||||
|
├── package.json
|
||||||
|
├── src/
|
||||||
|
│ ├── content.config.ts
|
||||||
|
│ ├── content/
|
||||||
|
│ │ ├── changelog/
|
||||||
|
│ │ └── pages/
|
||||||
|
│ │ ├── changelog.ts
|
||||||
|
│ │ ├── home.ts
|
||||||
|
│ │ ├── product.ts
|
||||||
|
│ │ └── trust.ts
|
||||||
|
│ ├── components/
|
||||||
|
│ │ ├── content/
|
||||||
|
│ │ ├── layout/
|
||||||
|
│ │ ├── primitives/
|
||||||
|
│ │ └── sections/
|
||||||
|
│ │ ├── CTASection.astro
|
||||||
|
│ │ ├── FeatureGrid.astro
|
||||||
|
│ │ ├── LogoStrip.astro
|
||||||
|
│ │ ├── PageHero.astro
|
||||||
|
│ │ └── TrustGrid.astro
|
||||||
|
│ ├── lib/
|
||||||
|
│ │ └── site.ts
|
||||||
|
│ ├── pages/
|
||||||
|
│ │ ├── changelog.astro
|
||||||
|
│ │ ├── contact.astro
|
||||||
|
│ │ ├── index.astro
|
||||||
|
│ │ ├── product.astro
|
||||||
|
│ │ └── trust.astro
|
||||||
|
│ └── types/
|
||||||
|
│ └── site.ts
|
||||||
|
└── tests/
|
||||||
|
└── smoke/
|
||||||
|
├── changelog-core-ia.spec.ts
|
||||||
|
├── home-product.spec.ts
|
||||||
|
└── smoke-helpers.ts
|
||||||
|
```
|
||||||
|
|
||||||
|
**Structure Decision**: Keep the feature completely inside `apps/website`, using the existing Astro page/content/component split. Extend `src/content/pages/home.ts`, reuse current section components where possible, and add only the smallest missing homepage composition pieces required by Spec 216.
|
||||||
|
|
||||||
|
## Complexity Tracking
|
||||||
|
|
||||||
|
None.
|
||||||
|
|
||||||
|
## Proportionality Review
|
||||||
|
|
||||||
|
- **Current operator problem**: The homepage currently explains route jobs and product seriousness only partially, which makes the public first-read weaker than the product truth already available elsewhere on the site.
|
||||||
|
- **Existing structure is insufficient because**: The current homepage composition lacks an explicit outcome section, uses the capability section for information architecture rather than product model explanation, keeps trust too implicit, and surfaces progress only as a CTA target instead of a real signal.
|
||||||
|
- **Narrowest correct implementation**: Recompose the existing homepage route with one explicit outcome block, grouped capability clusters, a dedicated trust block, and a real changelog teaser while reusing current content modules, section primitives, and collection truth.
|
||||||
|
- **Ownership cost created**: Ongoing maintenance of a slightly richer `home.ts` content shape, homepage section ordering, and a few additional browser assertions.
|
||||||
|
- **Alternative intentionally rejected**: A generic section registry or CMS-like homepage builder was rejected because only one homepage route needs this contract now, and the existing Astro content model is already sufficient.
|
||||||
|
- **Release truth**: Current-release truth
|
||||||
|
|
||||||
|
## Phase 0 — Outline & Research (complete)
|
||||||
|
|
||||||
|
- Output: `specs/216-homepage-structure/research.md`
|
||||||
|
- Key decisions captured:
|
||||||
|
- Keep the homepage local to the static Astro website and preserve runtime separation from `apps/platform`.
|
||||||
|
- Extend the current content-driven homepage model instead of adding a new section framework.
|
||||||
|
- Reorder the homepage into the explicit Spec 216 narrative flow while preserving optional supporting context only where it helps clarity.
|
||||||
|
- Reuse existing Trust page truth and changelog collection data for homepage proof signals.
|
||||||
|
- Validate via the current website build proof plus focused Playwright smoke coverage.
|
||||||
|
|
||||||
|
## Phase 1 — Design & Contracts (complete)
|
||||||
|
|
||||||
|
### Data model
|
||||||
|
|
||||||
|
- Output: `specs/216-homepage-structure/data-model.md`
|
||||||
|
- Model remains file- and route-based. No database schema changes are required.
|
||||||
|
|
||||||
|
### Public homepage contract
|
||||||
|
|
||||||
|
- Output: `specs/216-homepage-structure/contracts/homepage-surface.openapi.yaml`
|
||||||
|
- Contract captures the homepage route plus the required onward routes (`/product`, `/trust`, `/changelog`, `/contact`) and the structural rules the homepage must satisfy.
|
||||||
|
|
||||||
|
### Quickstart
|
||||||
|
|
||||||
|
- Output: `specs/216-homepage-structure/quickstart.md`
|
||||||
|
- Quickstart covers local development, homepage verification points, build proof, and smoke-test execution.
|
||||||
|
|
||||||
|
### Agent context update
|
||||||
|
|
||||||
|
- Completed via `.specify/scripts/bash/update-agent-context.sh copilot` after plan artifacts were generated.
|
||||||
|
|
||||||
|
### Constitution re-check (post-design)
|
||||||
|
|
||||||
|
- ✅ The plan remains website-only and static, with no platform-runtime coupling.
|
||||||
|
- ✅ No new persistence, state machines, background operations, or auth flows are introduced.
|
||||||
|
- ✅ The chosen shape reuses existing Astro content modules and components instead of adding speculative abstraction.
|
||||||
|
- ✅ Validation remains cheap, local, and aligned with the current website smoke harness.
|
||||||
|
|
||||||
|
## Phase 2 — Implementation Plan (next)
|
||||||
|
|
||||||
|
### Story 1 (P1): Understand the Product Fast
|
||||||
|
|
||||||
|
- Update `apps/website/src/pages/index.astro` so the homepage first-read flow establishes the hero, product-near visual, outcome framing, and clear CTA hierarchy required for immediate product understanding.
|
||||||
|
- Preserve the current header, footer, and existing hero shell while leaving clean insertion points for the later capability, trust, and progress blocks owned by the following story slices.
|
||||||
|
- Extend the homepage hero so it supports a product-near visual and optional bounded trust subclaims when they are factually supportable and traceable to `/trust`.
|
||||||
|
- Keep any optional supporting context, such as the ecosystem strip, only if it reinforces clarity instead of displacing the first-read story.
|
||||||
|
- Tests / validation:
|
||||||
|
- Extend `apps/website/tests/smoke/home-product.spec.ts` to assert the required blocks are visible in order, that the hero exposes a product-near visual, and that CTA hierarchy remains singular and clear.
|
||||||
|
- Re-run `corepack pnpm build:website`.
|
||||||
|
|
||||||
|
### Story 2 (P2): Evaluate Product Model and Trust Without a Feature Wall
|
||||||
|
|
||||||
|
- Refactor the remaining mid-page homepage content in `apps/website/src/content/pages/home.ts` so it explains grouped product capabilities instead of homepage information architecture.
|
||||||
|
- Add the smallest missing homepage content objects needed for grouped capability clusters, explicit trust proof, and dated progress signaling.
|
||||||
|
- Reuse `trust.ts` and the `TrustGrid` section to place a dedicated homepage trust block ahead of the final CTA.
|
||||||
|
- Add a homepage progress teaser backed by the changelog collection or a thin helper derived from it, keeping the signal dated and routed to `/changelog`.
|
||||||
|
- Ensure homepage claims remain bounded and traceable to `/trust`, with no fake proof systems or unsupported badges.
|
||||||
|
- Tests / validation:
|
||||||
|
- Update existing homepage smoke assertions for revised headings, grouped capability clusters, explicit trust visibility, and dated progress signaling.
|
||||||
|
- Verify the hero still exposes exactly one primary CTA and one secondary deepening CTA.
|
||||||
|
|
||||||
|
### Story 3 (P3): Move Into the Right Next Route
|
||||||
|
|
||||||
|
- Align homepage CTA targets, secondary deep links, and optional-route suppression so the homepage routes into Product, Trust, Changelog, and Contact without competing conversion paths.
|
||||||
|
- Tighten header and footer discoverability, including footer/legal reachability, while keeping optional unpublished routes de-emphasized.
|
||||||
|
- Finalize homepage onward routing in `apps/website/src/pages/index.astro` while preserving mobile readability and narrow-screen discoverability.
|
||||||
|
- Tests / validation:
|
||||||
|
- Expand smoke coverage to assert visible reachability of `/product`, `/trust`, `/changelog`, and `/contact`, plus footer/legal discoverability on desktop and narrow mobile widths.
|
||||||
|
- Add explicit narrow-screen assertions so the homepage stays readable and does not leak optional unpublished routes.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Close-Out Notes
|
||||||
|
|
||||||
|
**Implementation completed**: All 19 tasks (T001–T019) across 6 phases.
|
||||||
|
|
||||||
|
### Build & Test Proof
|
||||||
|
|
||||||
|
- `corepack pnpm build:website`: ✅ 12 pages built, 0 errors
|
||||||
|
- `cd apps/website && corepack pnpm exec playwright test`: ✅ 20/20 tests pass
|
||||||
|
|
||||||
|
### Summary of Changes
|
||||||
|
|
||||||
|
**New files**:
|
||||||
|
- `apps/website/src/components/sections/OutcomeSection.astro` — outcome framing block
|
||||||
|
- `apps/website/src/components/sections/ProgressTeaser.astro` — dated progress signals
|
||||||
|
- `apps/website/src/components/sections/CapabilityGrid.astro` — grouped capability clusters
|
||||||
|
- `apps/website/src/lib/changelog.ts` — shared helper for recent changelog entries
|
||||||
|
- `apps/website/public/images/hero-product-visual.svg` — product-near hero visual
|
||||||
|
|
||||||
|
**Modified files**:
|
||||||
|
- `apps/website/src/types/site.ts` — 7 new interfaces for Spec 216 section model
|
||||||
|
- `apps/website/src/content/pages/home.ts` — full rewrite with Spec 216 content
|
||||||
|
- `apps/website/src/pages/index.astro` — full rewrite with new section flow
|
||||||
|
- `apps/website/src/components/sections/PageHero.astro` — added productVisual + trustSubclaims support
|
||||||
|
- `apps/website/src/components/primitives/Section.astro` — forward rest attributes (data-section)
|
||||||
|
- `apps/website/src/components/primitives/Card.astro` — forward rest attributes (data-hero-visual)
|
||||||
|
- `apps/website/tests/smoke/home-product.spec.ts` — rewritten for Spec 216 homepage structure
|
||||||
|
- `apps/website/tests/smoke/smoke-helpers.ts` — 4 new assertion helpers
|
||||||
|
- `apps/website/tests/smoke/visual-foundation-guardrails.spec.ts` — updated CTA labels
|
||||||
|
|
||||||
|
### Homepage Section Flow (Spec 216)
|
||||||
|
|
||||||
|
1. **PageHero** — eyebrow, headline, description, primary/secondary CTA, product visual, trust subclaims
|
||||||
|
2. **LogoStrip** — ecosystem fit (Microsoft Graph, Entra ID, Intune, Review workflows)
|
||||||
|
3. **OutcomeSection** — 3 outcome cards explaining why TenantAtlas matters
|
||||||
|
4. **CapabilityGrid** — 4 grouped capability clusters (Backup, Restore, Inventory, Governance)
|
||||||
|
5. **TrustGrid** — 3 trust signals with link to /trust
|
||||||
|
6. **ProgressTeaser** — dated changelog entries (or fallback link) with link to /changelog
|
||||||
|
7. **CTASection** — final CTA: "Request a working session" (primary) + "See the product model" (secondary)
|
||||||
|
|
||||||
|
### Removed Exports
|
||||||
|
|
||||||
|
- `homeMetrics`, `homePillars`, `homeProofBlocks` from `home.ts` (replaced by outcome/capability/trust/progress model)
|
||||||
66
specs/216-homepage-structure/quickstart.md
Normal file
66
specs/216-homepage-structure/quickstart.md
Normal file
@ -0,0 +1,66 @@
|
|||||||
|
# Quickstart: Website Homepage Structure & Section Model
|
||||||
|
|
||||||
|
## Goal
|
||||||
|
|
||||||
|
Verify that the homepage in `apps/website` follows the Spec 216 section contract and routes visitors clearly into Product, Trust, Changelog, and Contact.
|
||||||
|
|
||||||
|
## Prerequisites
|
||||||
|
|
||||||
|
- Node.js 20+
|
||||||
|
- Corepack enabled
|
||||||
|
- Repo dependencies installed with `corepack pnpm install`
|
||||||
|
|
||||||
|
## Run the website locally
|
||||||
|
|
||||||
|
From the repository root:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
corepack pnpm dev:website
|
||||||
|
```
|
||||||
|
|
||||||
|
Alternative, inside the website app:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd apps/website
|
||||||
|
corepack pnpm dev
|
||||||
|
```
|
||||||
|
|
||||||
|
Default local URL: `http://127.0.0.1:4321/`
|
||||||
|
|
||||||
|
## What to verify on the homepage
|
||||||
|
|
||||||
|
Check the homepage in this order:
|
||||||
|
|
||||||
|
1. Header and global navigation expose Product, Trust, Changelog, and Contact, with no prominent links to unsubstantial optional routes.
|
||||||
|
2. Hero shows one dominant primary CTA, one secondary deepening CTA, and a product-near visual.
|
||||||
|
3. Outcome framing explains why the product matters in buyer language rather than route or feature-admin language.
|
||||||
|
4. Capability section groups the product model instead of listing a flat feature wall.
|
||||||
|
5. Trust block appears before the final CTA and routes to `/trust`.
|
||||||
|
6. Progress block shows visible dated product movement and routes to `/changelog`.
|
||||||
|
7. Final CTA offers one clear next step, currently `/contact`.
|
||||||
|
8. Footer keeps Product, Trust, Changelog, Contact, Privacy, and Imprint reachable.
|
||||||
|
|
||||||
|
## Build proof
|
||||||
|
|
||||||
|
From the repository root:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
corepack pnpm build:website
|
||||||
|
```
|
||||||
|
|
||||||
|
## Browser smoke proof
|
||||||
|
|
||||||
|
Run the website smoke suite:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd apps/website
|
||||||
|
corepack pnpm exec playwright test
|
||||||
|
```
|
||||||
|
|
||||||
|
## Expected proof points
|
||||||
|
|
||||||
|
- Homepage required blocks are visible in the intended order.
|
||||||
|
- The hero CTA hierarchy remains clear and non-competing.
|
||||||
|
- `/product`, `/trust`, `/changelog`, and `/contact` are reachable from the homepage.
|
||||||
|
- Optional unpublished routes are not surfaced prominently.
|
||||||
|
- The homepage remains readable on desktop and mobile widths.
|
||||||
41
specs/216-homepage-structure/research.md
Normal file
41
specs/216-homepage-structure/research.md
Normal file
@ -0,0 +1,41 @@
|
|||||||
|
# Research: Website Homepage Structure & Section Model
|
||||||
|
|
||||||
|
## Decision 1: Keep the homepage implementation local to the static Astro website
|
||||||
|
|
||||||
|
- **Decision**: Continue treating the homepage as a static `apps/website` route composed from Astro content modules and section components, with no runtime dependency on `apps/platform`.
|
||||||
|
- **Rationale**: Spec 216 is explicitly website-only. The current website already runs as a standalone Astro app, and the required homepage improvements concern structure, sequencing, and public route discoverability rather than dynamic runtime behavior.
|
||||||
|
- **Alternatives considered**:
|
||||||
|
- Couple homepage composition to `apps/platform` or a shared API: rejected because the spec forbids platform obligations and the homepage needs no dynamic platform data.
|
||||||
|
- Introduce a CMS or page-builder layer first: rejected because a single homepage route does not justify that operational overhead.
|
||||||
|
|
||||||
|
## Decision 2: Extend the existing content-driven homepage model instead of adding a section registry
|
||||||
|
|
||||||
|
- **Decision**: Preserve `apps/website/src/content/pages/home.ts` as the homepage source module and extend it with the smallest missing content objects for outcome, capability, trust, and progress sections.
|
||||||
|
- **Rationale**: The current site already uses typed content exports and section components (`PageHero`, `FeatureGrid`, `CTASection`, `TrustGrid`, `LogoStrip`). This is the narrowest correct place to express homepage-specific structure without adding a polymorphic section framework.
|
||||||
|
- **Alternatives considered**:
|
||||||
|
- Build a generic section registry with discriminated unions and render dispatch: rejected as premature abstraction for one page.
|
||||||
|
- Hardcode all new copy and structure directly in `index.astro`: rejected because it would weaken the existing typed content pattern and make future homepage iteration harder.
|
||||||
|
|
||||||
|
## Decision 3: Recompose the homepage into an explicit narrative flow
|
||||||
|
|
||||||
|
- **Decision**: Implement the homepage in the following functional order: header, hero, outcome framing, capability model, trust, progress, CTA, footer. Optional supporting context stays secondary and may only appear if it reinforces clarity.
|
||||||
|
- **Rationale**: Exploration of the current homepage showed that the site already has hero, optional ecosystem context, and CTA pieces, but the middle narrative is misaligned: the current feature grid explains route jobs instead of product outcomes or capabilities, trust is too implicit, and progress is only a CTA target.
|
||||||
|
- **Alternatives considered**:
|
||||||
|
- Keep the current hero → ecosystem → route-jobs → proof → CTA sequence: rejected because it does not satisfy Spec 216’s required block responsibilities.
|
||||||
|
- Collapse trust or progress into the CTA block: rejected because the spec requires both to appear explicitly before the final CTA.
|
||||||
|
|
||||||
|
## Decision 4: Reuse existing Trust and Changelog truth for homepage proof blocks
|
||||||
|
|
||||||
|
- **Decision**: Source homepage trust signals from the existing Trust content (`trust.ts`) and source homepage progress signals from the published changelog collection rather than inventing homepage-only proof systems.
|
||||||
|
- **Rationale**: The Trust page already contains bounded public claims and principles, while the changelog route already uses dated collection entries. Reusing those sources keeps one truth for proof-oriented content and avoids duplicate claim maintenance.
|
||||||
|
- **Alternatives considered**:
|
||||||
|
- Create homepage-only trust claims arrays disconnected from `/trust`: rejected because it would create duplicate truth and increase drift risk.
|
||||||
|
- Create manual homepage progress cards unrelated to the changelog collection: rejected because dated changelog truth already exists and is the stronger source.
|
||||||
|
|
||||||
|
## Decision 5: Validate through the existing website smoke harness
|
||||||
|
|
||||||
|
- **Decision**: Prove Spec 216 with the existing website build command and focused Playwright smoke updates for homepage section order, CTA hierarchy, and onward route reachability.
|
||||||
|
- **Rationale**: The homepage contract is about public rendering, navigational clarity, and responsive visibility. Browser smoke coverage is the narrowest proving layer that can validate those concerns.
|
||||||
|
- **Alternatives considered**:
|
||||||
|
- Build-only proof alone: rejected because static output generation does not prove section order, CTA hierarchy, or visible route reachability.
|
||||||
|
- Add visual regression or heavier browser matrices immediately: rejected because the feature scope does not require that extra cost.
|
||||||
169
specs/216-homepage-structure/spec.md
Normal file
169
specs/216-homepage-structure/spec.md
Normal file
@ -0,0 +1,169 @@
|
|||||||
|
# Feature Specification: Website Homepage Structure & Section Model
|
||||||
|
|
||||||
|
**Feature Branch**: `216-homepage-structure`
|
||||||
|
**Created**: 2026-04-19
|
||||||
|
**Status**: Draft
|
||||||
|
**Input**: User description: "Define Spec 216 as the website-only homepage structure and section model for `apps/website`, covering required sections, ordering, CTA logic, trust signal placement, product visuals, and onward routing."
|
||||||
|
|
||||||
|
## Spec Candidate Check *(mandatory — SPEC-GATE-001)*
|
||||||
|
|
||||||
|
- **Problem**: Without a clear homepage contract, the public website can drift into template-led marketing, feature dumps, weak trust framing, or premature sales pressure, leaving the product looking less real and less credible than it is.
|
||||||
|
- **Today's failure**: A first-time visitor can see visually polished sections yet still fail to understand what the product is, why it matters, why it should be trusted, or what the next sensible step should be.
|
||||||
|
- **User-visible improvement**: Visitors can understand the product category, the operational outcomes, the bounded trust posture, and the next route from the homepage alone without hunting through placeholder or mismatched sections.
|
||||||
|
- **Smallest enterprise-capable version**: One homepage-only structure contract that fixes the required section jobs, ordering, routing rules, product-visual expectations, trust claim rules, and excluded anti-patterns; no broader design-system rewrite or full-site IA expansion.
|
||||||
|
- **Explicit non-goals**: No final copy, no pixel or spacing decisions, no pricing surface, no full Product or Trust or Changelog spec, no CMS design, no platform UI work, no Filament theming, and no cross-app behavior changes.
|
||||||
|
- **Permanent complexity imported**: A stable homepage section vocabulary, CTA hierarchy, trust-claim guardrails, optional-section rules, and a clear structural baseline for follow-up website specs.
|
||||||
|
- **Why now**: The website foundation already exists, and the homepage needs a precise structural contract before deeper hero, screenshot, trust, and product-detail work can ship consistently.
|
||||||
|
- **Why not local**: A one-off homepage rewrite without a stated section model would not reliably prevent regression into generic SaaS patterns, weak trust sequencing, or conflicting CTAs as future iterations land.
|
||||||
|
- **Approval class**: Core Enterprise
|
||||||
|
- **Red flags triggered**: #1 introduces a homepage section taxonomy; #5 could become vocabulary-heavy if left unconstrained. The scope remains justified because the contract is strictly local to the public homepage and directly improves product clarity, trust, and onward routing.
|
||||||
|
- **Score**: Nutzen: 2 | Dringlichkeit: 2 | Scope: 2 | Komplexität: 1 | Produktnähe: 2 | Wiederverwendung: 1 | **Gesamt: 10/12**
|
||||||
|
- **Decision**: approve
|
||||||
|
|
||||||
|
## Spec Scope Fields *(mandatory)*
|
||||||
|
|
||||||
|
- **Scope**: workspace
|
||||||
|
- **Primary Routes**: `/` with onward routing to `/product`, `/trust`, `/changelog`, `/contact`, and supporting footer/legal reachability to `/privacy`, `/imprint`, and `/terms` when published
|
||||||
|
- **Data Ownership**: Workspace-owned public homepage content, section rules, CTA targets, trust/progress signals, and navigation behavior inside `apps/website`; no tenant-owned records or platform runtime data
|
||||||
|
- **RBAC**: Public-read runtime only. No authenticated membership or capability checks are required for homepage browsing; publishing remains repo-controlled.
|
||||||
|
|
||||||
|
## 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?**: yes
|
||||||
|
- **Current operator problem**: Buyers and technical evaluators cannot reliably judge the product from the homepage if structure drifts into generic marketing patterns or hides trust and next-step logic.
|
||||||
|
- **Existing structure is insufficient because**: The website foundation spec establishes public-site direction broadly, but it does not lock the homepage into mandatory block responsibilities, ordering, product-near rules, or claim/routing guardrails.
|
||||||
|
- **Narrowest correct implementation**: A homepage-only section model with required blocks, optional-block rules, claim constraints, and onward-routing expectations; no broader content platform, pricing model, or multi-page taxonomy expansion.
|
||||||
|
- **Ownership cost**: Ongoing review effort to keep homepage sections, CTA hierarchy, trust claims, and downstream route links aligned with the defined contract as follow-up page specs land.
|
||||||
|
- **Alternative intentionally rejected**: Letting homepage structure emerge ad hoc from design or copy iterations was rejected because it would not reliably prevent template drift, feature walls, trust being buried, or CTA pressure outpacing clarity.
|
||||||
|
- **Release truth**: Current-release truth
|
||||||
|
|
||||||
|
## Testing / Lane / Runtime Impact *(mandatory for runtime behavior changes)*
|
||||||
|
|
||||||
|
- **Test purpose / classification**: Browser
|
||||||
|
- **Validation lane(s)**: fast-feedback
|
||||||
|
- **Why this classification and these lanes are sufficient**: The feature is proven by homepage section presence, information order, route discoverability, and responsive behavior rather than by tenant data, authorization, or business-rule execution.
|
||||||
|
- **New or expanded test families**: Website browser smoke coverage for homepage section order, CTA reachability, and onward route discoverability, plus the existing static build proof.
|
||||||
|
- **Fixture / helper cost impact**: Minimal. Public homepage coverage does not require seeded tenant data, auth state, or platform helpers.
|
||||||
|
- **Heavy-family visibility / justification**: none
|
||||||
|
- **Special surface test profile**: N/A
|
||||||
|
- **Standard-native relief or required special coverage**: Homepage-specific browser coverage should verify required block presence, CTA hierarchy, and route reachability; no platform-specific fixtures or heavy suites are needed.
|
||||||
|
- **Reviewer handoff**: Reviewers must confirm that the homepage exposes all required structural blocks, routes visitors to Product, Trust, Changelog, and Contact without dead ends, omits unsubstantial optional routes, and remains understandable on desktop and mobile through build proof and browser smoke coverage.
|
||||||
|
- **Budget / baseline / trend impact**: Small increase limited to homepage/browser assertions in `apps/website`.
|
||||||
|
- **Escalation needed**: none
|
||||||
|
- **Active feature PR close-out entry**: Smoke Coverage
|
||||||
|
- **Planned validation commands**: `corepack pnpm build:website` and `cd apps/website && corepack pnpm exec playwright test`
|
||||||
|
|
||||||
|
## User Scenarios & Testing *(mandatory)*
|
||||||
|
|
||||||
|
### User Story 1 - Understand the Product Fast (Priority: P1)
|
||||||
|
|
||||||
|
A first-time buyer or technical evaluator lands on the homepage and can quickly understand what TenantAtlas is, why it matters, why it appears credible, and what to do next.
|
||||||
|
|
||||||
|
**Why this priority**: If the homepage fails to create immediate product clarity and trust, every deeper surface loses value.
|
||||||
|
|
||||||
|
**Independent Test**: This can be tested by visiting the homepage alone and confirming that the product category, relevance, credibility signals, and next-step route are all understandable without opening additional pages.
|
||||||
|
|
||||||
|
**Acceptance Scenarios**:
|
||||||
|
|
||||||
|
1. **Given** a first-time visitor opens the homepage, **When** they read the hero and immediate follow-on sections, **Then** they can describe what the product is and who it is for.
|
||||||
|
2. **Given** a visitor is unsure why the product matters, **When** they read the outcome framing, **Then** they can identify the operational problem space and the type of improvement the product offers.
|
||||||
|
3. **Given** a visitor wants to know what to do next, **When** they reach the CTA hierarchy, **Then** they can distinguish the primary next step from deeper exploratory routes.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### User Story 2 - Evaluate Product Model and Trust Without a Feature Wall (Priority: P2)
|
||||||
|
|
||||||
|
A serious evaluator can understand the connected product model, see credible trust and progress signals, and avoid mistaking the homepage for a generic feature dump.
|
||||||
|
|
||||||
|
**Why this priority**: Homepage structure must translate capabilities into a coherent operating model and establish seriousness early enough to qualify deeper interest.
|
||||||
|
|
||||||
|
**Independent Test**: This can be tested by reviewing only the homepage sections that explain outcomes, capabilities, trust, and progress and confirming that they form one coherent story rather than disconnected cards.
|
||||||
|
|
||||||
|
**Acceptance Scenarios**:
|
||||||
|
|
||||||
|
1. **Given** a visitor wants to understand the product logic, **When** they reach the capability section, **Then** they see grouped capability areas rather than an endless list of equal-weight features.
|
||||||
|
2. **Given** a visitor needs proof the product is credible, **When** they reach the trust and progress blocks, **Then** they see bounded claims and a visible route to deeper substantiation.
|
||||||
|
3. **Given** a visitor reads the homepage end-to-end, **When** they compare the sections, **Then** trust appears early enough to support the CTA instead of arriving as an afterthought.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### User Story 3 - Move Into the Right Next Route (Priority: P3)
|
||||||
|
|
||||||
|
A qualified visitor can choose whether to go deeper into Product, Trust, Changelog, or Contact without guessing which route answers the next question.
|
||||||
|
|
||||||
|
**Why this priority**: The homepage should route deliberately instead of trying to absorb every downstream page or forcing immediate sales contact.
|
||||||
|
|
||||||
|
**Independent Test**: This can be tested by starting on the homepage and verifying that each next-question route is visible, understandable, and reachable without dead ends or conflicting CTAs.
|
||||||
|
|
||||||
|
**Acceptance Scenarios**:
|
||||||
|
|
||||||
|
1. **Given** a visitor wants deeper product detail, **When** they act on the capability model section, **Then** the homepage routes them to `/product`.
|
||||||
|
2. **Given** a visitor wants trust or hosting context, **When** they act on the trust block or related CTA, **Then** the homepage routes them to `/trust`.
|
||||||
|
3. **Given** a visitor wants visible product movement or a direct conversation, **When** they inspect the progress block or final CTA, **Then** they can reach `/changelog` or `/contact` through clear, non-competing actions.
|
||||||
|
|
||||||
|
### Edge Cases
|
||||||
|
|
||||||
|
- What happens when optional surfaces such as Resources, Docs, Blog, Demo, or social proof are not yet substantive? The homepage must hide or de-emphasize them rather than linking to empty or immature routes.
|
||||||
|
- How does the homepage handle trust or residency claims that cannot yet be substantiated publicly? The claim must be omitted or softened instead of being implied broadly or theatrically.
|
||||||
|
- What happens if a real screenshot is not yet ready for publication? The hero may use a credible product-near visual, but it must still communicate real product structure and must not collapse into abstract decoration.
|
||||||
|
- How does the homepage behave on narrow screens? The same meaning order must survive mobile compression, and trust, progress, product-near context, and the primary CTA must remain visible without horizontal scrolling.
|
||||||
|
- What happens when the changelog surface has only a small amount of published history? The progress signal may stay concise, but it must still indicate real dated movement and link into the actual changelog route.
|
||||||
|
|
||||||
|
## Requirements *(mandatory)*
|
||||||
|
|
||||||
|
This feature changes only the public homepage in `apps/website`. It introduces no Microsoft Graph calls, no platform authorization changes, no Filament surfaces, no queued work, and no runtime coupling to `apps/platform`.
|
||||||
|
|
||||||
|
### Functional Requirements
|
||||||
|
|
||||||
|
- **FR-001**: The homepage MUST act as a product-near routing, positioning, and trust hub for `apps/website` rather than as full product documentation, a pure feature landing page, or a generic SaaS template.
|
||||||
|
- **FR-002**: The homepage MUST answer, within the initial reading flow, what the product is, who it is for, what problem it addresses, why it should be taken seriously, and what the next sensible step is.
|
||||||
|
- **FR-003**: The homepage MUST preserve the following functional block set: header or global navigation, hero, product outcome or why-it-matters section, core capability or product model section, trust or credibility signal block, product progress or changelog signal block, primary CTA or contact-transition block, and footer. Visual combination is allowed only when each block's functional job remains clear.
|
||||||
|
- **FR-004**: The homepage MUST follow a logical order of global context, hero, outcome framing, product model, trust, progress, CTA, and footer. Minor compression is allowed, but the page MUST NOT collapse into an unordered card stack or feature wall.
|
||||||
|
- **FR-005**: The header MUST include brand navigation to `/`, access to Product, Trust, Changelog, and Contact, and MAY include Resources only when substantive content exists. It MUST present one clear primary CTA and MUST NOT expose empty or immature routes.
|
||||||
|
- **FR-006**: The hero MUST include a positioning headline, supporting copy, one primary CTA, one secondary deepening CTA, and a product-near visual. The visual SHOULD be a real screenshot or a credible UI-near representation and MUST NOT rely only on abstract shapes to convey product truth.
|
||||||
|
- **FR-007**: Hero-adjacent trust subclaims MAY appear only when they are factually supportable, narrowly phrased, non-exaggerated, and traceable to the Trust surface for deeper context.
|
||||||
|
- **FR-008**: The product outcome or why-it-matters section MUST translate the product from capabilities into buyer-relevant outcomes, friction reduction, or operational improvements. It MUST explain why the problem space matters and MUST NOT rely on buzzwords or internal feature naming alone.
|
||||||
|
- **FR-009**: The core capability or product model section MUST explain the connected product model and the major capability areas without becoming full documentation. It MUST communicate grouped capability coverage spanning backup, restore, version history, auditability, inventory or drift visibility, and governance or evidence-oriented review work.
|
||||||
|
- **FR-010**: Capability explanation MUST use grouped clusters or another clear hierarchy, MUST route deeper product understanding to `/product`, and MUST NOT present the homepage as an endless list of equal-weight feature cards.
|
||||||
|
- **FR-011**: The homepage MUST include an explicit trust or credibility block before the final CTA. This block MUST signal technical seriousness and a bounded trust posture and MUST route to `/trust` for deeper context.
|
||||||
|
- **FR-012**: Any trust, hosting, residency, security, or governance claims shown on the homepage MUST be factually substantiated, narrowly phrased, and supportable by the Trust surface. The homepage MUST NOT imply unverified compliance or use invented badges, seals, or pseudo-certifications.
|
||||||
|
- **FR-013**: The homepage MUST include a dated or clearly progress-oriented signal that the product is active and evolving, such as a changelog teaser or recent public updates. It MUST route to `/changelog` and MUST NOT behave like a padded marketing news feed.
|
||||||
|
- **FR-014**: The homepage MUST provide a clear lower-page CTA transition to `/contact` or `/demo`. Until a distinct public `/demo` route exists, the primary conversion target MUST default to `/contact`.
|
||||||
|
- **FR-015**: The CTA system MUST keep exactly one dominant primary CTA and at least one secondary deepening CTA. The homepage MUST NOT present multiple equally loud primary sales actions in parallel.
|
||||||
|
- **FR-016**: The footer MUST keep Product, Trust, Changelog, Contact, Privacy, and Imprint reachable and MAY include Terms, Resources, or Docs only when those surfaces are real and maintained.
|
||||||
|
- **FR-017**: Optional homepage sections such as social proof, use-case spotlights, FAQ, or content teasers MAY be used only when backed by real substance and MUST NOT displace required sections or fake maturity.
|
||||||
|
- **FR-018**: The homepage MUST route visitors cleanly into `/product`, `/trust`, `/changelog`, and `/contact` without attempting to replace those pages completely.
|
||||||
|
- **FR-019**: The homepage MUST stay understandable and structurally equivalent on mobile. Hero, outcome, capability, trust, progress, and CTA blocks MUST remain recognizable on narrow screens, and mobile compression MUST NOT effectively hide trust or product-near context.
|
||||||
|
- **FR-020**: The homepage MUST avoid the following disallowed patterns: template-first SaaS framing, abstract-only storytelling, unstructured feature walls, hidden trust, demo-only pressure, fake social proof, and enterprise-theater claims.
|
||||||
|
- **FR-021**: The homepage MUST remain strictly local to `apps/website` and MUST NOT create implementation or contract requirements for `apps/platform`.
|
||||||
|
|
||||||
|
### Key Entities *(include if feature involves data)*
|
||||||
|
|
||||||
|
- **Homepage Block**: A functionally distinct section of the homepage such as Hero, Outcome, Capability Model, Trust, Progress, CTA, or Footer.
|
||||||
|
- **Product-Near Visual**: A screenshot or credible UI-adjacent visual that signals a real product rather than abstract marketing decoration.
|
||||||
|
- **Trust Claim**: A bounded public assertion about hosting, residency, seriousness, isolation, governance posture, or similar credibility signals that must be supportable by the Trust surface.
|
||||||
|
- **Progress Signal**: A homepage block or teaser that shows visible dated product movement and routes to the changelog.
|
||||||
|
- **CTA Target**: A next-question route reached from the homepage, especially Product, Trust, Changelog, or Contact.
|
||||||
|
|
||||||
|
## Assumptions & Dependencies
|
||||||
|
|
||||||
|
- `/product`, `/trust`, `/changelog`, `/contact`, `/privacy`, and `/imprint` remain the canonical core routes surfaced from the homepage in `apps/website`.
|
||||||
|
- `/contact` remains the primary conversion route unless and until a distinct public `/demo` surface exists and is substantively different.
|
||||||
|
- Optional surfaces such as Resources, Docs, Blog, Demo, customer references, or social proof remain hidden or secondary until they contain real, maintained content.
|
||||||
|
- If a publishable real screenshot is not yet ready, the initial hero may use a credible product-near visual that still reflects actual product structure rather than abstract illustration.
|
||||||
|
- Trust, hosting, residency, and governance claims will be limited to statements the team can substantiate publicly at release time.
|
||||||
|
|
||||||
|
## Success Criteria *(mandatory)*
|
||||||
|
|
||||||
|
### Measurable Outcomes
|
||||||
|
|
||||||
|
- **SC-001**: A first-time visitor can state what the product is, why it matters, why it appears credible, and the next step after 60 seconds or less on the homepage.
|
||||||
|
- **SC-002**: The released homepage exposes all eight mandatory functional blocks, or combined equivalents without loss of function, and each of `/product`, `/trust`, `/changelog`, and `/contact` is reachable from the homepage without dead links.
|
||||||
|
- **SC-003**: The hero presents exactly one primary CTA, at least one secondary deepening CTA, and a product-near visual on both desktop and mobile layouts.
|
||||||
|
- **SC-004**: Trust and progress signals appear before the final CTA and remain discoverable without leaving the homepage, while deeper substantiation stays reachable in one click to `/trust` and `/changelog`.
|
||||||
|
- **SC-005**: No released homepage version contains unsupported trust claims, fake logos or badges, placeholder routes, or more than one equally dominant primary conversion action.
|
||||||
|
- **SC-006**: On mobile widths, visitors can still identify the hero, outcome framing, capability model, trust block, progress block, and CTA transition without horizontal scrolling or hidden primary navigation.
|
||||||
206
specs/216-homepage-structure/tasks.md
Normal file
206
specs/216-homepage-structure/tasks.md
Normal file
@ -0,0 +1,206 @@
|
|||||||
|
# Tasks: Website Homepage Structure & Section Model
|
||||||
|
|
||||||
|
**Input**: Design documents from `/specs/216-homepage-structure/`
|
||||||
|
**Prerequisites**: `plan.md`, `spec.md`, `research.md`, `data-model.md`, `quickstart.md`, `contracts/homepage-surface.openapi.yaml`
|
||||||
|
|
||||||
|
**Tests**: Browser smoke coverage and the root website build proof are required for this runtime-changing website feature.
|
||||||
|
|
||||||
|
## Test Governance Checklist
|
||||||
|
|
||||||
|
- [X] Lane assignment stays `Browser` in `fast-feedback`, which is the narrowest sufficient proof for this homepage-only change.
|
||||||
|
- [X] New or changed tests stay in the existing website smoke suite instead of widening into a heavier family.
|
||||||
|
- [X] Shared helpers stay cheap by default; no backend, auth, database, or provider fixtures are introduced.
|
||||||
|
- [X] Planned validation commands remain the feature-local website build proof and Playwright smoke suite.
|
||||||
|
- [X] No additional budget, baseline, or escalation path is required beyond `document-in-feature` for this slice.
|
||||||
|
|
||||||
|
## Phase 1: Setup (Shared Infrastructure)
|
||||||
|
|
||||||
|
**Purpose**: Establish the homepage-specific content/type surface before story work begins.
|
||||||
|
|
||||||
|
- [X] T001 Add homepage-specific section content types for hero visuals, optional trust subclaims, outcome framing, capability clusters, trust signals, and progress teasers in `apps/website/src/types/site.ts`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 2: Foundational (Blocking Prerequisites)
|
||||||
|
|
||||||
|
**Purpose**: Add the reusable homepage helpers and scaffolds that every story slice depends on.
|
||||||
|
|
||||||
|
**⚠️ CRITICAL**: No user story work should begin until this phase is complete.
|
||||||
|
|
||||||
|
- [X] T002 [P] Create reusable homepage section scaffolds in `apps/website/src/components/sections/OutcomeSection.astro` and `apps/website/src/components/sections/ProgressTeaser.astro`
|
||||||
|
- [X] T003 [P] Add a shared recent-changelog helper for homepage progress signals in `apps/website/src/lib/changelog.ts`
|
||||||
|
- [X] T004 [P] Extend homepage smoke helpers for ordered section, product-near visual, mobile viewport, and CTA assertions in `apps/website/tests/smoke/smoke-helpers.ts`
|
||||||
|
|
||||||
|
**Checkpoint**: Homepage foundations are ready. User-story work can proceed.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 3: User Story 1 - Understand the Product Fast (Priority: P1) 🎯 MVP
|
||||||
|
|
||||||
|
**Goal**: Make the homepage explain what TenantAtlas is, why it matters, and what the next step is within the first reading pass.
|
||||||
|
|
||||||
|
**Independent Test**: Visit `/` and confirm the hero, outcome framing, and CTA hierarchy make the product category, buyer relevance, and next step understandable without opening any other route.
|
||||||
|
|
||||||
|
### Tests for User Story 1
|
||||||
|
|
||||||
|
> **NOTE**: Write this test first and confirm it fails before implementing the story.
|
||||||
|
|
||||||
|
- [X] T005 [P] [US1] Write failing homepage smoke assertions for hero clarity, product-near visual presence, outcome framing, and one dominant CTA hierarchy in `apps/website/tests/smoke/home-product.spec.ts`
|
||||||
|
|
||||||
|
### Implementation for User Story 1
|
||||||
|
|
||||||
|
- [X] T006 [P] [US1] Add Spec 216 hero content, product-near visual data, optional bounded trust subclaims, and outcome blocks in `apps/website/src/content/pages/home.ts`
|
||||||
|
- [X] T007 [US1] Implement the hero-to-outcome homepage flow and hero visual rendering in `apps/website/src/pages/index.astro` and `apps/website/src/components/sections/PageHero.astro`
|
||||||
|
|
||||||
|
**Checkpoint**: The homepage delivers the MVP story of product clarity, buyer relevance, and one clear next step.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 4: User Story 2 - Evaluate Product Model and Trust Without a Feature Wall (Priority: P2)
|
||||||
|
|
||||||
|
**Goal**: Turn the homepage middle section into a grouped product model with explicit trust and visible progress instead of route-job cards.
|
||||||
|
|
||||||
|
**Independent Test**: Visit `/` and confirm the homepage shows grouped capability coverage, an explicit trust block, and a dated progress signal before the final CTA.
|
||||||
|
|
||||||
|
### Tests for User Story 2
|
||||||
|
|
||||||
|
> **NOTE**: Write this test first and confirm it fails before implementing the story.
|
||||||
|
|
||||||
|
- [X] T008 [P] [US2] Write failing homepage smoke assertions for grouped capability clusters, explicit trust visibility, and pre-CTA progress signaling in `apps/website/tests/smoke/home-product.spec.ts` and `apps/website/tests/smoke/changelog-core-ia.spec.ts`
|
||||||
|
|
||||||
|
### Implementation for User Story 2
|
||||||
|
|
||||||
|
- [X] T009 [P] [US2] Replace route-job homepage cards with grouped capability and trust/progress content data in `apps/website/src/content/pages/home.ts` and `apps/website/src/content/pages/trust.ts`
|
||||||
|
- [X] T010 [US2] Render the grouped capability model and explicit trust block on the homepage in `apps/website/src/pages/index.astro` and `apps/website/src/components/sections/TrustGrid.astro`
|
||||||
|
- [X] T011 [US2] Use the shared changelog helper to wire a dated homepage progress teaser in `apps/website/src/components/sections/ProgressTeaser.astro` and `apps/website/src/pages/index.astro`
|
||||||
|
|
||||||
|
**Checkpoint**: The homepage explains the connected product model, shows explicit trust, and proves visible product movement.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 5: User Story 3 - Move Into the Right Next Route (Priority: P3)
|
||||||
|
|
||||||
|
**Goal**: Make Product, Trust, Changelog, Contact, and legal follow-through obvious from the homepage without multiple competing conversion paths.
|
||||||
|
|
||||||
|
**Independent Test**: Start on `/` and confirm the homepage routes clearly into `/product`, `/trust`, `/changelog`, and `/contact`, while footer/legal links remain discoverable and optional unpublished routes stay de-emphasized.
|
||||||
|
|
||||||
|
### Tests for User Story 3
|
||||||
|
|
||||||
|
> **NOTE**: Write this test first and confirm it fails before implementing the story.
|
||||||
|
|
||||||
|
- [X] T012 [P] [US3] Write failing homepage smoke assertions for Product, Trust, Changelog, Contact, footer/legal discoverability, and narrow-screen/mobile visibility in `apps/website/tests/smoke/home-product.spec.ts` and `apps/website/tests/smoke/contact-legal.spec.ts`
|
||||||
|
|
||||||
|
### Implementation for User Story 3
|
||||||
|
|
||||||
|
- [X] T013 [P] [US3] Align homepage CTA targets, secondary deep links, and optional-route suppression in `apps/website/src/content/pages/home.ts` and `apps/website/src/lib/site.ts`
|
||||||
|
- [X] T014 [P] [US3] Tighten header and footer route discoverability for the homepage journey on desktop and narrow screens in `apps/website/src/components/layout/Navbar.astro` and `apps/website/src/components/layout/Footer.astro`
|
||||||
|
- [X] T015 [US3] Finalize homepage onward routing to `/product`, `/trust`, `/changelog`, and `/contact` in `apps/website/src/pages/index.astro`
|
||||||
|
|
||||||
|
**Checkpoint**: The homepage routes qualified visitors into the right next page without dead ends or competing primary actions.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 6: Polish & Cross-Cutting Concerns
|
||||||
|
|
||||||
|
**Purpose**: Validate proof commands, tighten claim wording, and capture close-out notes.
|
||||||
|
|
||||||
|
- [X] T016 [P] Review homepage proof and trust wording against bounded-claim rules in `apps/website/src/content/pages/home.ts`, `apps/website/src/content/pages/trust.ts`, and `apps/website/src/content/pages/changelog.ts`
|
||||||
|
- [X] T017 [P] Run `corepack pnpm build:website` from `package.json` and confirm homepage proof expectations in `specs/216-homepage-structure/quickstart.md`
|
||||||
|
- [X] T018 [P] Run `cd apps/website && corepack pnpm exec playwright test` against `apps/website/tests/smoke/home-product.spec.ts`, `apps/website/tests/smoke/changelog-core-ia.spec.ts`, and `apps/website/tests/smoke/contact-legal.spec.ts`
|
||||||
|
- [X] T019 Record the homepage smoke-coverage close-out and verification notes in `specs/216-homepage-structure/plan.md` and `specs/216-homepage-structure/quickstart.md`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Dependencies & Execution Order
|
||||||
|
|
||||||
|
### Phase Dependencies
|
||||||
|
|
||||||
|
- **Setup (Phase 1)**: Starts immediately.
|
||||||
|
- **Foundational (Phase 2)**: Depends on Setup and blocks all user stories.
|
||||||
|
- **User Stories (Phases 3-5)**: Depend on Foundational. They remain independently testable, but shared homepage assembly in `apps/website/src/pages/index.astro` should land sequentially in story order.
|
||||||
|
- **Polish (Phase 6)**: Depends on all desired user stories being complete.
|
||||||
|
|
||||||
|
### User Story Dependencies
|
||||||
|
|
||||||
|
- **User Story 1 (P1)**: Starts after Foundational and is the MVP slice.
|
||||||
|
- **User Story 2 (P2)**: Starts after Foundational for content and helper work, and remains independently valuable because it upgrades the homepage product-model and proof layer. Its shared `index.astro` assembly should follow US1.
|
||||||
|
- **User Story 3 (P3)**: Starts after Foundational for routing and shell work, and remains independently valuable because it sharpens onward routing and CTA clarity from the homepage. Its shared `index.astro` assembly should follow US2.
|
||||||
|
|
||||||
|
### Within Each User Story
|
||||||
|
|
||||||
|
- Write the story-specific browser smoke assertions first and confirm they fail.
|
||||||
|
- Update content modules and supporting helpers before final route composition in `apps/website/src/pages/index.astro`.
|
||||||
|
- Treat `apps/website/src/pages/index.astro` as a shared assembly point: content/helper work may branch, but homepage route composition should land sequentially.
|
||||||
|
- Finish the homepage route wiring before running the story proof commands.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Parallel Opportunities
|
||||||
|
|
||||||
|
- `T002`, `T003`, and `T004` can run in parallel after `T001`.
|
||||||
|
- In US1, `T005` and `T006` can run in parallel before `T007`.
|
||||||
|
- In US2, `T008` and `T009` can run in parallel before `T010`; `T011` follows the shared homepage assembly work.
|
||||||
|
- In US3, `T013` and `T014` can run in parallel before `T015`.
|
||||||
|
- `T016`, `T017`, and `T018` can run in parallel during polish before `T019`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Parallel Example: User Story 1
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# After the foundations are complete, split the first slice into test + content work:
|
||||||
|
Task: "T005 [US1] Write failing homepage smoke assertions for hero clarity, outcome framing, and one dominant CTA hierarchy"
|
||||||
|
Task: "T006 [US1] Add Spec 216 hero and outcome content blocks"
|
||||||
|
|
||||||
|
# Then assemble the homepage route:
|
||||||
|
Task: "T007 [US1] Implement the hero-to-outcome homepage flow"
|
||||||
|
```
|
||||||
|
|
||||||
|
## Parallel Example: User Story 2
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Safe split inside US2 is limited to assertions plus content prep before homepage assembly:
|
||||||
|
Task: "T008 [US2] Write failing homepage smoke assertions for grouped capability clusters, explicit trust visibility, and pre-CTA progress signaling"
|
||||||
|
Task: "T009 [US2] Replace route-job homepage cards with grouped capability and trust/progress content data"
|
||||||
|
|
||||||
|
# Then complete the shared homepage assembly sequentially:
|
||||||
|
Task: "T010 [US2] Render the grouped capability model and explicit trust block on the homepage"
|
||||||
|
Task: "T011 [US2] Use the shared changelog helper to wire a dated homepage progress teaser"
|
||||||
|
```
|
||||||
|
|
||||||
|
## Parallel Example: User Story 3
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Split route-target and shell-discoverability work once the routing assertions exist:
|
||||||
|
Task: "T013 [US3] Align homepage CTA targets, secondary deep links, and optional-route suppression"
|
||||||
|
Task: "T014 [US3] Tighten header and footer route discoverability for the homepage journey"
|
||||||
|
|
||||||
|
# Then finish the homepage route wiring:
|
||||||
|
Task: "T015 [US3] Finalize homepage onward routing to /product, /trust, /changelog, and /contact"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Implementation Strategy
|
||||||
|
|
||||||
|
### MVP First
|
||||||
|
|
||||||
|
1. Complete Phase 1: Setup.
|
||||||
|
2. Complete Phase 2: Foundational.
|
||||||
|
3. Complete Phase 3: User Story 1.
|
||||||
|
4. Run the homepage build proof and US1 browser smoke coverage.
|
||||||
|
5. Demo the homepage MVP with clear product explanation and one dominant next step.
|
||||||
|
|
||||||
|
### Incremental Delivery
|
||||||
|
|
||||||
|
1. Foundations create the reusable homepage scaffolds, changelog helper, and smoke assertions.
|
||||||
|
2. US1 establishes immediate product clarity and CTA discipline.
|
||||||
|
3. US2 upgrades the homepage middle narrative into grouped product model, trust, and progress proof.
|
||||||
|
4. US3 sharpens onward routing and discoverability for Product, Trust, Changelog, Contact, and legal follow-through.
|
||||||
|
5. Polish runs both proof commands, validates wording, and records close-out notes before merge.
|
||||||
|
|
||||||
|
### Suggested MVP Scope
|
||||||
|
|
||||||
|
- Deliver through **User Story 1** for the smallest independently valuable slice.
|
||||||
|
- Add **User Story 2** next for stronger product-model and trust proof.
|
||||||
|
- Finish with **User Story 3** for fully deliberate onward routing from the homepage.
|
||||||
36
specs/216-provider-dispatch-gate/checklists/requirements.md
Normal file
36
specs/216-provider-dispatch-gate/checklists/requirements.md
Normal file
@ -0,0 +1,36 @@
|
|||||||
|
# Specification Quality Checklist: Provider-Backed Action Preflight and Dispatch Gate Unification
|
||||||
|
|
||||||
|
**Purpose**: Validate specification completeness and quality before proceeding to planning
|
||||||
|
**Created**: 2026-04-19
|
||||||
|
**Feature**: [spec.md](../spec.md)
|
||||||
|
|
||||||
|
## Content Quality
|
||||||
|
|
||||||
|
- [x] No implementation details (languages, frameworks, APIs)
|
||||||
|
- [x] Focused on user value and business needs
|
||||||
|
- [x] Written for non-technical stakeholders
|
||||||
|
- [x] All mandatory sections completed
|
||||||
|
|
||||||
|
## Requirement Completeness
|
||||||
|
|
||||||
|
- [x] No [NEEDS CLARIFICATION] markers remain
|
||||||
|
- [x] Requirements are testable and unambiguous
|
||||||
|
- [x] Success criteria are measurable
|
||||||
|
- [x] Success criteria are technology-agnostic (no implementation details)
|
||||||
|
- [x] All acceptance scenarios are defined
|
||||||
|
- [x] Edge cases are identified
|
||||||
|
- [x] Scope is clearly bounded
|
||||||
|
- [x] Dependencies and assumptions identified
|
||||||
|
|
||||||
|
## Feature Readiness
|
||||||
|
|
||||||
|
- [x] All functional requirements have clear acceptance criteria
|
||||||
|
- [x] User scenarios cover primary flows
|
||||||
|
- [x] Feature meets measurable outcomes defined in Success Criteria
|
||||||
|
- [x] No implementation details leak into specification
|
||||||
|
|
||||||
|
## Notes
|
||||||
|
|
||||||
|
- Validation pass completed on 2026-04-19.
|
||||||
|
- The chosen spec is aligned with the active / near-term roadmap lane and the highest-priority unspecced qualified candidate in spec-candidates.md.
|
||||||
|
- No clarification questions were needed; the candidate already had sufficient scope, sequencing, and boundary detail for planning readiness.
|
||||||
@ -0,0 +1,419 @@
|
|||||||
|
openapi: 3.1.0
|
||||||
|
info:
|
||||||
|
title: Provider Dispatch Gate Start Contract
|
||||||
|
version: 1.0.0
|
||||||
|
description: >-
|
||||||
|
Internal reference contract for the operator-triggered provider-backed start
|
||||||
|
surfaces covered by Spec 216. The real implementation remains Filament and
|
||||||
|
Livewire HTML actions. The vendor media types below document the structured
|
||||||
|
start-result and accepted-run payloads that must be derivable before
|
||||||
|
rendering. This is not a public API commitment.
|
||||||
|
paths:
|
||||||
|
/admin/t/{tenant}/provider-actions/{operation}/start:
|
||||||
|
post:
|
||||||
|
summary: Start a tenant-scoped provider-backed operation
|
||||||
|
parameters:
|
||||||
|
- name: tenant
|
||||||
|
in: path
|
||||||
|
required: true
|
||||||
|
schema:
|
||||||
|
type: string
|
||||||
|
- name: operation
|
||||||
|
in: path
|
||||||
|
required: true
|
||||||
|
schema:
|
||||||
|
type: string
|
||||||
|
requestBody:
|
||||||
|
required: false
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/ProviderStartRequest'
|
||||||
|
responses:
|
||||||
|
'200':
|
||||||
|
description: Rendered Livewire action response for the start attempt
|
||||||
|
content:
|
||||||
|
text/html:
|
||||||
|
schema:
|
||||||
|
type: string
|
||||||
|
application/vnd.tenantpilot.provider-start-outcome+json:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/ProviderStartOutcome'
|
||||||
|
'403':
|
||||||
|
description: Tenant member lacks the required capability for the operation
|
||||||
|
'404':
|
||||||
|
description: Tenant is not visible because workspace or tenant entitlement is missing
|
||||||
|
/admin/provider-connections/{connection}/actions/{operation}/start:
|
||||||
|
post:
|
||||||
|
summary: Start a provider-connection-scoped operation
|
||||||
|
parameters:
|
||||||
|
- name: connection
|
||||||
|
in: path
|
||||||
|
required: true
|
||||||
|
schema:
|
||||||
|
type: integer
|
||||||
|
- name: operation
|
||||||
|
in: path
|
||||||
|
required: true
|
||||||
|
schema:
|
||||||
|
type: string
|
||||||
|
responses:
|
||||||
|
'200':
|
||||||
|
description: Rendered Livewire action response for the connection-scoped start attempt
|
||||||
|
content:
|
||||||
|
text/html:
|
||||||
|
schema:
|
||||||
|
type: string
|
||||||
|
application/vnd.tenantpilot.provider-start-outcome+json:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/ProviderStartOutcome'
|
||||||
|
'403':
|
||||||
|
description: Viewer is in scope but lacks the required capability for the action
|
||||||
|
'404':
|
||||||
|
description: Provider connection is not visible because entitlement is missing
|
||||||
|
/admin/t/{tenant}/restore-runs/{restoreRun}/execute:
|
||||||
|
post:
|
||||||
|
summary: Execute a restore through the canonical provider start gate
|
||||||
|
parameters:
|
||||||
|
- name: tenant
|
||||||
|
in: path
|
||||||
|
required: true
|
||||||
|
schema:
|
||||||
|
type: string
|
||||||
|
- name: restoreRun
|
||||||
|
in: path
|
||||||
|
required: true
|
||||||
|
schema:
|
||||||
|
type: integer
|
||||||
|
responses:
|
||||||
|
'200':
|
||||||
|
description: Rendered restore execute action response
|
||||||
|
content:
|
||||||
|
text/html:
|
||||||
|
schema:
|
||||||
|
type: string
|
||||||
|
application/vnd.tenantpilot.provider-start-outcome+json:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/ProviderStartOutcome'
|
||||||
|
'403':
|
||||||
|
description: Tenant member lacks restore execution capability after membership is established
|
||||||
|
'404':
|
||||||
|
description: Restore run is not visible because entitlement is missing
|
||||||
|
/admin/t/{tenant}/directory/groups/sync:
|
||||||
|
post:
|
||||||
|
summary: Start directory groups sync
|
||||||
|
parameters:
|
||||||
|
- name: tenant
|
||||||
|
in: path
|
||||||
|
required: true
|
||||||
|
schema:
|
||||||
|
type: string
|
||||||
|
requestBody:
|
||||||
|
required: false
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/DirectoryGroupsSyncRequest'
|
||||||
|
responses:
|
||||||
|
'200':
|
||||||
|
description: Rendered directory groups sync action response
|
||||||
|
content:
|
||||||
|
text/html:
|
||||||
|
schema:
|
||||||
|
type: string
|
||||||
|
application/vnd.tenantpilot.provider-start-outcome+json:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/ProviderStartOutcome'
|
||||||
|
'403':
|
||||||
|
description: Tenant member lacks sync capability after membership is established
|
||||||
|
'404':
|
||||||
|
description: Tenant is not visible because entitlement is missing
|
||||||
|
/admin/t/{tenant}/directory/role-definitions/sync:
|
||||||
|
post:
|
||||||
|
summary: Start role definitions sync
|
||||||
|
parameters:
|
||||||
|
- name: tenant
|
||||||
|
in: path
|
||||||
|
required: true
|
||||||
|
schema:
|
||||||
|
type: string
|
||||||
|
responses:
|
||||||
|
'200':
|
||||||
|
description: Rendered role definitions sync action response
|
||||||
|
content:
|
||||||
|
text/html:
|
||||||
|
schema:
|
||||||
|
type: string
|
||||||
|
application/vnd.tenantpilot.provider-start-outcome+json:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/ProviderStartOutcome'
|
||||||
|
'403':
|
||||||
|
description: Tenant member lacks the required capability after membership is established
|
||||||
|
'404':
|
||||||
|
description: Tenant is not visible because entitlement is missing
|
||||||
|
/admin/onboarding/{session}/provider-actions/{operation}/start:
|
||||||
|
post:
|
||||||
|
summary: Start an onboarding provider verification action
|
||||||
|
parameters:
|
||||||
|
- name: session
|
||||||
|
in: path
|
||||||
|
required: true
|
||||||
|
schema:
|
||||||
|
type: integer
|
||||||
|
- name: operation
|
||||||
|
in: path
|
||||||
|
required: true
|
||||||
|
schema:
|
||||||
|
type: string
|
||||||
|
requestBody:
|
||||||
|
required: false
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/OnboardingProviderStartRequest'
|
||||||
|
responses:
|
||||||
|
'200':
|
||||||
|
description: Rendered onboarding verification action response
|
||||||
|
content:
|
||||||
|
text/html:
|
||||||
|
schema:
|
||||||
|
type: string
|
||||||
|
application/vnd.tenantpilot.provider-start-outcome+json:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/ProviderStartOutcome'
|
||||||
|
'403':
|
||||||
|
description: Workspace member lacks the required capability after scope is established
|
||||||
|
'404':
|
||||||
|
description: Onboarding session is not visible because entitlement is missing
|
||||||
|
/admin/onboarding/{session}/provider-bootstrap/start:
|
||||||
|
post:
|
||||||
|
summary: Start onboarding bootstrap work under sequential protected-scope admission
|
||||||
|
parameters:
|
||||||
|
- name: session
|
||||||
|
in: path
|
||||||
|
required: true
|
||||||
|
schema:
|
||||||
|
type: integer
|
||||||
|
requestBody:
|
||||||
|
required: true
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/OnboardingBootstrapStartRequest'
|
||||||
|
responses:
|
||||||
|
'200':
|
||||||
|
description: Rendered onboarding bootstrap action response
|
||||||
|
content:
|
||||||
|
text/html:
|
||||||
|
schema:
|
||||||
|
type: string
|
||||||
|
application/vnd.tenantpilot.onboarding-bootstrap-start-outcome+json:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/OnboardingBootstrapStartOutcome'
|
||||||
|
'403':
|
||||||
|
description: Workspace member lacks the required capability after scope is established
|
||||||
|
'404':
|
||||||
|
description: Onboarding session is not visible because entitlement is missing
|
||||||
|
/admin/operations/{run}:
|
||||||
|
get:
|
||||||
|
summary: Canonical provider-backed operation run detail
|
||||||
|
parameters:
|
||||||
|
- name: run
|
||||||
|
in: path
|
||||||
|
required: true
|
||||||
|
schema:
|
||||||
|
type: integer
|
||||||
|
responses:
|
||||||
|
'200':
|
||||||
|
description: Rendered Monitoring → Operations run detail page
|
||||||
|
content:
|
||||||
|
text/html:
|
||||||
|
schema:
|
||||||
|
type: string
|
||||||
|
application/vnd.tenantpilot.provider-backed-run-detail+json:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/ProviderBackedRunDetail'
|
||||||
|
'403':
|
||||||
|
description: Viewer is in scope but lacks permission for related actions
|
||||||
|
'404':
|
||||||
|
description: Run is not visible because workspace or tenant entitlement is missing
|
||||||
|
components:
|
||||||
|
schemas:
|
||||||
|
ProviderStartRequest:
|
||||||
|
type: object
|
||||||
|
properties:
|
||||||
|
providerConnectionId:
|
||||||
|
type: integer
|
||||||
|
nullable: true
|
||||||
|
sourceSurface:
|
||||||
|
type: string
|
||||||
|
targetContext:
|
||||||
|
type: object
|
||||||
|
additionalProperties: true
|
||||||
|
DirectoryGroupsSyncRequest:
|
||||||
|
type: object
|
||||||
|
properties:
|
||||||
|
selectionKey:
|
||||||
|
type: string
|
||||||
|
default: all_groups_v1
|
||||||
|
providerConnectionId:
|
||||||
|
type: integer
|
||||||
|
nullable: true
|
||||||
|
OnboardingProviderStartRequest:
|
||||||
|
type: object
|
||||||
|
properties:
|
||||||
|
providerConnectionId:
|
||||||
|
type: integer
|
||||||
|
nullable: true
|
||||||
|
step:
|
||||||
|
type: string
|
||||||
|
OnboardingBootstrapStartRequest:
|
||||||
|
type: object
|
||||||
|
required:
|
||||||
|
- providerConnectionId
|
||||||
|
- selectedOperations
|
||||||
|
properties:
|
||||||
|
providerConnectionId:
|
||||||
|
type: integer
|
||||||
|
selectedOperations:
|
||||||
|
type: array
|
||||||
|
minItems: 1
|
||||||
|
items:
|
||||||
|
type: string
|
||||||
|
sourceSurface:
|
||||||
|
type: string
|
||||||
|
default: onboarding.bootstrap
|
||||||
|
ProviderStartOutcome:
|
||||||
|
type: object
|
||||||
|
description: >-
|
||||||
|
Canonical start-result shape derived before queue admission for every
|
||||||
|
route-bounded provider-backed start covered by Spec 216.
|
||||||
|
required:
|
||||||
|
- status
|
||||||
|
- operationType
|
||||||
|
- operatorMessage
|
||||||
|
properties:
|
||||||
|
status:
|
||||||
|
type: string
|
||||||
|
enum:
|
||||||
|
- accepted
|
||||||
|
- deduped
|
||||||
|
- scope_busy
|
||||||
|
- blocked
|
||||||
|
operationType:
|
||||||
|
type: string
|
||||||
|
operatorVerb:
|
||||||
|
type: string
|
||||||
|
operatorTarget:
|
||||||
|
type: string
|
||||||
|
operatorMessage:
|
||||||
|
type: string
|
||||||
|
shortReason:
|
||||||
|
type: string
|
||||||
|
nullable: true
|
||||||
|
providerConnection:
|
||||||
|
$ref: '#/components/schemas/ProviderConnectionContext'
|
||||||
|
run:
|
||||||
|
$ref: '#/components/schemas/RunReference'
|
||||||
|
nextSteps:
|
||||||
|
type: array
|
||||||
|
items:
|
||||||
|
$ref: '#/components/schemas/NextStep'
|
||||||
|
actions:
|
||||||
|
type: array
|
||||||
|
items:
|
||||||
|
$ref: '#/components/schemas/ActionLink'
|
||||||
|
OnboardingBootstrapStartOutcome:
|
||||||
|
allOf:
|
||||||
|
- $ref: '#/components/schemas/ProviderStartOutcome'
|
||||||
|
- type: object
|
||||||
|
properties:
|
||||||
|
acceptedOperation:
|
||||||
|
type: string
|
||||||
|
nullable: true
|
||||||
|
pendingOperations:
|
||||||
|
type: array
|
||||||
|
items:
|
||||||
|
type: string
|
||||||
|
ProviderConnectionContext:
|
||||||
|
type: object
|
||||||
|
properties:
|
||||||
|
id:
|
||||||
|
type: integer
|
||||||
|
provider:
|
||||||
|
type: string
|
||||||
|
label:
|
||||||
|
type: string
|
||||||
|
RunReference:
|
||||||
|
type: object
|
||||||
|
properties:
|
||||||
|
id:
|
||||||
|
type: integer
|
||||||
|
url:
|
||||||
|
type: string
|
||||||
|
status:
|
||||||
|
type: string
|
||||||
|
NextStep:
|
||||||
|
type: object
|
||||||
|
required:
|
||||||
|
- label
|
||||||
|
properties:
|
||||||
|
label:
|
||||||
|
type: string
|
||||||
|
description:
|
||||||
|
type: string
|
||||||
|
nullable: true
|
||||||
|
href:
|
||||||
|
type: string
|
||||||
|
nullable: true
|
||||||
|
actionType:
|
||||||
|
type: string
|
||||||
|
nullable: true
|
||||||
|
ActionLink:
|
||||||
|
type: object
|
||||||
|
required:
|
||||||
|
- label
|
||||||
|
- href
|
||||||
|
properties:
|
||||||
|
label:
|
||||||
|
type: string
|
||||||
|
href:
|
||||||
|
type: string
|
||||||
|
kind:
|
||||||
|
type: string
|
||||||
|
nullable: true
|
||||||
|
ProviderBackedRunDetail:
|
||||||
|
type: object
|
||||||
|
description: >-
|
||||||
|
Canonical Monitoring run detail contract for accepted provider-backed
|
||||||
|
work. The rendered page must reuse the same translated reason family
|
||||||
|
for operator-triggered and scheduled or system-initiated runs, while
|
||||||
|
terminal notifications remain initiator-only.
|
||||||
|
required:
|
||||||
|
- runId
|
||||||
|
- operationType
|
||||||
|
- executionStatus
|
||||||
|
properties:
|
||||||
|
runId:
|
||||||
|
type: integer
|
||||||
|
operationType:
|
||||||
|
type: string
|
||||||
|
executionStatus:
|
||||||
|
type: string
|
||||||
|
outcome:
|
||||||
|
type: string
|
||||||
|
nullable: true
|
||||||
|
providerConnection:
|
||||||
|
$ref: '#/components/schemas/ProviderConnectionContext'
|
||||||
|
protectedScope:
|
||||||
|
type: object
|
||||||
|
additionalProperties: true
|
||||||
|
shortReason:
|
||||||
|
type: string
|
||||||
|
nullable: true
|
||||||
|
nextSteps:
|
||||||
|
type: array
|
||||||
|
items:
|
||||||
|
$ref: '#/components/schemas/NextStep'
|
||||||
|
diagnosticsAvailable:
|
||||||
|
type: boolean
|
||||||
236
specs/216-provider-dispatch-gate/data-model.md
Normal file
236
specs/216-provider-dispatch-gate/data-model.md
Normal file
@ -0,0 +1,236 @@
|
|||||||
|
# Data Model: Provider-Backed Action Preflight and Dispatch Gate Unification
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
This feature does not introduce new persisted entities or tables. It extends the existing provider-backed start contract around `ProviderConnection`, `OperationRun`, and onboarding draft state so every covered operator-triggered start follows the same queue-admission, conflict-protection, and operator-feedback rules.
|
||||||
|
|
||||||
|
The key design constraint is that start truth remains service-owned and derived from existing runtime records:
|
||||||
|
|
||||||
|
- provider readiness and connection identity from `ProviderConnection` plus `ProviderConnectionResolver`
|
||||||
|
- accepted or prevented work truth from `OperationRun`
|
||||||
|
- click-time queue-admission decisions from `ProviderOperationStartGate`
|
||||||
|
- onboarding bootstrap continuity from existing `TenantOnboardingSession.state`
|
||||||
|
- operator feedback from existing Ops UX helpers plus one thin shared provider-start presentation helper
|
||||||
|
|
||||||
|
## Existing Persistent Inputs
|
||||||
|
|
||||||
|
### 1. ProviderConnection
|
||||||
|
|
||||||
|
- Purpose: Tenant-owned provider credential and readiness record that defines which delegated connection a provider-backed operation can use.
|
||||||
|
- Key persisted fields used by this feature:
|
||||||
|
- `id`
|
||||||
|
- `tenant_id`
|
||||||
|
- `provider`
|
||||||
|
- `entra_tenant_id`
|
||||||
|
- Existing runtime facts consumed through current services:
|
||||||
|
- default-vs-explicit selection
|
||||||
|
- consent readiness
|
||||||
|
- credential usability
|
||||||
|
- provider identity and scope targeting
|
||||||
|
- Relationships used by this feature:
|
||||||
|
- owning tenant
|
||||||
|
- related operation runs through `OperationRun.context.provider_connection_id`
|
||||||
|
|
||||||
|
### 2. OperationRun
|
||||||
|
|
||||||
|
- Purpose: Canonical operational truth for queued or executed provider-backed work and for blocked preflight attempts that must remain observable.
|
||||||
|
- Key persisted fields used by this feature:
|
||||||
|
- `id`
|
||||||
|
- `tenant_id`
|
||||||
|
- `type`
|
||||||
|
- `status`
|
||||||
|
- `outcome`
|
||||||
|
- `reason_code`
|
||||||
|
- `context`
|
||||||
|
- `summary_counts`
|
||||||
|
- `started_at`
|
||||||
|
- `completed_at`
|
||||||
|
- Existing relationships or references used by this feature:
|
||||||
|
- initiator user
|
||||||
|
- tenant scope
|
||||||
|
- canonical Monitoring → Operations detail route
|
||||||
|
|
||||||
|
### 3. TenantOnboardingSession
|
||||||
|
|
||||||
|
- Purpose: Workspace-owned onboarding workflow record that already stores onboarding progress and step state, including bootstrap operation selections and related run references.
|
||||||
|
- Key persisted fields used by this feature:
|
||||||
|
- `id`
|
||||||
|
- `workspace_id`
|
||||||
|
- `tenant_id` (nullable until attached)
|
||||||
|
- `current_step`
|
||||||
|
- `state`
|
||||||
|
- Existing state keys used by this feature:
|
||||||
|
- `bootstrap_operation_types`
|
||||||
|
- `bootstrap_operation_runs`
|
||||||
|
- Relationships used by this feature:
|
||||||
|
- workspace
|
||||||
|
- attached tenant when present
|
||||||
|
|
||||||
|
### 4. RestoreRun
|
||||||
|
|
||||||
|
- Purpose: Tenant-owned restore execution record whose execute action becomes part of the canonical provider-backed start contract.
|
||||||
|
- Key persisted fields used by this feature:
|
||||||
|
- `id`
|
||||||
|
- `tenant_id`
|
||||||
|
- restore configuration and preview state already captured before execution
|
||||||
|
- Relationships used by this feature:
|
||||||
|
- tenant
|
||||||
|
- backup source records and restore safety flow already owned by existing restore logic
|
||||||
|
|
||||||
|
## Existing Service-Owned Inputs
|
||||||
|
|
||||||
|
### A. ProviderOperationRegistry Entry
|
||||||
|
|
||||||
|
This is an existing logical definition, not a new persisted entity.
|
||||||
|
|
||||||
|
| Field | Meaning |
|
||||||
|
|---|---|
|
||||||
|
| `operationType` | The write-time operation type string admitted by the gate |
|
||||||
|
| `module` | Current operation family/module metadata used in run context |
|
||||||
|
| `dispatcher` | The queue-dispatch callback or equivalent start hook |
|
||||||
|
| `requiredCapability` | Capability gate required to start the operation |
|
||||||
|
| `providerScopeExpectation` | Whether the operation is connection-scoped and therefore protected by click-time conflict rules |
|
||||||
|
|
||||||
|
Rules:
|
||||||
|
|
||||||
|
- First-slice coverage adds entries for every covered action host instead of introducing a second registry.
|
||||||
|
- This feature does not normalize historical operation names; registry entries follow current write-time operation types.
|
||||||
|
|
||||||
|
### B. ProviderConnectionResolution
|
||||||
|
|
||||||
|
Logical output of `ProviderConnectionResolver` and `ProviderOperationStartGate`.
|
||||||
|
|
||||||
|
| Field | Meaning |
|
||||||
|
|---|---|
|
||||||
|
| `providerConnectionId` | Explicit resolved connection identity when admission is possible |
|
||||||
|
| `provider` | Provider family used for copy and downstream dispatch |
|
||||||
|
| `targetScope` | Current tenant/provider scope metadata for run context |
|
||||||
|
| `reasonCode` | Stable blocked reason when admission is prevented |
|
||||||
|
| `reasonMeta` | Sanitized structured detail for translation and next steps |
|
||||||
|
| `nextSteps` | Resolution path metadata used by the shared presentation helper |
|
||||||
|
|
||||||
|
Rules:
|
||||||
|
|
||||||
|
- Every accepted covered start must resolve this state before queue admission.
|
||||||
|
- Blocked resolutions never admit a queued job.
|
||||||
|
|
||||||
|
## Derived Coordination Entities
|
||||||
|
|
||||||
|
### 1. ProtectedProviderScope
|
||||||
|
|
||||||
|
This is a logical concurrency boundary, not a new table.
|
||||||
|
|
||||||
|
| Field | Meaning |
|
||||||
|
|---|---|
|
||||||
|
| `tenantId` | Tenant boundary for the operation |
|
||||||
|
| `providerConnectionId` | Connection boundary for provider-backed conflict protection |
|
||||||
|
| `activeRunId` | Existing queued or running run occupying the scope, when present |
|
||||||
|
| `activeOperationType` | Operation currently using the protected scope |
|
||||||
|
|
||||||
|
Rules:
|
||||||
|
|
||||||
|
- At most one provider-backed operation may be accepted at a time for one protected scope.
|
||||||
|
- If the same operation type is already active on the scope, the result is `deduped`.
|
||||||
|
- If a different covered operation is already active on the same scope, the result is `scope_busy`.
|
||||||
|
|
||||||
|
### 2. PreflightStartResult
|
||||||
|
|
||||||
|
This is the shared logical result of a covered start attempt.
|
||||||
|
|
||||||
|
| Field | Meaning | Source |
|
||||||
|
|---|---|---|
|
||||||
|
| `internalState` | Current service-owned state such as `started`, `deduped`, `scope_busy`, or `blocked` | `ProviderOperationStartResult` |
|
||||||
|
| `operatorState` | Operator-facing vocabulary: `accepted`, `deduped`, `scope_busy`, `blocked` | shared presenter |
|
||||||
|
| `operationType` | Covered operation being started | action host + registry |
|
||||||
|
| `runId` | Canonical run for accepted, deduped, or scope-busy results, and optionally for blocked truth where already created | `OperationRun` |
|
||||||
|
| `providerConnectionId` | Resolved connection identity when known | gate + resolver |
|
||||||
|
| `reasonCode` | Stable problem class for blocked or other directed outcomes | current reason system |
|
||||||
|
| `nextSteps` | Structured resolution or follow-up guidance | next-step registry / helper |
|
||||||
|
|
||||||
|
Validation rules:
|
||||||
|
|
||||||
|
- `blocked` must never dispatch a background job.
|
||||||
|
- `accepted`, `deduped`, and `scope_busy` must point to the canonical run the operator should inspect.
|
||||||
|
- `accepted` must carry the explicit `provider_connection_id` into accepted-work context.
|
||||||
|
|
||||||
|
### 3. AcceptedProviderBackedRunContext
|
||||||
|
|
||||||
|
Logical accepted-work context persisted inside `OperationRun.context`.
|
||||||
|
|
||||||
|
| Field | Meaning |
|
||||||
|
|---|---|
|
||||||
|
| `provider_connection_id` | Explicit connection identity used by the accepted run |
|
||||||
|
| `provider` | Provider family label for display and diagnostics |
|
||||||
|
| `target_scope` | Sanitized tenant/provider scope metadata |
|
||||||
|
| `source_surface` | Action-host family such as tenant detail, provider connection, onboarding, restore, or directory sync |
|
||||||
|
| `initiator_user_id` | Starting actor |
|
||||||
|
| `operation_specific_context` | Existing per-operation context already needed by downstream jobs |
|
||||||
|
|
||||||
|
Rules:
|
||||||
|
|
||||||
|
- Jobs must receive the same `provider_connection_id` that was accepted at click time.
|
||||||
|
- Monitoring and notifications explain accepted work using this context rather than a runtime default connection lookup.
|
||||||
|
|
||||||
|
### 4. ProviderStartPresentation
|
||||||
|
|
||||||
|
Logical derived presentation output returned by the shared start-result helper.
|
||||||
|
|
||||||
|
| Field | Meaning |
|
||||||
|
|---|---|
|
||||||
|
| `title` | Standardized accepted/deduped/scope-busy/blocked headline |
|
||||||
|
| `body` | Short reason or queue message aligned with the spec vocabulary |
|
||||||
|
| `statusStyle` | Existing toast/notification severity |
|
||||||
|
| `viewRunAction` | Canonical open-run action when a run exists |
|
||||||
|
| `nextStepActions[]` | Optional resolution actions or follow-up links |
|
||||||
|
| `domainVerb` | Local action verb preserved from the host surface |
|
||||||
|
| `domainTarget` | Local object noun preserved from the host surface |
|
||||||
|
|
||||||
|
Rules:
|
||||||
|
|
||||||
|
- Accepted and deduped outcomes continue to use the existing `OperationUxPresenter` toast style.
|
||||||
|
- Blocked and scope-busy outcomes must no longer rely on page-local copy branches.
|
||||||
|
- Domain verb and target remain local so the shared contract does not flatten the product vocabulary into generic verbs.
|
||||||
|
|
||||||
|
### 5. OnboardingBootstrapAdmission
|
||||||
|
|
||||||
|
Logical onboarding-only coordination model built from existing draft state.
|
||||||
|
|
||||||
|
| Field | Meaning |
|
||||||
|
|---|---|
|
||||||
|
| `selectedOperationTypes[]` | Bootstrap operations the operator selected |
|
||||||
|
| `acceptedOperationType` | The one operation type admitted for the current submission |
|
||||||
|
| `pendingOperationTypes[]` | Remaining selected types still waiting to run |
|
||||||
|
| `runIdsByOperationType` | Existing and newly accepted run references persisted in draft state |
|
||||||
|
|
||||||
|
Rules:
|
||||||
|
|
||||||
|
- A bootstrap submission may accept at most one provider-backed operation for the protected scope.
|
||||||
|
- Remaining bootstrap selections stay in existing draft state rather than spawning a new orchestration entity.
|
||||||
|
- The onboarding step continues to be the only primary context; operators should not need a new workflow page to understand the pending follow-up.
|
||||||
|
|
||||||
|
## State Transitions
|
||||||
|
|
||||||
|
### Covered Start Attempt
|
||||||
|
|
||||||
|
```text
|
||||||
|
requested
|
||||||
|
-> blocked (no queue admission, blocked truth retained where applicable)
|
||||||
|
-> deduped (existing same-operation active run reused)
|
||||||
|
-> scope_busy (existing different-operation active run reused)
|
||||||
|
-> accepted (run admitted, queued job dispatched with explicit provider connection)
|
||||||
|
```
|
||||||
|
|
||||||
|
### Onboarding Bootstrap Submission
|
||||||
|
|
||||||
|
```text
|
||||||
|
selected operations
|
||||||
|
-> blocked / deduped / scope_busy (no new run admitted)
|
||||||
|
-> accepted operation + pending operations retained in draft state
|
||||||
|
```
|
||||||
|
|
||||||
|
## Non-Goals In The Data Model
|
||||||
|
|
||||||
|
- No new provider-start table or umbrella batch entity
|
||||||
|
- No new persisted summary or presentation artifact
|
||||||
|
- No platform-wide operation-type rename or taxonomy rewrite
|
||||||
|
- No new status family beyond the shared start-result vocabulary already required by the spec
|
||||||
228
specs/216-provider-dispatch-gate/plan.md
Normal file
228
specs/216-provider-dispatch-gate/plan.md
Normal file
@ -0,0 +1,228 @@
|
|||||||
|
# Implementation Plan: Provider-Backed Action Preflight and Dispatch Gate Unification
|
||||||
|
|
||||||
|
**Branch**: `216-provider-dispatch-gate` | **Date**: 2026-04-19 | **Spec**: [spec.md](./spec.md)
|
||||||
|
**Input**: Feature specification from `/specs/216-provider-dispatch-gate/spec.md`
|
||||||
|
|
||||||
|
## Summary
|
||||||
|
|
||||||
|
Unify every covered operator-triggered provider-backed start behind the existing `ProviderOperationStartGate` so preventable provider blockers, same-operation dedupe, and cross-operation scope conflicts are resolved before queue admission instead of surfacing later as execution failures. The implementation extends the current gate and registry, adds one thin shared start-result presentation helper over the existing Ops UX stack, standardizes the public start wording as `queued`, `already running`, `scope busy`, and `blocked` over the canonical `accepted`, `deduped`, `scope busy`, and `blocked` result model, pins accepted work to an explicit `provider_connection_id`, keeps scheduled and system-run Monitoring language compatible without widening click-time UX scope, and migrates the first-slice tenant, provider-connection, restore, directory-sync, and onboarding start surfaces without adding new persistence or a second orchestration framework.
|
||||||
|
|
||||||
|
## Technical Context
|
||||||
|
|
||||||
|
**Language/Version**: PHP 8.4.15, Laravel 12, Filament v5, Livewire v4
|
||||||
|
**Primary Dependencies**: Filament Resources/Pages/Actions, Livewire 4, Pest 4, `ProviderOperationStartGate`, `ProviderOperationRegistry`, `ProviderConnectionResolver`, `OperationRunService`, `ProviderNextStepsRegistry`, `ReasonPresenter`, `OperationUxPresenter`, `OperationRunLinks`
|
||||||
|
**Storage**: PostgreSQL via existing `operation_runs`, `provider_connections`, `managed_tenant_onboarding_sessions`, `restore_runs`, and tenant-owned runtime records; no new tables planned
|
||||||
|
**Testing**: Pest unit and focused feature tests for gate transitions, Filament action hosts, onboarding wizard flows, and Ops UX/run-detail alignment
|
||||||
|
**Validation Lanes**: `fast-feedback`, `confidence`
|
||||||
|
**Target Platform**: Laravel admin web app under Sail, rendered through Filament on Linux containers
|
||||||
|
**Project Type**: Monorepo with one Laravel platform application in `apps/platform` plus docs/spec artifacts at repository root
|
||||||
|
**Performance Goals**: Start preflight remains DB-only at click time, performs no Graph call before queue admission, adds no new remote latency source to the click-time path, and admits at most one accepted provider-backed run per protected scope
|
||||||
|
**Constraints**: No new provider-start framework, no parallel queue-admission path, no new persistence, no operation-type naming rewrite, no inline remote work on start surfaces, no drift from RBAC 404/403 semantics, no drift from Ops-UX 3-surface feedback, and no hidden bypass of the gate on covered start surfaces
|
||||||
|
**Scale/Scope**: First slice covers every current operator-triggered provider-backed start reachable from tenant-scoped surfaces, provider-connection surfaces, and onboarding: tenant verification, provider-connection check/inventory/compliance, restore execute, directory groups sync, role definitions sync, onboarding verification, and onboarding bootstrap
|
||||||
|
|
||||||
|
## Filament v5 Implementation Contract
|
||||||
|
|
||||||
|
- **Livewire v4.0+ compliance**: Preserved. This feature stays within existing Filament v5 and Livewire v4 patterns and does not introduce any Livewire v3-era APIs.
|
||||||
|
- **Provider registration location**: Unchanged. No panel/provider work is planned; existing panel providers remain registered in `bootstrap/providers.php`, not `bootstrap/app.php`.
|
||||||
|
- **Global search coverage**:
|
||||||
|
- `TenantResource` remains globally searchable and already has both view and edit pages.
|
||||||
|
- `EntraGroupResource` remains tenant-scoped for global search and already has a view page.
|
||||||
|
- `ProviderConnectionResource` keeps global search disabled (`$isGloballySearchable = false`).
|
||||||
|
- `RestoreRunResource` is not being newly enabled for global search in this feature; its existing view page remains available if current search behavior references it.
|
||||||
|
- **Destructive actions**: No new destructive actions are introduced. Restore execution remains a destructive-like flow that continues to rely on existing confirmation and authorization requirements; existing connection lifecycle actions remain `Action::make(...)->action(...)` based and keep `->requiresConfirmation()` where already required.
|
||||||
|
- **Asset strategy**: No new panel or shared assets are planned. Deployment expectations remain unchanged, including `cd apps/platform && php artisan filament:assets` when registered Filament assets change.
|
||||||
|
- **Testing plan**: Keep one supporting unit seam for the gate and shared presentation mapping, then prove the feature through focused Filament/Livewire feature coverage on tenant, provider-connection, onboarding, restore, directory-sync, and run-detail alignment. No browser lane or new heavy-governance family is planned.
|
||||||
|
|
||||||
|
## UI / Surface Guardrail Plan
|
||||||
|
|
||||||
|
- **Guardrail scope**: Changed surfaces across existing tenant-scoped provider-backed starts, provider-connection start actions, restore execute, directory sync starts, onboarding provider steps, and canonical Monitoring run drill-in language
|
||||||
|
- **Native vs custom classification summary**: Mixed shared-family change using native Filament actions, native notifications, and existing shared Ops UX helpers; no new custom shell or bespoke panel surface
|
||||||
|
- **Shared-family relevance**: Shared provider action family across `TenantResource`, `TenantVerificationReport`, `ProviderConnectionResource`, `RestoreRunResource`, `ListEntraGroups`, `ManagedTenantOnboardingWizard`, and related run-detail explanation surfaces
|
||||||
|
- **State layers in scope**: `page`, `detail`, `wizard-step`, and existing run-link drill-in; no new URL-query or shell ownership added
|
||||||
|
- **Handling modes by drift class or surface**: Hard-stop for blocked preflight and protected-scope conflicts; review-mandatory for any remaining direct dispatch path on a covered surface
|
||||||
|
- **Repository-signal treatment**: Review-mandatory because the feature changes a shared action-host family and canonical run-entry semantics
|
||||||
|
- **Special surface test profiles**: `standard-native-filament`, `workflow / wizard`, `monitoring-state-page`
|
||||||
|
- **Required tests or manual smoke**: `functional-core`, `state-contract`, `manual-smoke`
|
||||||
|
- **Exception path and spread control**: One named exception boundary only: onboarding bootstrap currently batches multiple provider-backed operations and must be normalized to one accepted protected-scope run without introducing a new orchestration framework
|
||||||
|
- **Active feature PR close-out entry**: `Guardrail`
|
||||||
|
|
||||||
|
## Constitution Check
|
||||||
|
|
||||||
|
*GATE: Passed before Phase 0 research. Re-checked after Phase 1 design: still passed with one bounded helper addition and no new persisted truth.*
|
||||||
|
|
||||||
|
| Gate | Status | Plan Notes |
|
||||||
|
|------|--------|------------|
|
||||||
|
| Inventory-first / read-write separation | PASS | The feature changes start admission and feedback only. Accepted work still executes asynchronously; restore keeps preview/confirmation/audit semantics and no new snapshot or runtime truth is introduced. |
|
||||||
|
| Single Graph contract path / no inline remote work | PASS | Start surfaces remain authorize + preflight + enqueue only. Existing queued jobs continue to own Graph calls through the current provider abstractions; no render-time or click-time Graph call is added. |
|
||||||
|
| RBAC, workspace isolation, tenant isolation | PASS | Covered starts remain inside existing tenant/workspace scopes. Non-members remain 404, members missing capability remain 403, and provider readiness messaging must not leak cross-tenant active runs or connection identity. |
|
||||||
|
| Run observability / Ops-UX 3-surface feedback | PASS | Covered starts continue to create or reuse canonical `OperationRun` truth. Accepted starts keep toast-only intent feedback, progress stays in active widgets/run detail, and terminal DB notifications remain owned by `OperationRunService` for accepted work only. |
|
||||||
|
| Proportionality / no premature abstraction / few layers | PASS | The plan extends the existing gate and registry and adds one thin presentation helper to absorb five-plus duplicated local result branches. No second gate, coordinator framework, or persisted summary layer is introduced. |
|
||||||
|
| UI semantics / Filament-native action discipline | PASS | Existing action hosts remain in place. The feature standardizes start-result language without adding new navigation models, new shell surfaces, or page-local status frameworks. |
|
||||||
|
| Test governance | PASS | Proof stays in unit plus focused feature lanes with explicit provider/tenant context opt-in. No new browser or heavy-governance family is required. |
|
||||||
|
|
||||||
|
## Test Governance Check
|
||||||
|
|
||||||
|
- **Test purpose / classification by changed surface**: `Unit` for gate transition and presentation mapping seams; `Feature` for tenant/provider/onboarding/restore/directory start surfaces and accepted-run reason alignment; `Heavy-Governance`: none; `Browser`: none
|
||||||
|
- **Affected validation lanes**: `fast-feedback`, `confidence`
|
||||||
|
- **Why this lane mix is the narrowest sufficient proof**: The business truth is server-side preflight, authorization, dedupe/scope busy handling, queue admission, and consistent operator feedback on existing action hosts. That behavior is fully provable with direct feature tests and one small unit seam; browser coverage would add cost without proving unique behavior.
|
||||||
|
- **Narrowest proving command(s)**:
|
||||||
|
- `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail bin pint --dirty --format agent`
|
||||||
|
- `export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Unit/Providers/ProviderOperationStartGateTest.php tests/Unit/Providers/ProviderOperationStartResultPresenterTest.php tests/Feature/ProviderConnections/ProviderDispatchGateStartSurfaceTest.php tests/Feature/Tenants/TenantProviderBackedActionStartTest.php tests/Feature/Workspaces/ManagedTenantOnboardingProviderStartTest.php tests/Feature/Restore/RestoreRunProviderStartTest.php tests/Feature/Directory/ProviderBackedDirectoryStartTest.php tests/Feature/Onboarding/OnboardingRbacSemanticsTest.php tests/Feature/Filament/RestoreRunUiEnforcementTest.php tests/Feature/Guards/ActionSurfaceContractTest.php tests/Feature/Operations/ProviderBackedRunReasonAlignmentTest.php tests/Feature/OpsUx/CanonicalViewRunLinksTest.php tests/Feature/Operations/OperationRunBlockedExecutionPresentationTest.php tests/Feature/OpsUx/Constitution/JobDbNotificationGuardTest.php tests/Feature/OpsUx/NoQueuedDbNotificationsTest.php tests/Feature/Guards/ProviderDispatchGateCoverageTest.php tests/Feature/Guards/TestLaneManifestTest.php`
|
||||||
|
- **Fixture / helper / factory / seed / context cost risks**: Provider-backed start fixtures need tenant membership, explicit provider connections, active-run setup, and onboarding draft state; these remain explicit per test and must not become default helpers.
|
||||||
|
- **Expensive defaults or shared helper growth introduced?**: No. Existing factories and helpers remain opt-in; no global provider/workspace bootstrap is planned.
|
||||||
|
- **Heavy-family additions, promotions, or visibility changes**: None
|
||||||
|
- **Surface-class relief / special coverage rule**: Standard native Filament relief for resource/page action hosts; onboarding uses a named wizard-step profile but still remains feature-testable without browser automation.
|
||||||
|
- **Closing validation and reviewer handoff**: Re-run `pint`, then the focused test command above, then do one human smoke pass for blocked, deduped, scope busy, and queued outcomes across tenant, provider-connection, onboarding, and restore. Reviewers should confirm there is no remaining direct-dispatch bypass on a route-bounded covered surface, that scheduled or system-run Monitoring wording stays compatible with the same problem classes, and that no new custom completion notification path was introduced.
|
||||||
|
- **Budget / baseline / trend follow-up**: None expected; document in feature if the new focused suite materially expands runtime beyond ordinary feature-local upkeep.
|
||||||
|
- **Review-stop questions**: Did any coverage drift into browser/heavy lanes without a unique proving need? Did any helper start providing implicit tenant/provider context? Did any route-bounded covered start remain on local `ensureRun*/dispatch` logic? Did scheduled or system-run compatibility widen into click-time UX scope? Did any test assert presentation details that should stay in the shared presenter seam only?
|
||||||
|
- **Escalation path**: `document-in-feature`
|
||||||
|
- **Active feature PR close-out entry**: `Guardrail`
|
||||||
|
- **Why no dedicated follow-up spec is needed**: This change is feature-local hardening around existing start surfaces. Only recurring operation-type normalization or structural lane-cost drift would justify a follow-up spec.
|
||||||
|
|
||||||
|
## Project Structure
|
||||||
|
|
||||||
|
### Documentation (this feature)
|
||||||
|
|
||||||
|
```text
|
||||||
|
specs/216-provider-dispatch-gate/
|
||||||
|
├── plan.md
|
||||||
|
├── research.md
|
||||||
|
├── data-model.md
|
||||||
|
├── quickstart.md
|
||||||
|
├── contracts/
|
||||||
|
│ └── provider-dispatch-gate.logical.openapi.yaml
|
||||||
|
└── tasks.md
|
||||||
|
```
|
||||||
|
|
||||||
|
### Source Code (repository root)
|
||||||
|
|
||||||
|
```text
|
||||||
|
apps/platform/
|
||||||
|
├── app/
|
||||||
|
│ ├── Filament/
|
||||||
|
│ │ ├── Pages/Operations/TenantlessOperationRunViewer.php
|
||||||
|
│ │ ├── Pages/Workspaces/ManagedTenantOnboardingWizard.php
|
||||||
|
│ │ ├── Resources/EntraGroupResource/Pages/ListEntraGroups.php
|
||||||
|
│ │ ├── Resources/OperationRunResource.php
|
||||||
|
│ │ ├── Resources/ProviderConnectionResource.php
|
||||||
|
│ │ ├── Resources/RestoreRunResource.php
|
||||||
|
│ │ └── Resources/TenantResource.php
|
||||||
|
│ ├── Notifications/
|
||||||
|
│ │ └── OperationRunCompleted.php
|
||||||
|
│ ├── Services/
|
||||||
|
│ │ ├── Directory/RoleDefinitionsSyncService.php
|
||||||
|
│ │ ├── Providers/ProviderConnectionResolver.php
|
||||||
|
│ │ ├── Providers/ProviderOperationRegistry.php
|
||||||
|
│ │ ├── Providers/ProviderOperationStartGate.php
|
||||||
|
│ │ └── Verification/StartVerification.php
|
||||||
|
│ └── Support/
|
||||||
|
│ ├── OperationRunLinks.php
|
||||||
|
│ ├── OpsUx/OperationUxPresenter.php
|
||||||
|
│ ├── OpsUx/ProviderOperationStartResultPresenter.php
|
||||||
|
│ ├── ReasonTranslation/
|
||||||
|
│ └── Providers/
|
||||||
|
└── tests/
|
||||||
|
├── Feature/
|
||||||
|
│ ├── Directory/
|
||||||
|
│ ├── Filament/
|
||||||
|
│ ├── Guards/
|
||||||
|
│ ├── Onboarding/
|
||||||
|
│ ├── Operations/
|
||||||
|
│ ├── OpsUx/
|
||||||
|
│ ├── ProviderConnections/
|
||||||
|
│ ├── Restore/
|
||||||
|
│ ├── Tenants/
|
||||||
|
│ └── Workspaces/
|
||||||
|
├── Support/
|
||||||
|
└── Unit/Providers/
|
||||||
|
```
|
||||||
|
|
||||||
|
**Structure Decision**: Single Laravel application inside the monorepo. All runtime work lands in `apps/platform`, while planning artifacts stay under `specs/216-provider-dispatch-gate`.
|
||||||
|
|
||||||
|
## Complexity Tracking
|
||||||
|
|
||||||
|
No constitutional violation is planned. One bounded addition is tracked explicitly because it adds a thin presentation layer.
|
||||||
|
|
||||||
|
| Violation | Why Needed | Simpler Alternative Rejected Because |
|
||||||
|
|-----------|------------|-------------------------------------|
|
||||||
|
| BLOAT-001 bounded presenter helper | Five-plus real start surfaces already duplicate blocked/deduped/scope busy/accepted notification branches; one shared helper is the narrowest way to keep one operator vocabulary without inventing a framework | Keeping page-local branches would fail FR-216-008/009, preserve inconsistent copy drift, and force every new covered surface to duplicate the same start contract again |
|
||||||
|
|
||||||
|
## Proportionality Review
|
||||||
|
|
||||||
|
- **Current operator problem**: Operators can trigger provider-backed actions that look accepted at click time but only fail later because provider connection, consent, credential, or protected-scope prerequisites were missing. The same blocker also renders differently across tenant, provider-connection, onboarding, restore, and directory-sync surfaces.
|
||||||
|
- **Existing structure is insufficient because**: The canonical gate already exists, but only a subset of surfaces use it and those surfaces still duplicate result rendering locally. Legacy `ensureRun*/dispatch` starts also resolve provider identity too late, which allows runtime default drift and inconsistent click-time feedback.
|
||||||
|
- **Narrowest correct implementation**: Extend the existing `ProviderOperationRegistry` and `ProviderOperationStartGate`, add one thin start-result presentation helper over the current Ops UX primitives, and migrate the first-slice action hosts. Keep blocked/accepted truth in existing `OperationRun` records and do not add new persistence.
|
||||||
|
- **Ownership cost created**: One small shared presentation helper, a broader but still focused feature test surface, and explicit registry entries for each newly covered operation type.
|
||||||
|
- **Alternative intentionally rejected**: A new provider-start coordinator/orchestration framework or a second queue-admission path. Those would duplicate existing gate behavior, broaden the slice into architecture work, and violate FR-216-014.
|
||||||
|
- **Release truth**: Current-release truth. The problem exists today on current operator-facing start surfaces.
|
||||||
|
|
||||||
|
## Phase 0 Research Summary
|
||||||
|
|
||||||
|
- Reuse the current gate and registry; adoption breadth is the gap, not missing queue-admission infrastructure.
|
||||||
|
- Resolve and pin `provider_connection_id` at dispatch time for every accepted covered start to prevent runtime default drift.
|
||||||
|
- Preserve blocked preflight truth as canonical blocked start state, but never admit background work for blocked starts.
|
||||||
|
- Add one shared presentation helper over `OperationUxPresenter`, `ReasonPresenter`, `ProviderNextStepsRegistry`, and `OperationRunLinks` rather than leaving local `Notification::make()` branches in place.
|
||||||
|
- Keep the operator-facing start vocabulary explicit: the canonical outcomes remain `accepted`, `deduped`, `scope busy`, and `blocked`, while public wording stays `queued`, `already running`, `scope busy`, and `blocked`.
|
||||||
|
- Keep the first implementation slice bounded to tenant, provider-connection, onboarding, restore, and directory start surfaces named by the spec; workspace-level baseline/evidence/review generators remain outside this route-bounded slice.
|
||||||
|
- Do not combine dispatch-gate hardening with operation-type normalization; legacy write-time operation strings stay as-is in this feature.
|
||||||
|
- Keep scheduled and system-triggered compatibility bounded to Monitoring-side reason reuse and initiator-aware notification behavior; no click-time UX is added for those runs.
|
||||||
|
- Normalize onboarding bootstrap from multi-start batch admission to sequential protected-scope admission without adding a new orchestration framework.
|
||||||
|
|
||||||
|
## Phase 1 Design Summary
|
||||||
|
|
||||||
|
- `data-model.md` documents the feature as a service-owned extension over existing `ProviderConnection`, `OperationRun`, and onboarding draft state plus new logical models for protected scope, preflight result, accepted run context, and shared presentation output.
|
||||||
|
- `contracts/provider-dispatch-gate.logical.openapi.yaml` defines the internal action-start contract for tenant, provider-connection, restore, directory-sync, and onboarding action hosts plus canonical run-detail output.
|
||||||
|
- `quickstart.md` defines the focused verification path for blocked, deduped, scope busy, and accepted results, including the onboarding bootstrap sequentialization case.
|
||||||
|
|
||||||
|
## Implementation Strategy
|
||||||
|
|
||||||
|
1. **Extend canonical gate coverage**
|
||||||
|
- Expand `ProviderOperationRegistry` to include every first-slice covered operation type.
|
||||||
|
- Keep the existing `ProviderOperationStartGate` as the single queue-admission path.
|
||||||
|
- Ensure each migrated start resolves provider access before dispatch and passes the explicit `provider_connection_id` into both `OperationRun` context and job args.
|
||||||
|
|
||||||
|
2. **Centralize start-result presentation**
|
||||||
|
- Add one shared helper that consumes `ProviderOperationStartResult` and emits the standardized operator feedback mapping: `accepted -> queued`, `deduped -> already running`, `scope busy -> scope busy`, and `blocked -> blocked`.
|
||||||
|
- Reuse `OperationUxPresenter` for queued and already running toast semantics and `ReasonPresenter` plus next-step metadata for blocked and scope busy outcomes.
|
||||||
|
- Keep domain verbs and target nouns local to each action host while unifying outcome vocabulary and run-link behavior.
|
||||||
|
|
||||||
|
3. **Migrate already-gated surfaces first**
|
||||||
|
- Refactor `StartVerification`, `TenantResource`, `TenantVerificationReport`, `ProviderConnectionResource`, and the onboarding verification step to call the shared presentation helper instead of keeping local result-branching code.
|
||||||
|
- Preserve current protection while removing duplicated operator copy paths.
|
||||||
|
|
||||||
|
4. **Migrate legacy direct-dispatch starts**
|
||||||
|
- Replace local `ensureRun*/dispatch` logic on restore execute, directory groups sync, role definitions sync, and onboarding bootstrap with the canonical gate.
|
||||||
|
- Keep destructive restore semantics intact: existing preview, warning, confirmation, and authorization remain unchanged; only queue admission and feedback unify.
|
||||||
|
|
||||||
|
5. **Normalize onboarding bootstrap sequencing**
|
||||||
|
- Convert bootstrap admission from “start every selected provider-backed operation immediately” to “accept one protected-scope provider-backed operation at a time.”
|
||||||
|
- Reuse existing onboarding draft state to retain pending bootstrap selections and run references instead of introducing an umbrella batch entity or new orchestration model.
|
||||||
|
|
||||||
|
6. **Align accepted-run diagnostics**
|
||||||
|
- Ensure canonical run detail and terminal notification reason translation stay aligned with the shared start-result problem classes whenever they describe the same accepted operation.
|
||||||
|
- Keep scheduled and system-triggered Monitoring reason reuse in the same public language family without adding click-time start UX or breaking initiator-only notification policy.
|
||||||
|
- Keep blocked starts distinguishable from accepted execution failures in monitoring and audit surfaces.
|
||||||
|
|
||||||
|
7. **Backfill proof and regression guards**
|
||||||
|
- Extend the gate unit suite for newly registered operation types and onboarding sequencing.
|
||||||
|
- Add one route-bounded guard that inventories covered start hosts and fails if any first-slice action still bypasses `ProviderOperationStartGate` through local `ensureRun*/dispatch` logic.
|
||||||
|
- Add focused feature coverage around each migrated action host and around cross-surface reason vocabulary alignment.
|
||||||
|
|
||||||
|
## Risks and Mitigations
|
||||||
|
|
||||||
|
- **Onboarding bootstrap behavior change**: The current wizard can admit more than one provider-backed run for one connection. The mitigation is to normalize the flow to one accepted protected-scope run per submission and keep the rest as pending follow-up using existing draft state.
|
||||||
|
- **Legacy operation-type aliases**: Directory sync operations currently use legacy write-time names. The mitigation is to keep those names stable in this feature and avoid widening the scope into operation taxonomy cleanup.
|
||||||
|
- **Restore flow regression risk**: Restore is destructive-like and already confirmation-heavy. The mitigation is to change only the start path and feedback contract, not the preview/confirmation/authorization model.
|
||||||
|
- **Hidden direct-dispatch bypasses**: Additional provider-backed starts may exist outside obvious action hosts. The mitigation is to treat any remaining first-slice `ensureRun*/dispatch` path as a review blocker and keep the route-bounded scope explicit.
|
||||||
|
- **Over-testing via expensive fixtures**: Provider-backed starts need tenant, provider, and active-run context. The mitigation is to keep those fixtures opt-in and avoid a new shared “full provider world” helper.
|
||||||
|
|
||||||
|
## Post-Design Re-check
|
||||||
|
|
||||||
|
Phase 0 and Phase 1 outputs resolve the earlier design unknowns without introducing new gates, new persisted truth, or a second UI semantics framework. The plan remains constitution-compliant, bounded to current-release operator pain, and ready for `/speckit.tasks`.
|
||||||
|
|
||||||
|
## Implementation Close-Out
|
||||||
|
|
||||||
|
- **Final lane outcome**: Focused verification completed with `pint --dirty --format agent`, the documented quickstart suite, the broader migrated-surface regression suite, and the route-bounded guard/manifest close-out suite all passing during implementation.
|
||||||
|
- **Route-bounded covered-surface audit**: `tests/Feature/Guards/ProviderDispatchGateCoverageTest.php` now guards the first-slice route-bounded provider-backed starts so tenant verification, provider-connection actions, restore execute, directory sync, role definitions sync, and onboarding bootstrap stay on canonical gate-owned entry points rather than regressing to local `ensureRun` / `dispatchOrFail` admission paths.
|
||||||
|
- **Guardrail close-out**: Livewire v4.0+ compliance is preserved, provider registration remains in `bootstrap/providers.php`, no new global-search behavior was introduced, destructive restore semantics remain on existing confirmation/authorization paths, and no new Filament assets were added beyond the standing `php artisan filament:assets` deployment expectation when registered assets change.
|
||||||
|
- **Test governance disposition**: `document-in-feature` remains the explicit disposition. The proof stays in unit plus focused feature lanes, with one new heavy-governance guard family (`provider-dispatch-gate-coverage`) recorded in the manifest instead of widening into browser or new heavy workflow suites.
|
||||||
164
specs/216-provider-dispatch-gate/quickstart.md
Normal file
164
specs/216-provider-dispatch-gate/quickstart.md
Normal file
@ -0,0 +1,164 @@
|
|||||||
|
# Quickstart: Provider-Backed Action Preflight and Dispatch Gate Unification
|
||||||
|
|
||||||
|
## Goal
|
||||||
|
|
||||||
|
Validate that every covered operator-triggered provider-backed start now resolves click-time provider blockers, same-operation dedupe, and protected-scope conflicts before queue admission, while preserving one shared operator vocabulary and truthful Monitoring → Operations drill-in for accepted work.
|
||||||
|
|
||||||
|
## Prerequisites
|
||||||
|
|
||||||
|
1. Start Sail if it is not already running.
|
||||||
|
2. Use a tenant member with the exact capability required by the target action host.
|
||||||
|
3. Prepare at least one tenant with:
|
||||||
|
- one usable provider connection,
|
||||||
|
- one blocked provider condition such as missing default connection, missing consent, or unusable credentials,
|
||||||
|
- one active queued or running provider-backed `OperationRun` for same-operation dedupe checks,
|
||||||
|
- one active queued or running provider-backed `OperationRun` for cross-operation `scope_busy` checks,
|
||||||
|
- one restore run ready for execute validation,
|
||||||
|
- one onboarding session with provider verification and bootstrap steps available.
|
||||||
|
4. Keep a second user or session available if you want to validate tenant isolation and initiator-only notification behavior.
|
||||||
|
|
||||||
|
## Focused Automated Verification
|
||||||
|
|
||||||
|
Run formatting first:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail bin pint --dirty --format agent
|
||||||
|
```
|
||||||
|
|
||||||
|
Then run the narrowest focused suite that proves the contract:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail artisan test --compact \
|
||||||
|
tests/Unit/Providers/ProviderOperationStartGateTest.php \
|
||||||
|
tests/Unit/Providers/ProviderOperationStartResultPresenterTest.php \
|
||||||
|
tests/Feature/ProviderConnections/ProviderDispatchGateStartSurfaceTest.php \
|
||||||
|
tests/Feature/Tenants/TenantProviderBackedActionStartTest.php \
|
||||||
|
tests/Feature/Workspaces/ManagedTenantOnboardingProviderStartTest.php \
|
||||||
|
tests/Feature/Restore/RestoreRunProviderStartTest.php \
|
||||||
|
tests/Feature/Directory/ProviderBackedDirectoryStartTest.php \
|
||||||
|
tests/Feature/Operations/ProviderBackedRunReasonAlignmentTest.php
|
||||||
|
```
|
||||||
|
|
||||||
|
If only one action host changed during implementation, rerun the smallest relevant subset before broadening to the full focused suite.
|
||||||
|
|
||||||
|
## Manual Validation Pass
|
||||||
|
|
||||||
|
### 1. Tenant-scoped start surfaces
|
||||||
|
|
||||||
|
Trigger a covered tenant-scoped provider-backed action such as verification, restore execute, or directory sync in these four conditions:
|
||||||
|
|
||||||
|
- blocked by missing or unusable provider access,
|
||||||
|
- deduped by an equivalent active run,
|
||||||
|
- `scope_busy` because a different covered operation is already active for the same provider connection,
|
||||||
|
- accepted with a valid explicit provider connection.
|
||||||
|
|
||||||
|
Confirm that:
|
||||||
|
|
||||||
|
- no blocked case queues background work,
|
||||||
|
- the operator sees one consistent accepted/deduped/scope-busy/blocked vocabulary,
|
||||||
|
- each non-blocked result points to the correct existing or accepted run,
|
||||||
|
- and no page-local notification copy contradicts the shared contract.
|
||||||
|
|
||||||
|
### 2. Provider-connection resource surfaces
|
||||||
|
|
||||||
|
From the provider-connection list and detail pages, start the covered connection-scoped actions.
|
||||||
|
|
||||||
|
Confirm that:
|
||||||
|
|
||||||
|
- the same blocker or active-run condition produces the same outcome category used on tenant surfaces,
|
||||||
|
- the provider connection identity shown to the operator matches the accepted run context,
|
||||||
|
- and connection lifecycle actions remain where they were before this feature.
|
||||||
|
|
||||||
|
### 3. Restore execute flow
|
||||||
|
|
||||||
|
Start a restore execution from the existing restore surface.
|
||||||
|
|
||||||
|
Confirm that:
|
||||||
|
|
||||||
|
- existing preview, warning, and confirmation behavior remains intact,
|
||||||
|
- blocked preflight prevents queue admission before execution starts,
|
||||||
|
- accepted execution opens the canonical run link,
|
||||||
|
- and restore-specific destructive semantics are unchanged apart from the unified start contract.
|
||||||
|
|
||||||
|
### 4. Onboarding verification and bootstrap
|
||||||
|
|
||||||
|
Use the onboarding wizard to trigger the provider verification step and the bootstrap step.
|
||||||
|
|
||||||
|
Confirm that:
|
||||||
|
|
||||||
|
- provider verification uses the same outcome categories as the other covered action hosts,
|
||||||
|
- bootstrap no longer admits multiple provider-backed runs concurrently for the same connection,
|
||||||
|
- a blocked, deduped, or `scope_busy` bootstrap attempt does not leave hidden extra queued work behind,
|
||||||
|
- and any remaining bootstrap work is visible as a follow-up state on the existing step rather than on a new workflow page.
|
||||||
|
|
||||||
|
### 5. Monitoring → Operations run detail
|
||||||
|
|
||||||
|
Open the run detail pages linked from accepted, deduped, and `scope_busy` results.
|
||||||
|
|
||||||
|
Confirm that:
|
||||||
|
|
||||||
|
- accepted work shows the same provider connection identity that was chosen at click time,
|
||||||
|
- run detail and terminal notification reason translation align with the same problem class used at the start surface,
|
||||||
|
- blocked starts remain distinguishable from accepted work that later fails,
|
||||||
|
- and no cross-tenant provider identity or active-run existence leaks through canonical monitoring views.
|
||||||
|
|
||||||
|
### 6. Authorization and isolation non-regression
|
||||||
|
|
||||||
|
Confirm that:
|
||||||
|
|
||||||
|
- non-members still receive 404 behavior and learn nothing about provider readiness or active runs in other tenants,
|
||||||
|
- members missing the required capability still receive server-enforced 403 on execution,
|
||||||
|
- accepted-run notifications remain initiator-only,
|
||||||
|
- and no new start action bypasses the central capability registry.
|
||||||
|
|
||||||
|
### 7. Ten-second scan check
|
||||||
|
|
||||||
|
Timebox the first visible read of one result from each action-host family:
|
||||||
|
|
||||||
|
- tenant-scoped start surface,
|
||||||
|
- provider-connection surface,
|
||||||
|
- onboarding provider step,
|
||||||
|
- Monitoring run detail.
|
||||||
|
|
||||||
|
Confirm that within 10 seconds the operator can answer:
|
||||||
|
|
||||||
|
- did the operation get accepted,
|
||||||
|
- if not, why not,
|
||||||
|
- and what should happen next.
|
||||||
|
|
||||||
|
## Final Verification
|
||||||
|
|
||||||
|
Before merge, re-run:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail bin pint --dirty --format agent
|
||||||
|
export PATH="/bin:/usr/bin:/usr/local/bin:$PATH" && cd apps/platform && ./vendor/bin/sail artisan test --compact \
|
||||||
|
tests/Unit/Providers/ProviderOperationStartGateTest.php \
|
||||||
|
tests/Unit/Providers/ProviderOperationStartResultPresenterTest.php \
|
||||||
|
tests/Feature/Tenants/TenantProviderBackedActionStartTest.php \
|
||||||
|
tests/Feature/ProviderConnections/ProviderDispatchGateStartSurfaceTest.php \
|
||||||
|
tests/Feature/ProviderConnections/ProviderConnectionHealthCheckStartSurfaceTest.php \
|
||||||
|
tests/Feature/ProviderConnections/ProviderOperationBlockedGuidanceSpec081Test.php \
|
||||||
|
tests/Feature/Filament/TenantVerificationReportWidgetTest.php \
|
||||||
|
tests/Feature/Restore/RestoreRunProviderStartTest.php \
|
||||||
|
tests/Feature/RestoreRunWizardExecuteTest.php \
|
||||||
|
tests/Feature/RestoreRunRerunTest.php \
|
||||||
|
tests/Feature/Directory/ProviderBackedDirectoryStartTest.php \
|
||||||
|
tests/Feature/DirectoryGroups/StartSyncFromGroupsPageTest.php \
|
||||||
|
tests/Feature/DirectoryGroups/StartSyncTest.php \
|
||||||
|
tests/Feature/TenantRBAC/RoleDefinitionsSyncNowTest.php \
|
||||||
|
tests/Feature/Workspaces/ManagedTenantOnboardingProviderStartTest.php \
|
||||||
|
tests/Feature/ManagedTenantOnboardingWizardTest.php \
|
||||||
|
tests/Feature/Onboarding/OnboardingRbacSemanticsTest.php \
|
||||||
|
tests/Feature/Filament/RestoreRunUiEnforcementTest.php \
|
||||||
|
tests/Feature/Operations/ProviderBackedRunReasonAlignmentTest.php \
|
||||||
|
tests/Feature/OpsUx/CanonicalViewRunLinksTest.php \
|
||||||
|
tests/Feature/Operations/OperationRunBlockedExecutionPresentationTest.php \
|
||||||
|
tests/Feature/OpsUx/Constitution/JobDbNotificationGuardTest.php \
|
||||||
|
tests/Feature/OpsUx/NoQueuedDbNotificationsTest.php \
|
||||||
|
tests/Feature/Guards/ProviderDispatchGateCoverageTest.php \
|
||||||
|
tests/Feature/Guards/ActionSurfaceContractTest.php \
|
||||||
|
tests/Feature/Guards/TestLaneManifestTest.php
|
||||||
|
```
|
||||||
|
|
||||||
|
If broader confidence is wanted after the focused suite, offer a full application test run as a separate follow-up step.
|
||||||
73
specs/216-provider-dispatch-gate/research.md
Normal file
73
specs/216-provider-dispatch-gate/research.md
Normal file
@ -0,0 +1,73 @@
|
|||||||
|
# Research: Provider-Backed Action Preflight and Dispatch Gate Unification
|
||||||
|
|
||||||
|
## Decision 1: Extend the existing gate and registry; do not create a second provider-start framework
|
||||||
|
|
||||||
|
- Decision: Migrate every covered operator-triggered provider-backed start onto the existing `ProviderOperationStartGate` by expanding `ProviderOperationRegistry` and updating the current action hosts to call the same queue-admission path.
|
||||||
|
- Rationale: The repo already has the right hardening seam. `ProviderOperationStartGate` resolves connection readiness, blocks missing provider prerequisites before queue admission, dedupes same-operation starts, blocks conflicting operations on the same protected scope, and returns a shared result object. The problem is adoption breadth, not missing infrastructure.
|
||||||
|
- Alternatives considered:
|
||||||
|
- Introduce a new `ProviderStartCoordinator` or second orchestration pipeline. Rejected because it would duplicate the gate, widen the change into architecture work, and violate FR-216-014.
|
||||||
|
- Keep local `ensureRun*/dispatch` flows and copy the same preflight into each action. Rejected because that preserves semantic drift and repeats the same blocker logic across tenant, provider-connection, restore, directory, and onboarding surfaces.
|
||||||
|
|
||||||
|
## Decision 2: Accepted work must pin `provider_connection_id` at dispatch time
|
||||||
|
|
||||||
|
- Decision: Every migrated accepted start resolves its provider connection before queue admission and persists the chosen `provider_connection_id` into `OperationRun.context` and job arguments.
|
||||||
|
- Rationale: Existing non-gate starts often resolve the default connection at job execution time. That allows runtime default drift if the default connection changes between click time and job execution. Dispatch-time pinning is already the correct pattern on the gated starts and is required by FR-216-006.
|
||||||
|
- Alternatives considered:
|
||||||
|
- Continue resolving the default connection inside queued jobs. Rejected because the same operator action can execute against a different connection than the one implied at click time.
|
||||||
|
- Store only a display label or provider name in context. Rejected because monitoring, dedupe, and run-detail explanation need the stable connection identity, not only presentation data.
|
||||||
|
|
||||||
|
## Decision 3: Keep blocked starts as canonical prevented-from-starting truth, but never admit background work
|
||||||
|
|
||||||
|
- Decision: A blocked preflight continues to produce canonical blocked-start truth where the current gate already does so, but blocked starts never enqueue jobs and must remain distinguishable from accepted runs that later fail during execution.
|
||||||
|
- Rationale: FR-216-004 and FR-216-015 require the product to stop turning preventable prerequisite problems into ordinary execution failures. Preserving canonical blocked truth keeps auditability and resolution links intact while still ensuring no remote work was accepted.
|
||||||
|
- Alternatives considered:
|
||||||
|
- Show only an ephemeral toast and create no run truth at all. Rejected because operators and support would lose the canonical blocked audit trail and linked next-step metadata.
|
||||||
|
- Queue the work and let the job fail fast. Rejected because that is the failure mode this spec is correcting.
|
||||||
|
|
||||||
|
## Decision 4: Standardize operator feedback through one thin presentation helper layered over the existing Ops UX stack
|
||||||
|
|
||||||
|
- Decision: Add one narrow start-result presentation helper that consumes `ProviderOperationStartResult` and composes the existing `OperationUxPresenter`, `ReasonPresenter`, `ProviderNextStepsRegistry`, and `OperationRunLinks` building blocks.
|
||||||
|
- Rationale: The current gated surfaces already prove that the shared start result is viable, but they still duplicate local `if/else` notification code across tenant, widget, provider-connection, and onboarding surfaces. A thin presenter absorbs that duplication without introducing a new UI semantics framework.
|
||||||
|
- Alternatives considered:
|
||||||
|
- Leave each surface with its own `Notification::make()` branching. Rejected because it fails FR-216-008/009 and guarantees future copy drift.
|
||||||
|
- Invent a larger badge/explanation framework for provider starts. Rejected because the repo constitution explicitly discourages turning UI semantics into their own mandatory architecture.
|
||||||
|
|
||||||
|
## Decision 5: The first slice is bounded by the spec routes, not by every possible provider-backed job in the codebase
|
||||||
|
|
||||||
|
- Decision: The first implementation slice covers every current operator-triggered provider-backed start reachable from tenant-scoped surfaces, provider-connection surfaces, and onboarding: tenant verification, provider-connection check/inventory/compliance actions, restore execute, directory groups sync, role definitions sync, onboarding verification, and onboarding bootstrap.
|
||||||
|
- Rationale: FR-216-012 defines the route-bounded first slice. Read-only exploration also surfaced workspace-level baseline/evidence/review generators and other background operations, but those lie outside the spec's primary routes and would expand this hardening feature into adjacent workflow areas.
|
||||||
|
- Alternatives considered:
|
||||||
|
- Expand the first slice to every provider-backed operation in the entire repo. Rejected because it would overshoot the spec and slow delivery.
|
||||||
|
- Limit the slice to restore only. Rejected because the same operator pain already exists across onboarding, directory sync, and provider-connection action hosts.
|
||||||
|
|
||||||
|
## Decision 6: Keep current write-time operation type strings in this feature; do not combine hardening with operation-type normalization
|
||||||
|
|
||||||
|
- Decision: Migrated starts keep their current write-time operation type strings, even where `OperationCatalog` exposes newer aliases or canonical dotted names.
|
||||||
|
- Rationale: The operator problem here is queue admission and start-result consistency, not operation-type taxonomy cleanup. Renaming start types would widen the blast radius into monitoring, audit, test fixtures, and historical read-model expectations.
|
||||||
|
- Alternatives considered:
|
||||||
|
- Rename legacy operation types such as `entra_group_sync` or `directory_role_definitions.sync` during the gate migration. Rejected because that is a separate normalization concern and not required to deliver block-before-queue semantics.
|
||||||
|
- Add a new translation layer inside the gate just for this feature. Rejected because that adds semantic machinery without solving the core operator problem.
|
||||||
|
|
||||||
|
## Decision 7: Onboarding bootstrap must normalize to sequential protected-scope admission
|
||||||
|
|
||||||
|
- Decision: The onboarding wizard can no longer admit multiple provider-backed operations concurrently for the same provider connection. The existing wizard flow remains, but queue admission becomes sequential: one accepted provider-backed run per protected scope, with remaining selected bootstrap work retained as follow-up state.
|
||||||
|
- Rationale: The current bootstrap implementation explicitly starts more than one provider-backed run for the same connection if no run is active at the beginning of the transaction. That conflicts with FR-216-007 and SC-216-003, which require click-time conflict protection and at most one accepted provider-backed operation per protected scope.
|
||||||
|
- Alternatives considered:
|
||||||
|
- Keep the existing batch-start bypass for onboarding only. Rejected because it would leave a permanent exception to the canonical start contract inside the first slice.
|
||||||
|
- Introduce a new umbrella bootstrap orchestration entity. Rejected because it would create a second start framework and unnecessary new semantics.
|
||||||
|
|
||||||
|
## Decision 8: The operator contract uses accepted/deduped/scope-busy/blocked vocabulary, even if the internal result object keeps `started` for compatibility
|
||||||
|
|
||||||
|
- Decision: The shared operator-facing contract standardizes on `accepted`, `deduped`, `scope busy`, and `blocked`, while the internal `ProviderOperationStartResult` can retain its current `started` variant in this slice if that avoids unnecessary churn.
|
||||||
|
- Rationale: FR-216-002 is about operator-visible start outcomes. The narrowest implementation is to keep internal compatibility where it helps while converging every visible surface and logical contract on the same operator vocabulary.
|
||||||
|
- Alternatives considered:
|
||||||
|
- Rename every internal `started` code path immediately. Rejected because it widens refactor scope without increasing product certainty.
|
||||||
|
- Keep operator-facing copy inconsistent with the spec language. Rejected because that would fail the core purpose of the feature.
|
||||||
|
|
||||||
|
## Decision 9: Testing should stay feature-first with one supporting unit seam
|
||||||
|
|
||||||
|
- Decision: Reuse and extend the existing `ProviderOperationStartGateTest` unit suite, then prove the feature through focused feature coverage on real Filament action hosts and canonical run-detail reason alignment.
|
||||||
|
- Rationale: The business truth is server-side authorization, preflight, dedupe, scope-busy handling, queue admission, and cross-surface reason consistency. Those are best proven with targeted feature tests against current action hosts, not with browser-heavy coverage or a new dedicated presenter harness.
|
||||||
|
- Alternatives considered:
|
||||||
|
- Rely mainly on browser tests. Rejected because the critical behavior is server-owned and already easier to prove through existing resource/page test families.
|
||||||
|
- Create a large presenter-only test harness. Rejected because it would shift effort from the real action hosts to indirection created only for the test suite.
|
||||||
237
specs/216-provider-dispatch-gate/spec.md
Normal file
237
specs/216-provider-dispatch-gate/spec.md
Normal file
@ -0,0 +1,237 @@
|
|||||||
|
# Feature Specification: Provider-Backed Action Preflight and Dispatch Gate Unification
|
||||||
|
|
||||||
|
**Feature Branch**: `216-provider-dispatch-gate`
|
||||||
|
**Created**: 2026-04-19
|
||||||
|
**Status**: Draft
|
||||||
|
**Input**: User description: "Provider-Backed Action Preflight and Dispatch Gate Unification"
|
||||||
|
|
||||||
|
## Spec Candidate Check *(mandatory — SPEC-GATE-001)*
|
||||||
|
|
||||||
|
- **Problem**: Provider-backed actions currently use two different start patterns, so the same missing-prerequisite or concurrency problem can be blocked before queue on one surface but only discovered later as a failed run on another.
|
||||||
|
- **Today's failure**: Operators click a provider-backed action, see nothing obviously wrong, and only later discover that the job failed because a primary connection, valid consent, usable credentials, or an available scope was missing.
|
||||||
|
- **User-visible improvement**: Covered provider-backed action surfaces all answer the same first question immediately using one public vocabulary: was the start queued, is it already running, is the scope busy, or is it blocked and what should I do next.
|
||||||
|
- **Smallest enterprise-capable version**: Extend one canonical preflight-and-dispatch path plus one shared result-presentation contract to every current operator-triggered provider-backed start surface, without redesigning provider architecture or adding new provider domains.
|
||||||
|
- **Explicit non-goals**: No provider-connection label redesign, no broad provider-domain expansion, no operation naming overhaul, no new workflow hub, no legacy-credential cleanup, and no full backend normalization of every legacy provider service in this spec.
|
||||||
|
- **Permanent complexity imported**: One expanded start-gate contract, one shared start-result presentation contract, focused regression coverage across existing action hosts, and limited next-step translation expansion where current blocked reasons are insufficient.
|
||||||
|
- **Why now**: The roadmap marks this as an active adjacent hardening lane, the proven start-gate pattern already exists, and every new provider-backed action that bypasses it increases trust debt on core operator surfaces.
|
||||||
|
- **Why not local**: The failure mode spans tenant action surfaces, provider-connection surfaces, onboarding, and monitoring. Per-action fixes would recreate inconsistent blocked, deduped, and next-step language on roughly twenty workflows.
|
||||||
|
- **Approval class**: Core Enterprise
|
||||||
|
- **Red flags triggered**: New shared support contract and broad multi-surface rollout. This remains justified because it does not add new persisted truth, it protects operator trust on remote actions, and the narrowest alternative is still one shared gate rather than many local copies.
|
||||||
|
- **Score**: Nutzen: 2 | Dringlichkeit: 2 | Scope: 1 | Komplexität: 1 | Produktnähe: 2 | Wiederverwendung: 2 | **Gesamt: 10/12**
|
||||||
|
- **Decision**: approve
|
||||||
|
|
||||||
|
## Spec Scope Fields *(mandatory)*
|
||||||
|
|
||||||
|
- **Scope**: workspace, tenant, canonical-view
|
||||||
|
- **Primary Routes**: `/admin/t/{tenant}/...` existing tenant-scoped provider-backed action surfaces, `/admin/provider-connections`, `/admin/onboarding/...`, `/admin/operations/{run}`
|
||||||
|
- **Data Ownership**: Tenant-owned provider connections and tenant-bound operation runs remain the authoritative runtime records. Workspace context continues to scope access and canonical monitoring views; this spec does not introduce new persisted ownership models.
|
||||||
|
- **RBAC**: Existing workspace membership, tenant entitlement, and per-action capabilities remain authoritative. Preflight blocks never replace server-side authorization checks.
|
||||||
|
|
||||||
|
For canonical-view specs, the spec MUST define:
|
||||||
|
|
||||||
|
- **Default filter behavior when tenant-context is active**: Canonical run links opened from a tenant-context action continue to preserve the active tenant context and related filtering behavior rather than widening back to all tenants by default.
|
||||||
|
- **Explicit entitlement checks preventing cross-tenant leakage**: Canonical run detail, existing-run dedupe links, and blocked next-step guidance must only be built after workspace membership and tenant entitlement are confirmed. Non-members or non-entitled viewers remain deny-as-not-found and must not learn whether another tenant currently has an active conflicting run or configured provider connection.
|
||||||
|
|
||||||
|
## 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 |
|
||||||
|
|---|---|---|---|---|---|---|
|
||||||
|
| Tenant-scoped provider-backed start surfaces | yes | Native admin actions + shared provider-start feedback | shared provider action family | page, modal, detail | no | Existing start actions remain; only preflight and queue-accept feedback are unified |
|
||||||
|
| Provider-connection resource surfaces | yes | Native admin actions + shared provider-start feedback | shared provider action family | table, detail | no | Existing connection-check and provider-triggered actions adopt the same result language |
|
||||||
|
| Onboarding provider step | yes | Native wizard actions + shared provider-start feedback | shared provider action family | wizard step | no | No new onboarding page is introduced |
|
||||||
|
| Canonical provider-backed operation run detail | yes | Native monitoring detail primitives | shared monitoring family | detail | no | Run detail keeps diagnostics secondary to translated execution meaning |
|
||||||
|
|
||||||
|
## 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 |
|
||||||
|
|---|---|---|---|---|---|---|---|
|
||||||
|
| Tenant-scoped provider-backed start surfaces | Primary Decision Surface | Decide whether to start remote provider work now or resolve a blocker first | Start accepted vs blocked vs already running vs scope busy, short reason, next step, existing-run path when relevant | Full run detail, low-level provider diagnostics, connection detail | Primary because this is the moment an operator commits to remote work or resolves a blocker | Follows the action-start workflow directly on the tenant work surface | Removes the need to click, wait, and later inspect a failed run for preventable prerequisite problems |
|
||||||
|
| Provider-connection resource surfaces | Secondary Context Surface | Resolve provider readiness and retry a blocked provider-backed action | Current start-prevention reason, next step, retry path | Connection detail, historical runs, deeper diagnostics | Secondary because the connection surface supports the real start decision rather than replacing it | Follows provider-readiness remediation workflow | Shortens recovery from blocked states without inventing a second start dialect |
|
||||||
|
| Onboarding provider step | Primary Decision Surface | Decide whether onboarding can proceed with provider-backed verification or needs operator follow-up | Start accepted vs blocked, reason, next step, safe continue path | Provider detail, existing run detail, low-level diagnostics | Primary because onboarding must tell the operator immediately whether the next remote step can proceed | Follows onboarding completion workflow, not monitoring navigation | Prevents onboarding from feeling successful until a hidden failed run appears later |
|
||||||
|
| Canonical provider-backed operation run detail | Tertiary Evidence / Diagnostics Surface | Understand what happened after a covered operation was actually accepted | Human-readable outcome, dominant reason, next step, scope identity | Raw payload context, low-level diagnostics, implementation detail | Tertiary because operators usually arrive here after a start surface or notification already answered the first decision | Follows drill-in and troubleshooting workflow | Keeps deep diagnostics available without making them the first explanation for start-preventing problems |
|
||||||
|
|
||||||
|
## 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 |
|
||||||
|
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
|
||||||
|
| Tenant-scoped provider-backed start surfaces | Record / Detail / Edit | Detail-first Operational Surface | Start work or resolve the blocker that prevented it | Existing detail page or modal on the current tenant surface | forbidden | Existing safe navigation remains secondary to the primary start decision | Existing dangerous actions stay where they already live; this spec adds none | `/admin/t/{tenant}/...` | existing tenant-scoped detail routes | Workspace and tenant scope, action target, provider readiness | Provider-backed operations / Provider-backed operation | Whether the action can start now and what the operator should do next | none |
|
||||||
|
| Provider-connection resource surfaces | List / Table / Bulk | CRUD / List-first Resource | Inspect provider readiness or start a connection-scoped check | Full-row click to connection detail where already used | allowed | Existing secondary actions remain grouped or contextual | Existing destructive connection lifecycle actions remain in danger placements | `/admin/provider-connections` | existing provider-connection detail route | Workspace and tenant scope, provider identity, readiness state | Provider connections / Provider connection | Whether the connection is usable for the requested operation | none |
|
||||||
|
| Onboarding provider step | Workflow / Wizard / Launch | Wizard / Step-driven Flow | Continue onboarding or resolve provider prerequisites | Existing wizard step as the only primary context | forbidden | Secondary navigation stays outside the primary step action | Destructive actions are out of scope | `/admin/onboarding/...` | existing onboarding step route | Workspace and tenant scope, onboarding phase, provider readiness | Onboarding / Onboarding step | Whether onboarding can continue with provider-backed work right now | none |
|
||||||
|
| Canonical provider-backed operation run detail | Record / Detail / Edit | Detail-first Operational Surface | Inspect the accepted run or open the related resolution surface | Explicit run detail page | forbidden | Related navigation and diagnostics remain secondary | Destructive actions are out of scope | `/admin/operations` | `/admin/operations/{run}` | Workspace and tenant scope, operation target, provider identity, active vs terminal state | Operation runs / Operation run | What actually happened after the operation was accepted | canonical monitoring detail |
|
||||||
|
|
||||||
|
## 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 |
|
||||||
|
|---|---|---|---|---|---|---|---|---|---|---|
|
||||||
|
| Tenant-scoped provider-backed start surfaces | Tenant operator or manager | Decide whether to start remote provider work now | Detail / launch affordance | Can this operation start now, and if not, what should I do next? | Start result, short reason, next step, existing-run path where relevant | Low-level provider diagnostics, raw failure context, deeper connection detail | start eligibility, dedupe, concurrency, actionability | Microsoft tenant and TenantPilot run creation when accepted | Start operation, open existing run, open resolution surface | none added |
|
||||||
|
| Provider-connection resource surfaces | Tenant manager or owner | Resolve connection readiness and retry blocked work | List/detail | Is this connection ready for the operation I am trying to start? | Start result, reason, next step, connection identity | Historical diagnostics, low-level provider context | readiness, actionability | TenantPilot only until an operation is accepted | Inspect connection, run connection-scoped checks, retry action | Existing destructive connection actions unchanged |
|
||||||
|
| Onboarding provider step | Tenant manager or onboarding operator | Continue onboarding or stop to resolve provider readiness | Wizard step | Can onboarding proceed with the next provider-backed action? | Start result, reason, next step, safe continuation or resolution path | Deep provider diagnostics, monitoring drill-in | readiness, actionability, progress continuity | TenantPilot onboarding flow and remote provider work when accepted | Continue onboarding, open resolution path | none added |
|
||||||
|
| Canonical provider-backed operation run detail | Tenant operator, workspace operator, support reviewer | Diagnose accepted provider-backed work after it started | Detail | What happened to the accepted operation, and what should happen next? | Human-readable outcome, short reason, next step, scope identity | Raw payload context, exception detail, low-level provider metadata | execution outcome, completeness, actionability | none | Open related target, inspect diagnostics | none added |
|
||||||
|
|
||||||
|
## 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**: Operators cannot trust provider-backed start behavior because equivalent prerequisite and concurrency problems are surfaced at different times and in different languages depending on the action host.
|
||||||
|
- **Existing structure is insufficient because**: The current split between one guarded start path and many legacy local start paths creates inconsistent operator truth and inconsistent queue behavior across the same class of remote actions.
|
||||||
|
- **Narrowest correct implementation**: Reuse the existing canonical start-gate pattern and extend it to all current operator-triggered provider-backed starts, with one shared result-presentation contract and no new provider-domain framework.
|
||||||
|
- **Ownership cost**: One broader start-gate contract, one shared result-presentation contract, and focused regression coverage across covered action hosts and monitoring surfaces.
|
||||||
|
- **Alternative intentionally rejected**: Per-action preflight fixes on each surface. That would look cheaper short term but would preserve multiple provider-start dialects and duplicate concurrency logic in local action handlers.
|
||||||
|
- **Release truth**: Current-release truth. This feature closes a present operator and monitoring trust gap on already-shipped provider-backed workflows.
|
||||||
|
|
||||||
|
## Testing / Lane / Runtime Impact *(mandatory for runtime behavior changes)*
|
||||||
|
|
||||||
|
- **Test purpose / classification**: Feature
|
||||||
|
- **Validation lane(s)**: fast-feedback, confidence
|
||||||
|
- **Why this classification and these lanes are sufficient**: The change is proven by operator-visible start behavior, queue-accept semantics, dedupe and scope busy outcomes, and monitoring/notification alignment on existing action hosts. Focused feature coverage is the narrowest sufficient proof.
|
||||||
|
- **New or expanded test families**: Expanded action-surface feature coverage for tenant start surfaces, provider-connection surfaces, onboarding, and provider-backed run/notification alignment. Limited focused unit coverage may support start-result mapping, but the proving purpose remains feature-level.
|
||||||
|
- **Fixture / helper cost impact**: Moderate. Tests need provider connection state, tenant membership, action capability context, and active-run fixtures, but can reuse existing workspace and tenant setup.
|
||||||
|
- **Heavy-family visibility / justification**: none
|
||||||
|
- **Special surface test profile**: standard-native-filament
|
||||||
|
- **Standard-native relief or required special coverage**: Standard admin-surface feature coverage is sufficient; no browser or heavy-governance lane is required for the first slice.
|
||||||
|
- **Reviewer handoff**: Reviewers must confirm that missing prerequisites and concurrency collisions are caught before queue acceptance, that covered start surfaces all use the same result vocabulary, and that accepted runs still follow the canonical monitoring feedback contract.
|
||||||
|
- **Budget / baseline / trend impact**: Low-to-moderate increase in feature assertions around start surfaces and provider-backed run outcomes; no new heavy cost center expected.
|
||||||
|
- **Escalation needed**: none
|
||||||
|
- **Active feature PR close-out entry**: Guardrail
|
||||||
|
- **Planned validation commands**: `cd apps/platform && ./vendor/bin/sail artisan test --compact --filter=ProviderBackedActionStart`; `cd apps/platform && ./vendor/bin/sail artisan test --compact --filter=ProviderConnection`; `cd apps/platform && ./vendor/bin/sail artisan test --compact --filter=OperationRun`
|
||||||
|
|
||||||
|
## User Scenarios & Testing *(mandatory)*
|
||||||
|
|
||||||
|
### User Story 1 - Block Before Queue (Priority: P1)
|
||||||
|
|
||||||
|
An operator starts a provider-backed action and needs the product to stop immediately when the operation cannot legitimately start, instead of silently queueing work that only fails later.
|
||||||
|
|
||||||
|
**Why this priority**: This is the direct trust and workflow problem the spec exists to solve. If preventable failures still appear only as later failed runs, the spec has not delivered its core value.
|
||||||
|
|
||||||
|
**Independent Test**: Can be fully tested by triggering covered actions under missing-connection, unusable-access, and active-run-conflict conditions and verifying that the operator receives an immediate blocked, deduped, or scope busy result before any background work is accepted.
|
||||||
|
|
||||||
|
**Acceptance Scenarios**:
|
||||||
|
|
||||||
|
1. **Given** a covered provider-backed action has no usable provider connection or access, **When** an operator starts it, **Then** the product blocks the start immediately and shows the cause and next step before any background work begins.
|
||||||
|
2. **Given** an equivalent action is already active for the same tenant or scope, **When** an operator starts the same action again, **Then** the product returns a deduped or scope busy result and points the operator to the existing work instead of queueing a duplicate.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### User Story 2 - Same Start Semantics on Every Covered Surface (Priority: P2)
|
||||||
|
|
||||||
|
An operator encounters the same provider problem from different action surfaces and needs the product to describe it in the same way every time.
|
||||||
|
|
||||||
|
**Why this priority**: Shared start semantics are the leverage of the spec. Without cross-surface consistency, the product keeps teaching a different operational language per action host.
|
||||||
|
|
||||||
|
**Independent Test**: Can be fully tested by triggering the same blocked or allowed scenario from tenant surfaces, provider-connection surfaces, and onboarding, then verifying that all of them return the same result categories and next-step structure.
|
||||||
|
|
||||||
|
**Acceptance Scenarios**:
|
||||||
|
|
||||||
|
1. **Given** the same blocked prerequisite appears from a tenant action and from onboarding, **When** the operator starts both actions, **Then** both surfaces use the same outcome category and the same next-step pattern.
|
||||||
|
2. **Given** the same action can start from a tenant surface and a provider-connection surface, **When** the operator starts it from either place, **Then** both surfaces confirm acceptance using the same queued-start pattern and the same run-link semantics.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### User Story 3 - Keep Monitoring Truthful After Accepted Work (Priority: P3)
|
||||||
|
|
||||||
|
An operator starts provider-backed work successfully and later needs monitoring and notifications to explain accepted work in the same human-readable language family used on the start surface.
|
||||||
|
|
||||||
|
**Why this priority**: Start-surface trust must carry through to monitoring. Otherwise the product still feels split between action UX and run UX.
|
||||||
|
|
||||||
|
**Independent Test**: Can be fully tested by accepting covered work, letting it finish in successful or failed terminal states, and verifying that run detail and terminal notifications preserve the same translated problem and next-step direction without pretending a preflight block was an execution failure.
|
||||||
|
|
||||||
|
**Acceptance Scenarios**:
|
||||||
|
|
||||||
|
1. **Given** a provider-backed action was accepted and later fails for execution-time reasons, **When** the operator opens run detail, **Then** the page explains the dominant problem and next step using the same humanized language family as the start surface.
|
||||||
|
2. **Given** preflight prevented a covered action from starting, **When** the operator later checks monitoring, **Then** there is no misleading ordinary failed run implying that remote work actually executed.
|
||||||
|
|
||||||
|
### Edge Cases
|
||||||
|
|
||||||
|
- Membership or capability denial must retain 404 and 403 semantics and must not be flattened into generic blocked-prerequisite copy.
|
||||||
|
- A provider connection can change after click time; accepted work must remain bound to the selected connection identity or record that identity before execution continues.
|
||||||
|
- Legacy operator-triggered actions that still rely on implicit provider resolution remain in scope for start gating even before full provider-connection normalization lands.
|
||||||
|
- Scheduled or system-triggered provider work is out of scope for immediate click-time feedback, but any shared reason language reused by monitoring must stay aligned with the covered start contract.
|
||||||
|
- Busy, blocked, and degraded follow-up conditions can overlap conceptually; the operator surface must lead with one dominant result and one next step instead of several equal-weight warnings.
|
||||||
|
|
||||||
|
## Requirements *(mandatory)*
|
||||||
|
|
||||||
|
**Constitution alignment (required):** This feature changes how existing provider-backed remote work is admitted to execution. It does not introduce new remote domains or new contract-registry object types. All underlying provider calls remain on the existing contract path. The gate runs before remote work is accepted and must preserve tenant isolation, run observability for accepted work, and focused regression coverage.
|
||||||
|
|
||||||
|
**Constitution alignment (PROP-001 / ABSTR-001 / PERSIST-001 / STATE-001 / BLOAT-001):** The feature adds one shared start-gate expansion and one shared result-presentation contract because the operator problem is already cross-surface and cannot be solved safely with local action copy. No new persisted entity, no second source of truth, and no new top-level operation state family are introduced.
|
||||||
|
|
||||||
|
**Constitution alignment (TEST-GOV-001):** Proof stays in focused feature coverage over existing action hosts and monitoring surfaces. No browser or heavy-governance family is added. Any helper introduced for provider start fixtures must keep provider, workspace, membership, and active-run context explicit rather than default.
|
||||||
|
|
||||||
|
**Constitution alignment (OPS-UX):** Accepted provider-backed starts continue to use the three-surface feedback contract: queued intent feedback, active progress surfaces, and one terminal database notification. Preflight-blocked starts do not emit accepted-start feedback and must not masquerade as ordinary execution-failed runs. Any accepted run continues to use the canonical run-transition service and numeric summary-count rules.
|
||||||
|
|
||||||
|
**Constitution alignment (RBAC-UX):** Covered start surfaces live on the tenant/admin plane and the canonical monitoring plane. Non-members or non-entitled viewers remain 404. Established members missing the required capability remain 403. Server-side authorization remains authoritative for every covered start action, and preflight guidance must never leak another tenant's connection or active-run existence.
|
||||||
|
|
||||||
|
**Constitution alignment (OPS-EX-AUTH-001):** No authentication-handshake exception is used. This feature does not introduce synchronous outbound provider work on monitoring or auth routes.
|
||||||
|
|
||||||
|
**Constitution alignment (BADGE-001):** Start-result and run-outcome semantics remain centralized. Covered surfaces must not invent local status mappings for blocked, deduped, scope busy, or accepted provider-backed starts.
|
||||||
|
|
||||||
|
**Constitution alignment (UI-FIL-001):** Covered surfaces continue to use native admin actions, shared notifications, and shared monitoring detail primitives. The feature standardizes action feedback and explanation order without introducing custom local status widgets.
|
||||||
|
|
||||||
|
**Constitution alignment (UI-NAMING-001):** The target objects remain the existing operation nouns such as sync, restore, capture, generate, or check. The canonical start-result model remains `accepted`, `deduped`, `scope busy`, and `blocked`, while operator-facing wording maps those outcomes to `queued`, `already running`, `scope busy`, and `blocked`. That mapping and next-step guidance must stay aligned across buttons, modals, run links, notifications, and monitoring detail.
|
||||||
|
|
||||||
|
**Canonical vocabulary mapping:** The shared start-result model uses the internal outcomes `accepted`, `deduped`, `scope busy`, and `blocked`. Operator-facing wording presents those outcomes respectively as `queued`, `already running`, `scope busy`, and `blocked`.
|
||||||
|
|
||||||
|
**Constitution alignment (DECIDE-001):** Tenant-scoped start actions and onboarding remain the primary decision moments because they determine whether remote work begins. Provider-connection surfaces remain supporting context for resolving blocked starts. Canonical run detail remains the diagnostic drill-in after work was actually accepted.
|
||||||
|
|
||||||
|
**Constitution alignment (UI-CONST-001 / UI-SURF-001 / ACTSURF-001 / UI-HARD-001 / UI-EX-001 / UI-REVIEW-001 / HDR-001):** The feature does not add a new inspect model or new primary pages. Existing action hosts keep navigation separate from mutation, secondary navigation stays grouped or contextual, destructive actions remain in their current danger placements, and run detail remains the canonical diagnostic destination for accepted work.
|
||||||
|
|
||||||
|
**Constitution alignment (ACTSURF-001 - action hierarchy):** Covered surfaces may standardize start-result feedback, but they must not mix navigation, mutation, and remediation into an unstructured catch-all. Existing start actions remain the primary action. Existing run links or resolution links remain secondary and contextual.
|
||||||
|
|
||||||
|
### Functional Requirements
|
||||||
|
|
||||||
|
- **FR-216-001**: Every current operator-triggered provider-backed action that can accept remote provider work MUST pass through one canonical preflight-and-dispatch path before background execution is admitted.
|
||||||
|
- **FR-216-002**: The canonical start path MUST use one shared result model with exactly four canonical outcomes: accepted, deduped, scope busy, and blocked. Operator-facing wording MUST present those outcomes consistently as queued, already running, scope busy, and blocked.
|
||||||
|
- **FR-216-003**: Missing or unusable provider access, missing required permissions, non-operable tenant state, and equivalent start-preventing conditions MUST be detected before queue admission on all covered start surfaces.
|
||||||
|
- **FR-216-004**: A covered action that is blocked during preflight MUST not silently queue work and later appear as an ordinary execution-failed run.
|
||||||
|
- **FR-216-005**: Deduped and scope busy outcomes MUST be decided at click time for covered operations and MUST direct the operator to the existing work or the correct resolution path.
|
||||||
|
- **FR-216-006**: Accepted starts MUST bind to a specific provider connection identity and carry that identity into accepted-work context so later monitoring and notifications can explain which connection the work used.
|
||||||
|
- **FR-216-007**: Covered starts MUST use dispatch-time conflict protection so two conflicting provider-backed operations for the same protected scope cannot both be accepted at once.
|
||||||
|
- **FR-216-008**: Covered start surfaces MUST render the shared start outcomes through one shared presentation contract rather than action-local conditional copy.
|
||||||
|
- **FR-216-009**: The shared presentation contract MUST preserve each action's domain verb and target object while keeping the outcome vocabulary and next-step structure consistent across all covered start surfaces.
|
||||||
|
- **FR-216-010**: Terminal notifications and canonical run detail for accepted provider-backed work MUST use the same translated problem and next-step direction as the covered start surfaces whenever they are explaining the same problem class.
|
||||||
|
- **FR-216-011**: Existing guarded provider-backed starts that already use the canonical preflight pattern MUST migrate to the shared presentation contract without losing any current protection.
|
||||||
|
- **FR-216-012**: The first implementation slice MUST cover all current operator-triggered provider-backed starts reachable from tenant-scoped surfaces, provider-connection surfaces, and onboarding.
|
||||||
|
- **FR-216-013**: Scheduled or system-triggered provider operations are out of scope for click-time UX. In scope for this feature is only that Monitoring-side shared reason vocabulary and initiator-aware notification behavior for those runs remain compatible with the covered start contract where the same problem class is shown.
|
||||||
|
- **FR-216-014**: The feature MUST not introduce a second provider-start framework, a parallel queue-admission path, or new per-action local preflight logic for covered surfaces.
|
||||||
|
- **FR-216-015**: Monitoring and audit surfaces MUST continue to distinguish between work that was actually accepted and executed versus work that was prevented from starting.
|
||||||
|
- **FR-216-016**: Covered start and monitoring surfaces MUST preserve deny-as-not-found tenant isolation and must not leak other tenants' connection readiness, active-run existence, or provider identity through start-result messaging.
|
||||||
|
|
||||||
|
## UI Action Matrix *(mandatory when Filament is changed)*
|
||||||
|
|
||||||
|
If this feature adds/modifies any Filament Resource / RelationManager / Page, fill out the matrix below.
|
||||||
|
|
||||||
|
For each surface, list the exact action labels, whether they are destructive (confirmation? typed confirmation?),
|
||||||
|
RBAC gating (capability + enforcement helper), whether the mutation writes an audit log, and any exemption or exception used.
|
||||||
|
|
||||||
|
| Surface | Location | Header Actions | Inspect Affordance (List/Table) | Row Actions (max 2 visible) | Bulk Actions (grouped) | Empty-State CTA(s) | View Header Actions | Create/Edit Save+Cancel | Audit log? | Notes / Exemptions |
|
||||||
|
|---|---|---|---|---|---|---|---|---|---|---|
|
||||||
|
| Tenant-scoped provider-backed start surfaces | Existing tenant detail pages and tenant-scoped resource pages that already expose provider-backed starts | Existing start actions such as sync, restore, capture, generate, or verify remain; no new header action is introduced by this spec | Existing inspect model remains unchanged on each host surface | Existing safe start affordances remain; no new visible destructive row action is added | Existing grouped bulk actions remain unchanged | Existing empty-state guidance remains unchanged | Existing start actions continue where already present | Existing create and edit flows remain unchanged | Existing operation-start and accepted-run audit behavior remains authoritative | This spec standardizes preflight result handling, not action inventory |
|
||||||
|
| Provider-connection resource surfaces | Existing provider-connection list and detail surfaces | Existing connection-management actions remain; connection-scoped checks adopt the shared start-result pattern where relevant | Full-row click or explicit inspect behavior remains unchanged | Existing safe actions such as inspect or connection health checks remain; no new destructive row action is added | Existing grouped bulk actions remain unchanged | Existing add-connection CTA remains unchanged | Existing connection-scoped start actions continue where already present | Existing create and edit flows remain unchanged | Existing connection lifecycle and accepted-run audit behavior remains authoritative | No connection-label redesign is introduced here |
|
||||||
|
| Onboarding provider step | Existing onboarding wizard step that triggers provider-backed verification or follow-up work | none added | n/a | n/a | none | Existing continue/setup CTA remains | Existing step actions remain the only primary controls | Existing save/continue flow remains | Existing onboarding audit behavior remains authoritative | The step adopts the shared blocked, deduped, scope busy, and accepted semantics |
|
||||||
|
| Canonical provider-backed operation run detail | Existing canonical monitoring detail surface | no new header action | Existing run detail open model remains canonical | none added | none | n/a | Existing related navigation remains | n/a | Existing run audit behavior remains authoritative | Detail hierarchy changes only insofar as it must stay aligned with the shared start-result language |
|
||||||
|
|
||||||
|
### Key Entities *(include if feature involves data)*
|
||||||
|
|
||||||
|
- **Provider-backed Start Surface**: Any existing operator-facing action host that can admit remote provider work and therefore must answer whether the operation can start now.
|
||||||
|
- **Preflight Start Result**: The shared start outcome for a covered action, including the canonical results accepted, deduped, scope busy, or blocked plus the operator-facing mapping to queued, already running, scope busy, or blocked, the short reason, and the next-step direction.
|
||||||
|
- **Accepted Provider-backed Run Context**: The canonical accepted-work context that records scope identity, chosen provider connection, and the related run or resolution path once work is admitted.
|
||||||
|
|
||||||
|
## Assumptions & Dependencies
|
||||||
|
|
||||||
|
- Provider Connection Resolution Normalization remains a soft dependency. This spec may use bridge behavior where current operator-triggered starts still rely on legacy provider-resolution internals, but accepted work must still record the chosen connection identity consistently.
|
||||||
|
- Shared outcome vocabulary and translated next-step guidance from the operator-truth lane are available to extend rather than re-invent.
|
||||||
|
- No new provider domain, no platform-wide operation naming rewrite, and no provider-connection UX relabeling are pulled into this scope.
|
||||||
|
- Existing canonical monitoring detail and terminal notification behavior for accepted runs remain authoritative and are aligned, not replaced.
|
||||||
|
|
||||||
|
## Success Criteria *(mandatory)*
|
||||||
|
|
||||||
|
### Measurable Outcomes
|
||||||
|
|
||||||
|
- **SC-216-001**: In regression coverage for all covered start surfaces, 100% of seeded missing-prerequisite and scope-conflict scenarios are surfaced to the operator before any background work is accepted.
|
||||||
|
- **SC-216-002**: In release review across the covered start surfaces, operators encounter exactly one shared public start vocabulary: queued, already running, scope busy, and blocked.
|
||||||
|
- **SC-216-003**: In concurrent-start regression scenarios for the same protected scope, at most one provider-backed operation is accepted and all additional attempts are resolved as deduped or scope busy before queue admission.
|
||||||
|
- **SC-216-004**: In acceptance review, an operator can identify the next action for a blocked covered start within 10 seconds without opening low-level diagnostics.
|
||||||
|
- **SC-216-005**: Equivalent prerequisite problems no longer surface as immediate blocked guidance on one covered action and only as an after-the-fact ordinary failed run on another covered action.
|
||||||
|
- **SC-216-006**: In regression coverage for scheduled or system-triggered provider-backed runs that reuse shared reason translation, Monitoring preserves the same public wording for the same problem class without emitting initiator-only completion UX meant for click-time starts.
|
||||||
242
specs/216-provider-dispatch-gate/tasks.md
Normal file
242
specs/216-provider-dispatch-gate/tasks.md
Normal file
@ -0,0 +1,242 @@
|
|||||||
|
# Tasks: Provider-Backed Action Preflight and Dispatch Gate Unification
|
||||||
|
|
||||||
|
**Input**: Design documents from `/specs/216-provider-dispatch-gate/`
|
||||||
|
**Prerequisites**: `plan.md` (required), `spec.md` (required for user stories), `research.md`, `data-model.md`, `contracts/`, `quickstart.md`
|
||||||
|
|
||||||
|
**Tests**: Runtime behavior changes in this repo require Pest coverage. This feature keeps proof in the `fast-feedback` and `confidence` lanes with one supporting unit seam and focused feature coverage on the existing Filament and Livewire action hosts.
|
||||||
|
|
||||||
|
## Phase 1: Setup (Shared Gate Scaffolding)
|
||||||
|
|
||||||
|
**Purpose**: Introduce the bounded shared testing and presentation seams needed before touching operator-facing start hosts.
|
||||||
|
|
||||||
|
- [x] T001 [P] Add failing unit coverage for newly covered operation types, explicit `provider_connection_id` binding, and onboarding bootstrap protected-scope admission in `apps/platform/tests/Unit/Providers/ProviderOperationStartGateTest.php`
|
||||||
|
- [x] T002 [P] Add failing unit coverage for shared accepted, deduped, scope busy, and blocked presentation behavior in `apps/platform/tests/Unit/Providers/ProviderOperationStartResultPresenterTest.php`
|
||||||
|
- [x] T003 [P] Create the bounded shared presenter for provider-backed start outcomes in `apps/platform/app/Support/OpsUx/ProviderOperationStartResultPresenter.php`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 2: Foundational (Blocking Prerequisites)
|
||||||
|
|
||||||
|
**Purpose**: Extend the canonical gate, reason translation, and shared Ops UX wiring that every user story depends on.
|
||||||
|
|
||||||
|
**Critical**: No user story work should begin until this phase is complete.
|
||||||
|
|
||||||
|
- [x] T004 Expand first-slice provider-backed operation definitions and dispatch callbacks in `apps/platform/app/Services/Providers/ProviderOperationRegistry.php`
|
||||||
|
- [x] T005 Update canonical gate and result plumbing for explicit `provider_connection_id`, protected-scope conflict handling, and blocked-start truth in `apps/platform/app/Services/Providers/ProviderOperationStartGate.php` and `apps/platform/app/Services/Providers/ProviderOperationStartResult.php`
|
||||||
|
- [x] T006 [P] Extend provider blocker translation and next-step mapping for the shared start contract in `apps/platform/app/Support/Providers/ProviderNextStepsRegistry.php` and `apps/platform/app/Support/ReasonTranslation/ReasonPresenter.php`
|
||||||
|
- [x] T007 [P] Wire the shared presenter into existing toast and canonical run-link primitives in `apps/platform/app/Support/OpsUx/OperationUxPresenter.php`, `apps/platform/app/Support/OpsUx/ProviderOperationStartResultPresenter.php`, and `apps/platform/app/Support/OperationRunLinks.php`
|
||||||
|
- [x] T008 [P] Keep capability and alias-aware operation metadata aligned for the covered start types in `apps/platform/app/Support/Operations/OperationRunCapabilityResolver.php` and `apps/platform/app/Support/OperationCatalog.php`
|
||||||
|
|
||||||
|
**Checkpoint**: The canonical gate, presenter seam, and shared reason vocabulary are ready; user story work can now proceed.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 3: User Story 1 - Block Before Queue (Priority: P1)
|
||||||
|
|
||||||
|
**Goal**: Every covered operator-triggered provider-backed start resolves blocked, deduped, and scope busy outcomes before queue admission.
|
||||||
|
|
||||||
|
**Independent Test**: Trigger covered starts under missing-connection, unusable-access, same-operation active-run, and conflicting active-run conditions and verify the operator gets an immediate blocked, deduped, or scope busy result before any background work is accepted.
|
||||||
|
|
||||||
|
### Tests for User Story 1
|
||||||
|
|
||||||
|
- [x] T009 [P] [US1] Add covered tenant and provider-connection gate-admission coverage for blocked, deduped, scope busy, and accepted starts in `apps/platform/tests/Feature/Tenants/TenantProviderBackedActionStartTest.php` and `apps/platform/tests/Feature/ProviderConnections/ProviderDispatchGateStartSurfaceTest.php`
|
||||||
|
- [x] T010 [P] [US1] Add restore and directory gate-admission coverage for explicit connection pinning and no-queue-on-block behavior in `apps/platform/tests/Feature/Restore/RestoreRunProviderStartTest.php` and `apps/platform/tests/Feature/Directory/ProviderBackedDirectoryStartTest.php`
|
||||||
|
- [x] T011 [P] [US1] Add onboarding verification and bootstrap gate-admission coverage, including protected-scope serialization, in `apps/platform/tests/Feature/Workspaces/ManagedTenantOnboardingProviderStartTest.php`
|
||||||
|
|
||||||
|
### Implementation for User Story 1
|
||||||
|
|
||||||
|
- [x] T012 [P] [US1] Migrate restore execute to canonical gate admission and explicit connection-aware queued execution in `apps/platform/app/Filament/Resources/RestoreRunResource.php` and `apps/platform/app/Jobs/ExecuteRestoreRunJob.php`
|
||||||
|
- [x] T013 [P] [US1] Migrate directory sync action hosts and queued jobs to canonical gate admission and explicit connection-aware execution in `apps/platform/app/Filament/Resources/EntraGroupResource/Pages/ListEntraGroups.php`, `apps/platform/app/Filament/Resources/TenantResource.php`, `apps/platform/app/Services/Directory/EntraGroupSyncService.php`, `apps/platform/app/Services/Directory/RoleDefinitionsSyncService.php`, `apps/platform/app/Jobs/EntraGroupSyncJob.php`, and `apps/platform/app/Jobs/SyncRoleDefinitionsJob.php`
|
||||||
|
- [x] T014 [P] [US1] Migrate onboarding bootstrap to canonical gate admission and sequential protected-scope execution in `apps/platform/app/Filament/Pages/Workspaces/ManagedTenantOnboardingWizard.php`
|
||||||
|
- [x] T015 [US1] Verify existing gated tenant and provider-connection starts keep explicit connection context and no-queue-on-block behavior in `apps/platform/app/Services/Verification/StartVerification.php`, `apps/platform/app/Filament/Resources/TenantResource.php`, `apps/platform/app/Filament/Widgets/Tenant/TenantVerificationReport.php`, and `apps/platform/app/Filament/Resources/ProviderConnectionResource.php`
|
||||||
|
- [x] T016 [US1] Run the US1 focused verification flow documented in `specs/216-provider-dispatch-gate/quickstart.md`
|
||||||
|
|
||||||
|
**Checkpoint**: User Story 1 is independently deliverable as the core block-before-queue hardening slice.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 4: User Story 2 - Same Start Semantics on Every Covered Surface (Priority: P2)
|
||||||
|
|
||||||
|
**Goal**: Covered tenant, provider-connection, restore, directory, and onboarding surfaces use one shared public start vocabulary and next-step structure.
|
||||||
|
|
||||||
|
**Independent Test**: Trigger the same blocked and accepted scenarios from tenant surfaces, provider-connection surfaces, onboarding, restore, and directory starts and verify all of them render the same queued, already running, scope busy, and blocked wording with the same run-link and next-step pattern.
|
||||||
|
|
||||||
|
### Tests for User Story 2
|
||||||
|
|
||||||
|
- [x] T017 [P] [US2] Add cross-surface queued, already running, scope busy, and blocked vocabulary parity assertions for tenant and provider-connection hosts in `apps/platform/tests/Feature/Tenants/TenantProviderBackedActionStartTest.php` and `apps/platform/tests/Feature/ProviderConnections/ProviderDispatchGateStartSurfaceTest.php`
|
||||||
|
- [x] T018 [P] [US2] Add onboarding, restore, and directory shared-language plus 404 and 403 assertions in `apps/platform/tests/Feature/Workspaces/ManagedTenantOnboardingProviderStartTest.php`, `apps/platform/tests/Feature/Restore/RestoreRunProviderStartTest.php`, `apps/platform/tests/Feature/Directory/ProviderBackedDirectoryStartTest.php`, `apps/platform/tests/Feature/Onboarding/OnboardingRbacSemanticsTest.php`, and `apps/platform/tests/Feature/Filament/RestoreRunUiEnforcementTest.php`
|
||||||
|
- [x] T019 [P] [US2] Update Filament action-surface contract coverage for the affected start hosts in `apps/platform/tests/Feature/Guards/ActionSurfaceContractTest.php`
|
||||||
|
|
||||||
|
### Implementation for User Story 2
|
||||||
|
|
||||||
|
- [x] T020 [US2] Replace tenant and provider-connection local start-result branching with the shared presenter and public queued or already running vocabulary in `apps/platform/app/Services/Verification/StartVerification.php`, `apps/platform/app/Filament/Resources/TenantResource.php`, `apps/platform/app/Filament/Widgets/Tenant/TenantVerificationReport.php`, `apps/platform/app/Filament/Resources/ProviderConnectionResource.php`, and `apps/platform/app/Filament/Resources/ProviderConnectionResource/Pages/EditProviderConnection.php`
|
||||||
|
- [x] T021 [P] [US2] Apply the shared presenter and safe continuation wording to onboarding verification and bootstrap surfaces in `apps/platform/app/Filament/Pages/Workspaces/ManagedTenantOnboardingWizard.php`
|
||||||
|
- [x] T022 [US2] Apply the shared presenter and aligned `Verb + Object` copy plus queued or already running wording to restore and directory start surfaces in `apps/platform/app/Filament/Resources/RestoreRunResource.php`, `apps/platform/app/Filament/Resources/EntraGroupResource/Pages/ListEntraGroups.php`, and `apps/platform/app/Filament/Resources/TenantResource.php`
|
||||||
|
- [x] T023 [US2] Run the US2 cross-surface verification flow documented in `specs/216-provider-dispatch-gate/quickstart.md`
|
||||||
|
|
||||||
|
**Checkpoint**: User Stories 1 and 2 both work independently, with start gating and operator copy fully aligned across the covered action hosts.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 5: User Story 3 - Keep Monitoring Truthful After Accepted Work (Priority: P3)
|
||||||
|
|
||||||
|
**Goal**: Accepted provider-backed work preserves the same translated problem and next-step direction in Monitoring and terminal notifications without turning prevented starts into ordinary execution failures.
|
||||||
|
|
||||||
|
**Independent Test**: Accept covered provider-backed work, let it finish in successful or failed terminal states, and verify canonical run detail and terminal notifications stay aligned with the start contract while blocked starts remain distinguishable from executed work.
|
||||||
|
|
||||||
|
### Tests for User Story 3
|
||||||
|
|
||||||
|
- [x] T024 [P] [US3] Add provider-backed run-detail reason-alignment, canonical view-run-link coverage, and scheduled or system-run shared-vocabulary compatibility assertions in `apps/platform/tests/Feature/Operations/ProviderBackedRunReasonAlignmentTest.php` and `apps/platform/tests/Feature/OpsUx/CanonicalViewRunLinksTest.php`
|
||||||
|
- [x] T025 [P] [US3] Add blocked-vs-executed monitoring truth, initiator-only no-queued-db-notification, and non-initiator compatibility assertions in `apps/platform/tests/Feature/Operations/OperationRunBlockedExecutionPresentationTest.php`, `apps/platform/tests/Feature/OpsUx/Constitution/JobDbNotificationGuardTest.php`, and `apps/platform/tests/Feature/OpsUx/NoQueuedDbNotificationsTest.php`
|
||||||
|
|
||||||
|
### Implementation for User Story 3
|
||||||
|
|
||||||
|
- [x] T026 [P] [US3] Align terminal notification translation, initiator-only delivery, and canonical view-run actions with the shared start contract in `apps/platform/app/Notifications/OperationRunCompleted.php`, `apps/platform/app/Support/OpsUx/OperationUxPresenter.php`, and `apps/platform/app/Support/OperationRunLinks.php`
|
||||||
|
- [x] T027 [P] [US3] Align canonical Monitoring run detail with accepted provider-backed start semantics and scheduled or system-run shared reason reuse in `apps/platform/app/Filament/Resources/OperationRunResource.php` and `apps/platform/app/Filament/Pages/Operations/TenantlessOperationRunViewer.php`
|
||||||
|
- [x] T028 [P] [US3] Keep provider-backed jobs on service-owned lifecycle transitions and flat summary-count discipline in `apps/platform/app/Jobs/ExecuteRestoreRunJob.php`, `apps/platform/app/Jobs/EntraGroupSyncJob.php`, `apps/platform/app/Jobs/SyncRoleDefinitionsJob.php`, and `apps/platform/app/Services/OperationRunService.php`
|
||||||
|
- [x] T029 [US3] Run the US3 monitoring and notification verification flow documented in `specs/216-provider-dispatch-gate/quickstart.md`
|
||||||
|
|
||||||
|
**Checkpoint**: All user stories are independently functional and monitoring truth stays aligned with the accepted-start contract.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 6: Polish & Cross-Cutting Concerns
|
||||||
|
|
||||||
|
**Purpose**: Finalize lane metadata, contract notes, and close-out proof across all stories.
|
||||||
|
|
||||||
|
- [x] T030 [P] Add and register a route-bounded first-slice direct-dispatch bypass guard in `apps/platform/tests/Feature/Guards/ProviderDispatchGateCoverageTest.php`, `apps/platform/tests/Support/TestLaneManifest.php`, and `apps/platform/tests/Feature/Guards/TestLaneManifestTest.php`
|
||||||
|
- [x] T031 [P] Refresh the logical start contract and verification steps after implementation in `specs/216-provider-dispatch-gate/contracts/provider-dispatch-gate.logical.openapi.yaml` and `specs/216-provider-dispatch-gate/quickstart.md`
|
||||||
|
- [x] T032 Run formatting and the final focused Pest command set documented in `specs/216-provider-dispatch-gate/quickstart.md`
|
||||||
|
- [x] T033 Record the final lane outcome, route-bounded covered-surface audit, guardrail close-out, and `document-in-feature` test-governance disposition in `specs/216-provider-dispatch-gate/plan.md`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Test Governance Checklist
|
||||||
|
|
||||||
|
- [x] Lane assignment is named and is the narrowest sufficient proof for the changed behavior.
|
||||||
|
- [x] New or changed tests stay in the smallest honest family, and any heavy-governance or browser addition is explicit.
|
||||||
|
- [x] Shared helpers, factories, seeds, fixtures, and context defaults stay cheap by default; any widening is isolated or documented.
|
||||||
|
- [x] Planned validation commands cover the change without pulling in unrelated lane cost.
|
||||||
|
- [x] The declared surface test profile or `standard-native-filament` relief is explicit.
|
||||||
|
- [x] Any material budget, baseline, trend, or escalation note is recorded in the active spec or PR.
|
||||||
|
|
||||||
|
## Dependencies & Execution Order
|
||||||
|
|
||||||
|
### Phase Dependencies
|
||||||
|
|
||||||
|
- **Setup (Phase 1)**: No dependencies; can start immediately.
|
||||||
|
- **Foundational (Phase 2)**: Depends on Setup completion and blocks all user stories.
|
||||||
|
- **User Story 1 (Phase 3)**: Depends on Foundational completion.
|
||||||
|
- **User Story 2 (Phase 4)**: Depends on Foundational completion and should follow the stable gate adoption from User Story 1 on shared action hosts.
|
||||||
|
- **User Story 3 (Phase 5)**: Depends on Foundational completion and on the shared start vocabulary being stable enough for Monitoring parity.
|
||||||
|
- **Polish (Phase 6)**: Depends on all desired user stories being complete.
|
||||||
|
|
||||||
|
### User Story Dependencies
|
||||||
|
|
||||||
|
- **User Story 1 (P1)**: This is the MVP and should ship first.
|
||||||
|
- **User Story 2 (P2)**: Conceptually independent after Phase 2, but it reuses the shared presenter and touches some of the same action hosts stabilized in User Story 1.
|
||||||
|
- **User Story 3 (P3)**: Conceptually independent after Phase 2, but it must preserve parity with the accepted-start contract implemented by User Stories 1 and 2.
|
||||||
|
|
||||||
|
### Within Each User Story
|
||||||
|
|
||||||
|
- Tests should be written and fail before the corresponding implementation tasks.
|
||||||
|
- Shared gate and presenter changes must land before any surface-specific adoption task uses them.
|
||||||
|
- For shared files such as `apps/platform/app/Filament/Resources/TenantResource.php` and `apps/platform/app/Filament/Pages/Workspaces/ManagedTenantOnboardingWizard.php`, serialize edits even when the surrounding story tasks are otherwise parallelizable.
|
||||||
|
- Finish each story's verification task before moving to the next priority when working sequentially.
|
||||||
|
|
||||||
|
### Parallel Opportunities
|
||||||
|
|
||||||
|
- **Setup**: `T001`, `T002`, and `T003` can run in parallel.
|
||||||
|
- **Foundational**: `T006`, `T007`, and `T008` can run in parallel after `T004` and `T005` settle the canonical gate contract.
|
||||||
|
- **US1 tests**: `T009`, `T010`, and `T011` can run in parallel.
|
||||||
|
- **US1 implementation**: `T012`, `T013`, and `T014` can run in parallel; `T015` should follow once the legacy-start migrations are in place.
|
||||||
|
- **US2 tests**: `T017`, `T018`, and `T019` can run in parallel.
|
||||||
|
- **US2 implementation**: `T021` can run in parallel with `T020` or `T022`, but `T020` and `T022` should serialize because both touch `apps/platform/app/Filament/Resources/TenantResource.php`.
|
||||||
|
- **US3**: `T024` and `T025` can run in parallel, and `T026`, `T027`, and `T028` can run in parallel once the test expectations are fixed.
|
||||||
|
- **Polish**: `T030` and `T031` can run in parallel before `T032` and `T033`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Parallel Example: User Story 1
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Run US1 coverage in parallel:
|
||||||
|
T009 apps/platform/tests/Feature/Tenants/TenantProviderBackedActionStartTest.php and apps/platform/tests/Feature/ProviderConnections/ProviderDispatchGateStartSurfaceTest.php
|
||||||
|
T010 apps/platform/tests/Feature/Restore/RestoreRunProviderStartTest.php and apps/platform/tests/Feature/Directory/ProviderBackedDirectoryStartTest.php
|
||||||
|
T011 apps/platform/tests/Feature/Workspaces/ManagedTenantOnboardingProviderStartTest.php
|
||||||
|
|
||||||
|
# Then split the non-overlapping legacy start migrations:
|
||||||
|
T012 apps/platform/app/Filament/Resources/RestoreRunResource.php and apps/platform/app/Jobs/ExecuteRestoreRunJob.php
|
||||||
|
T013 apps/platform/app/Filament/Resources/EntraGroupResource/Pages/ListEntraGroups.php, apps/platform/app/Services/Directory/EntraGroupSyncService.php, apps/platform/app/Services/Directory/RoleDefinitionsSyncService.php, apps/platform/app/Jobs/EntraGroupSyncJob.php, and apps/platform/app/Jobs/SyncRoleDefinitionsJob.php
|
||||||
|
T014 apps/platform/app/Filament/Pages/Workspaces/ManagedTenantOnboardingWizard.php
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Parallel Example: User Story 2
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Run US2 parity and contract assertions in parallel:
|
||||||
|
T017 apps/platform/tests/Feature/Tenants/TenantProviderBackedActionStartTest.php and apps/platform/tests/Feature/ProviderConnections/ProviderDispatchGateStartSurfaceTest.php
|
||||||
|
T018 apps/platform/tests/Feature/Workspaces/ManagedTenantOnboardingProviderStartTest.php, apps/platform/tests/Feature/Restore/RestoreRunProviderStartTest.php, apps/platform/tests/Feature/Directory/ProviderBackedDirectoryStartTest.php, apps/platform/tests/Feature/Onboarding/OnboardingRbacSemanticsTest.php, and apps/platform/tests/Feature/Filament/RestoreRunUiEnforcementTest.php
|
||||||
|
T019 apps/platform/tests/Feature/Guards/ActionSurfaceContractTest.php
|
||||||
|
|
||||||
|
# Then split presenter adoption where files do not overlap:
|
||||||
|
T020 apps/platform/app/Services/Verification/StartVerification.php, apps/platform/app/Filament/Resources/TenantResource.php, apps/platform/app/Filament/Widgets/Tenant/TenantVerificationReport.php, apps/platform/app/Filament/Resources/ProviderConnectionResource.php, and apps/platform/app/Filament/Resources/ProviderConnectionResource/Pages/EditProviderConnection.php
|
||||||
|
T021 apps/platform/app/Filament/Pages/Workspaces/ManagedTenantOnboardingWizard.php
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Parallel Example: User Story 3
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Run US3 monitoring and notification assertions in parallel:
|
||||||
|
T024 apps/platform/tests/Feature/Operations/ProviderBackedRunReasonAlignmentTest.php and apps/platform/tests/Feature/OpsUx/CanonicalViewRunLinksTest.php
|
||||||
|
T025 apps/platform/tests/Feature/Operations/OperationRunBlockedExecutionPresentationTest.php, apps/platform/tests/Feature/OpsUx/Constitution/JobDbNotificationGuardTest.php, and apps/platform/tests/Feature/OpsUx/NoQueuedDbNotificationsTest.php
|
||||||
|
|
||||||
|
# Then split the non-overlapping Monitoring and notification implementation:
|
||||||
|
T026 apps/platform/app/Notifications/OperationRunCompleted.php, apps/platform/app/Support/OpsUx/OperationUxPresenter.php, and apps/platform/app/Support/OperationRunLinks.php
|
||||||
|
T027 apps/platform/app/Filament/Resources/OperationRunResource.php and apps/platform/app/Filament/Pages/Operations/TenantlessOperationRunViewer.php
|
||||||
|
T028 apps/platform/app/Jobs/ExecuteRestoreRunJob.php, apps/platform/app/Jobs/EntraGroupSyncJob.php, apps/platform/app/Jobs/SyncRoleDefinitionsJob.php, and apps/platform/app/Services/OperationRunService.php
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Implementation Strategy
|
||||||
|
|
||||||
|
### MVP First (User Story 1 Only)
|
||||||
|
|
||||||
|
1. Complete Phase 1: Setup.
|
||||||
|
2. Complete Phase 2: Foundational.
|
||||||
|
3. Complete Phase 3: User Story 1.
|
||||||
|
4. Stop and validate with `T016`.
|
||||||
|
5. Demo or ship the block-before-queue hardening before layering shared copy and Monitoring parity on top.
|
||||||
|
|
||||||
|
### Incremental Delivery
|
||||||
|
|
||||||
|
1. Setup and Foundational establish the canonical gate, presenter seam, and shared blocker vocabulary.
|
||||||
|
2. Add User Story 1 and validate legacy start migration and protected-scope admission.
|
||||||
|
3. Add User Story 2 and validate cross-surface copy, next-step, and authorization consistency.
|
||||||
|
4. Add User Story 3 and validate Monitoring and terminal notification parity.
|
||||||
|
5. Finish with lane metadata, quickstart and contract refresh, formatting, and close-out proof.
|
||||||
|
|
||||||
|
### Parallel Team Strategy
|
||||||
|
|
||||||
|
With multiple developers:
|
||||||
|
|
||||||
|
1. Complete Setup and Foundational together.
|
||||||
|
2. After Phase 2:
|
||||||
|
- Developer A: restore and directory start migration in `apps/platform/app/Filament/Resources/RestoreRunResource.php`, `apps/platform/app/Filament/Resources/EntraGroupResource/Pages/ListEntraGroups.php`, and related jobs.
|
||||||
|
- Developer B: onboarding migration and shared presenter adoption in `apps/platform/app/Filament/Pages/Workspaces/ManagedTenantOnboardingWizard.php`.
|
||||||
|
- Developer C: tenant and provider-connection shared presenter adoption plus Monitoring parity in `apps/platform/app/Filament/Resources/TenantResource.php`, `apps/platform/app/Filament/Resources/ProviderConnectionResource.php`, and `apps/platform/app/Filament/Resources/OperationRunResource.php`.
|
||||||
|
3. Serialize edits in `apps/platform/app/Filament/Resources/TenantResource.php` and `apps/platform/app/Filament/Pages/Workspaces/ManagedTenantOnboardingWizard.php` because multiple stories touch those files.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Notes
|
||||||
|
|
||||||
|
- `[P]` marks tasks that can run in parallel once their prerequisites are satisfied and the files do not overlap.
|
||||||
|
- `[US1]`, `[US2]`, and `[US3]` map directly to the spec's independently testable user stories.
|
||||||
|
- The narrowest proving lane remains `fast-feedback` plus `confidence`; do not widen into browser or heavy-governance without explicit follow-up justification.
|
||||||
|
- Keep new fixtures and helpers opt-in so provider, workspace, membership, active-run, and onboarding draft context do not become expensive test defaults.
|
||||||
Loading…
Reference in New Issue
Block a user