feat: add tenant governance aggregate contract and action surface follow-ups #199
@ -131,7 +131,7 @@ public static function actionSurfaceDeclaration(): ActionSurfaceDeclaration
|
|||||||
return ActionSurfaceDeclaration::forResource(ActionSurfaceProfile::CrudListAndView)
|
return ActionSurfaceDeclaration::forResource(ActionSurfaceProfile::CrudListAndView)
|
||||||
->satisfy(ActionSurfaceSlot::ListHeader, 'Header actions support filtered findings operations (legacy acknowledge-all-matching remains until bulk workflow migration).')
|
->satisfy(ActionSurfaceSlot::ListHeader, 'Header actions support filtered findings operations (legacy acknowledge-all-matching remains until bulk workflow migration).')
|
||||||
->satisfy(ActionSurfaceSlot::InspectAffordance, ActionSurfaceInspectAffordance::ClickableRow->value)
|
->satisfy(ActionSurfaceSlot::InspectAffordance, ActionSurfaceInspectAffordance::ClickableRow->value)
|
||||||
->satisfy(ActionSurfaceSlot::ListRowMoreMenu, 'Secondary row actions are grouped under "More".')
|
->satisfy(ActionSurfaceSlot::ListRowMoreMenu, 'Secondary workflow actions are grouped under "More"; the only inline row action is the related-record drill-down.')
|
||||||
->satisfy(ActionSurfaceSlot::ListBulkMoreGroup, 'Bulk actions are grouped under "More".')
|
->satisfy(ActionSurfaceSlot::ListBulkMoreGroup, 'Bulk actions are grouped under "More".')
|
||||||
->exempt(ActionSurfaceSlot::ListEmptyState, 'Findings are generated by drift detection and intentionally have no create CTA.')
|
->exempt(ActionSurfaceSlot::ListEmptyState, 'Findings are generated by drift detection and intentionally have no create CTA.')
|
||||||
->satisfy(ActionSurfaceSlot::DetailHeader, 'View page exposes capability-gated workflow actions for finding lifecycle management.');
|
->satisfy(ActionSurfaceSlot::DetailHeader, 'View page exposes capability-gated workflow actions for finding lifecycle management.');
|
||||||
@ -1643,6 +1643,7 @@ public static function reopenAction(): Actions\Action
|
|||||||
*/
|
*/
|
||||||
private static function runWorkflowMutation(Finding $record, string $successTitle, callable $callback): void
|
private static function runWorkflowMutation(Finding $record, string $successTitle, callable $callback): void
|
||||||
{
|
{
|
||||||
|
$pageRecord = $record;
|
||||||
$record = static::resolveProtectedFindingRecordOrFail($record);
|
$record = static::resolveProtectedFindingRecordOrFail($record);
|
||||||
$tenant = static::resolveTenantContextForCurrentPanel();
|
$tenant = static::resolveTenantContextForCurrentPanel();
|
||||||
$user = auth()->user();
|
$user = auth()->user();
|
||||||
@ -1671,6 +1672,8 @@ private static function runWorkflowMutation(Finding $record, string $successTitl
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
$callback($record, $tenant, $user);
|
$callback($record, $tenant, $user);
|
||||||
|
|
||||||
|
$pageRecord->refresh();
|
||||||
} catch (InvalidArgumentException $e) {
|
} catch (InvalidArgumentException $e) {
|
||||||
Notification::make()
|
Notification::make()
|
||||||
->title('Workflow action failed')
|
->title('Workflow action failed')
|
||||||
|
|||||||
@ -108,7 +108,9 @@ private function getMembership(User $user, Tenant $tenant): ?array
|
|||||||
/**
|
/**
|
||||||
* Prime membership cache for a set of tenants in one query.
|
* Prime membership cache for a set of tenants in one query.
|
||||||
*
|
*
|
||||||
* Used to avoid N+1 queries for bulk selection authorization.
|
* Used to avoid N+1 queries for bulk selection authorization while still
|
||||||
|
* reflecting membership changes that may have happened earlier in the same
|
||||||
|
* request or test process.
|
||||||
*
|
*
|
||||||
* @param array<int, int|string> $tenantIds
|
* @param array<int, int|string> $tenantIds
|
||||||
*/
|
*/
|
||||||
@ -120,26 +122,14 @@ public function primeMemberships(User $user, array $tenantIds): void
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
$missingTenantIds = [];
|
|
||||||
foreach ($tenantIds as $tenantId) {
|
|
||||||
$cacheKey = "membership_{$user->id}_{$tenantId}";
|
|
||||||
if (! array_key_exists($cacheKey, $this->resolvedMemberships)) {
|
|
||||||
$missingTenantIds[] = $tenantId;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if ($missingTenantIds === []) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
$memberships = TenantMembership::query()
|
$memberships = TenantMembership::query()
|
||||||
->where('user_id', $user->id)
|
->where('user_id', $user->id)
|
||||||
->whereIn('tenant_id', $missingTenantIds)
|
->whereIn('tenant_id', $tenantIds)
|
||||||
->get(['tenant_id', 'role', 'source', 'source_ref']);
|
->get(['tenant_id', 'role', 'source', 'source_ref']);
|
||||||
|
|
||||||
$byTenantId = $memberships->keyBy('tenant_id');
|
$byTenantId = $memberships->keyBy('tenant_id');
|
||||||
|
|
||||||
foreach ($missingTenantIds as $tenantId) {
|
foreach ($tenantIds as $tenantId) {
|
||||||
$cacheKey = "membership_{$user->id}_{$tenantId}";
|
$cacheKey = "membership_{$user->id}_{$tenantId}";
|
||||||
$membership = $byTenantId->get($tenantId);
|
$membership = $byTenantId->get($tenantId);
|
||||||
$this->resolvedMemberships[$cacheKey] = $membership?->toArray();
|
$this->resolvedMemberships[$cacheKey] = $membership?->toArray();
|
||||||
|
|||||||
@ -5,12 +5,15 @@
|
|||||||
namespace App\Services\TenantReviews;
|
namespace App\Services\TenantReviews;
|
||||||
|
|
||||||
use App\Models\EvidenceSnapshot;
|
use App\Models\EvidenceSnapshot;
|
||||||
|
use App\Models\ReviewPack;
|
||||||
use App\Models\Tenant;
|
use App\Models\Tenant;
|
||||||
use App\Models\TenantReview;
|
use App\Models\TenantReview;
|
||||||
use App\Models\User;
|
use App\Models\User;
|
||||||
use App\Services\Audit\WorkspaceAuditLogger;
|
use App\Services\Audit\WorkspaceAuditLogger;
|
||||||
use App\Support\Audit\AuditActionId;
|
use App\Support\Audit\AuditActionId;
|
||||||
use App\Support\TenantReviewStatus;
|
use App\Support\TenantReviewStatus;
|
||||||
|
use App\Support\Ui\DerivedState\DerivedStateFamily;
|
||||||
|
use App\Support\Ui\DerivedState\RequestScopedDerivedStateStore;
|
||||||
use Illuminate\Support\Facades\DB;
|
use Illuminate\Support\Facades\DB;
|
||||||
use InvalidArgumentException;
|
use InvalidArgumentException;
|
||||||
|
|
||||||
@ -20,6 +23,7 @@ public function __construct(
|
|||||||
private readonly TenantReviewReadinessGate $readinessGate,
|
private readonly TenantReviewReadinessGate $readinessGate,
|
||||||
private readonly TenantReviewService $reviewService,
|
private readonly TenantReviewService $reviewService,
|
||||||
private readonly WorkspaceAuditLogger $auditLogger,
|
private readonly WorkspaceAuditLogger $auditLogger,
|
||||||
|
private readonly RequestScopedDerivedStateStore $derivedStateStore,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
public function publish(TenantReview $review, User $user): TenantReview
|
public function publish(TenantReview $review, User $user): TenantReview
|
||||||
@ -64,6 +68,8 @@ public function publish(TenantReview $review, User $user): TenantReview
|
|||||||
tenant: $tenant,
|
tenant: $tenant,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
$this->invalidateArtifactTruthCache($review);
|
||||||
|
|
||||||
return $review->refresh()->load(['tenant', 'sections', 'currentExportReviewPack']);
|
return $review->refresh()->load(['tenant', 'sections', 'currentExportReviewPack']);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -104,6 +110,8 @@ public function archive(TenantReview $review, User $user): TenantReview
|
|||||||
tenant: $tenant,
|
tenant: $tenant,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
$this->invalidateArtifactTruthCache($review);
|
||||||
|
|
||||||
return $review->refresh()->load(['tenant', 'sections', 'currentExportReviewPack']);
|
return $review->refresh()->load(['tenant', 'sections', 'currentExportReviewPack']);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -126,7 +134,7 @@ public function createNextReview(TenantReview $review, User $user, ?EvidenceSnap
|
|||||||
throw new InvalidArgumentException('An eligible evidence snapshot is required to create the next review.');
|
throw new InvalidArgumentException('An eligible evidence snapshot is required to create the next review.');
|
||||||
}
|
}
|
||||||
|
|
||||||
return DB::transaction(function () use ($review, $user, $snapshot, $tenant): TenantReview {
|
$nextReview = DB::transaction(function () use ($review, $user, $snapshot, $tenant): TenantReview {
|
||||||
$nextReview = $this->reviewService->create($tenant, $snapshot, $user);
|
$nextReview = $this->reviewService->create($tenant, $snapshot, $user);
|
||||||
|
|
||||||
if ((int) $nextReview->getKey() !== (int) $review->getKey()) {
|
if ((int) $nextReview->getKey() !== (int) $review->getKey()) {
|
||||||
@ -156,5 +164,23 @@ public function createNextReview(TenantReview $review, User $user, ?EvidenceSnap
|
|||||||
|
|
||||||
return $nextReview->refresh()->load(['tenant', 'evidenceSnapshot', 'sections', 'operationRun', 'initiator', 'publisher']);
|
return $nextReview->refresh()->load(['tenant', 'evidenceSnapshot', 'sections', 'operationRun', 'initiator', 'publisher']);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
$this->invalidateArtifactTruthCache($review);
|
||||||
|
$this->invalidateArtifactTruthCache($nextReview);
|
||||||
|
|
||||||
|
return $nextReview;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function invalidateArtifactTruthCache(TenantReview $review): void
|
||||||
|
{
|
||||||
|
$this->derivedStateStore->invalidateModel(DerivedStateFamily::ArtifactTruth, $review, 'tenant_review');
|
||||||
|
|
||||||
|
$review->loadMissing('currentExportReviewPack');
|
||||||
|
|
||||||
|
$pack = $review->currentExportReviewPack;
|
||||||
|
|
||||||
|
if ($pack instanceof ReviewPack) {
|
||||||
|
$this->derivedStateStore->invalidateModel(DerivedStateFamily::ArtifactTruth, $pack, 'review_pack');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -58,7 +58,7 @@ public function resolve(ReferenceDescriptor $descriptor): ResolvedReference
|
|||||||
secondaryLabel: 'Backup set #'.$backupSet->getKey(),
|
secondaryLabel: 'Backup set #'.$backupSet->getKey(),
|
||||||
linkTarget: new ReferenceLinkTarget(
|
linkTarget: new ReferenceLinkTarget(
|
||||||
targetKind: ReferenceClass::BackupSet->value,
|
targetKind: ReferenceClass::BackupSet->value,
|
||||||
url: BackupSetResource::getUrl('view', ['record' => $backupSet], tenant: $backupSet->tenant),
|
url: BackupSetResource::getUrl('view', ['record' => $backupSet], panel: 'tenant', tenant: $backupSet->tenant),
|
||||||
actionLabel: 'View backup set',
|
actionLabel: 'View backup set',
|
||||||
contextBadge: 'Tenant',
|
contextBadge: 'Tenant',
|
||||||
),
|
),
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user