Implements spec 111 (Findings workflow + SLA) and fixes Workspace findings SLA settings UX/validation. Key changes: - Findings workflow service + SLA policy and alerting. - Workspace settings: allow partial SLA overrides without auto-filling unset severities in the UI; effective values still resolve via defaults. - New migrations, jobs, command, UI/resource updates, and comprehensive test coverage. Tests: - `vendor/bin/sail artisan test --compact` (1779 passed, 8 skipped). Co-authored-by: Ahmed Darrazi <ahmed.darrazi@live.de> Reviewed-on: #135
979 lines
33 KiB
PHP
979 lines
33 KiB
PHP
<?php
|
||
|
||
declare(strict_types=1);
|
||
|
||
namespace App\Filament\Pages\Settings;
|
||
|
||
use App\Models\User;
|
||
use App\Models\Workspace;
|
||
use App\Models\WorkspaceSetting;
|
||
use App\Services\Auth\WorkspaceCapabilityResolver;
|
||
use App\Services\Settings\SettingsResolver;
|
||
use App\Services\Settings\SettingsWriter;
|
||
use App\Support\Auth\Capabilities;
|
||
use App\Support\Settings\SettingDefinition;
|
||
use App\Support\Settings\SettingsRegistry;
|
||
use App\Support\Ui\ActionSurface\ActionSurfaceDeclaration;
|
||
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceProfile;
|
||
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceSlot;
|
||
use App\Support\Workspaces\WorkspaceContext;
|
||
use BackedEnum;
|
||
use Filament\Actions\Action;
|
||
use Filament\Forms\Components\KeyValue;
|
||
use Filament\Forms\Components\TextInput;
|
||
use Filament\Notifications\Notification;
|
||
use Filament\Pages\Page;
|
||
use Filament\Schemas\Components\Section;
|
||
use Filament\Schemas\Schema;
|
||
use Illuminate\Support\Carbon;
|
||
use Illuminate\Support\Facades\Validator;
|
||
use Illuminate\Validation\ValidationException;
|
||
use UnitEnum;
|
||
|
||
class WorkspaceSettings extends Page
|
||
{
|
||
protected static bool $isDiscovered = false;
|
||
|
||
protected static bool $shouldRegisterNavigation = false;
|
||
|
||
protected static ?string $slug = 'settings/workspace';
|
||
|
||
protected static ?string $title = 'Workspace settings';
|
||
|
||
protected static string|BackedEnum|null $navigationIcon = 'heroicon-o-cog-6-tooth';
|
||
|
||
protected static string|UnitEnum|null $navigationGroup = 'Settings';
|
||
|
||
protected static ?int $navigationSort = 20;
|
||
|
||
/**
|
||
* @var array<string, array{domain: string, key: string, type: 'int'|'json'}>
|
||
*/
|
||
private const SETTING_FIELDS = [
|
||
'backup_retention_keep_last_default' => ['domain' => 'backup', 'key' => 'retention_keep_last_default', 'type' => 'int'],
|
||
'backup_retention_min_floor' => ['domain' => 'backup', 'key' => 'retention_min_floor', 'type' => 'int'],
|
||
'drift_severity_mapping' => ['domain' => 'drift', 'key' => 'severity_mapping', 'type' => 'json'],
|
||
'findings_sla_days' => ['domain' => 'findings', 'key' => 'sla_days', 'type' => 'json'],
|
||
'operations_operation_run_retention_days' => ['domain' => 'operations', 'key' => 'operation_run_retention_days', 'type' => 'int'],
|
||
'operations_stuck_run_threshold_minutes' => ['domain' => 'operations', 'key' => 'stuck_run_threshold_minutes', 'type' => 'int'],
|
||
];
|
||
|
||
/**
|
||
* Fields rendered as Filament KeyValue components (array state, not JSON string).
|
||
*
|
||
* @var array<int, string>
|
||
*/
|
||
private const KEYVALUE_FIELDS = [
|
||
'drift_severity_mapping',
|
||
];
|
||
|
||
/**
|
||
* Findings SLA days are decomposed into individual form fields per severity.
|
||
*
|
||
* @var array<string, string>
|
||
*/
|
||
private const SLA_SUB_FIELDS = [
|
||
'findings_sla_critical' => 'critical',
|
||
'findings_sla_high' => 'high',
|
||
'findings_sla_medium' => 'medium',
|
||
'findings_sla_low' => 'low',
|
||
];
|
||
|
||
public Workspace $workspace;
|
||
|
||
/**
|
||
* @var array<string, mixed>
|
||
*/
|
||
public array $data = [];
|
||
|
||
/**
|
||
* @var array<string, mixed>
|
||
*/
|
||
public array $workspaceOverrides = [];
|
||
|
||
/**
|
||
* @var array<string, array{source: string, value: mixed, system_default: mixed}>
|
||
*/
|
||
public array $resolvedSettings = [];
|
||
|
||
/**
|
||
* Per-domain "last modified" metadata: domain => {user_name, updated_at}.
|
||
*
|
||
* @var array<string, array{user_name: string, updated_at: Carbon}>
|
||
*/
|
||
public array $domainLastModified = [];
|
||
|
||
/**
|
||
* @return array<Action>
|
||
*/
|
||
protected function getHeaderActions(): array
|
||
{
|
||
return [
|
||
Action::make('save')
|
||
->label('Save')
|
||
->action(function (): void {
|
||
$this->save();
|
||
})
|
||
->disabled(fn (): bool => ! $this->currentUserCanManage())
|
||
->tooltip(fn (): ?string => $this->currentUserCanManage()
|
||
? null
|
||
: 'You do not have permission to manage workspace settings.'),
|
||
];
|
||
}
|
||
|
||
public static function actionSurfaceDeclaration(): ActionSurfaceDeclaration
|
||
{
|
||
return ActionSurfaceDeclaration::forPage(ActionSurfaceProfile::ListOnlyReadOnly)
|
||
->satisfy(ActionSurfaceSlot::ListHeader, 'Header action saves settings; each setting includes a confirmed reset action.')
|
||
->exempt(ActionSurfaceSlot::InspectAffordance, 'Workspace settings are edited as a singleton form without a record inspect action.')
|
||
->exempt(ActionSurfaceSlot::ListRowMoreMenu, 'The page does not render table rows with secondary actions.')
|
||
->exempt(ActionSurfaceSlot::ListBulkMoreGroup, 'The page has no bulk actions because it manages a single settings scope.')
|
||
->exempt(ActionSurfaceSlot::ListEmptyState, 'The settings form is always rendered and has no list empty state.');
|
||
}
|
||
|
||
public function mount(): void
|
||
{
|
||
$user = auth()->user();
|
||
|
||
if (! $user instanceof User) {
|
||
abort(403);
|
||
}
|
||
|
||
$workspaceId = app(WorkspaceContext::class)->currentWorkspaceId(request());
|
||
|
||
if ($workspaceId === null) {
|
||
$this->redirect('/admin/choose-workspace');
|
||
|
||
return;
|
||
}
|
||
|
||
$workspace = Workspace::query()->whereKey($workspaceId)->first();
|
||
|
||
if (! $workspace instanceof Workspace) {
|
||
abort(404);
|
||
}
|
||
|
||
$this->workspace = $workspace;
|
||
|
||
$this->authorizeWorkspaceView($user);
|
||
|
||
$this->loadFormState();
|
||
}
|
||
|
||
public function content(Schema $schema): Schema
|
||
{
|
||
return $schema
|
||
->statePath('data')
|
||
->schema([
|
||
Section::make('Backup settings')
|
||
->description($this->sectionDescription('backup', 'Workspace defaults used when a schedule has no explicit value.'))
|
||
->schema([
|
||
TextInput::make('backup_retention_keep_last_default')
|
||
->label('Default retention keep-last')
|
||
->placeholder('Unset (uses default)')
|
||
->suffix('versions')
|
||
->hint('1 – 365')
|
||
->numeric()
|
||
->integer()
|
||
->minValue(1)
|
||
->maxValue(365)
|
||
->disabled(fn (): bool => ! $this->currentUserCanManage())
|
||
->helperText(fn (): string => $this->helperTextFor('backup_retention_keep_last_default'))
|
||
->hintAction($this->makeResetAction('backup_retention_keep_last_default')),
|
||
TextInput::make('backup_retention_min_floor')
|
||
->label('Minimum retention floor')
|
||
->placeholder('Unset (uses default)')
|
||
->suffix('versions')
|
||
->hint('1 – 365')
|
||
->numeric()
|
||
->integer()
|
||
->minValue(1)
|
||
->maxValue(365)
|
||
->disabled(fn (): bool => ! $this->currentUserCanManage())
|
||
->helperText(fn (): string => $this->helperTextFor('backup_retention_min_floor'))
|
||
->hintAction($this->makeResetAction('backup_retention_min_floor')),
|
||
]),
|
||
Section::make('Drift settings')
|
||
->description($this->sectionDescription('drift', 'Map finding types to severity levels. Allowed severities: critical, high, medium, low.'))
|
||
->schema([
|
||
KeyValue::make('drift_severity_mapping')
|
||
->label('Severity mapping')
|
||
->keyLabel('Finding type')
|
||
->valueLabel('Severity')
|
||
->keyPlaceholder('e.g. drift')
|
||
->valuePlaceholder('critical, high, medium, or low')
|
||
->disabled(fn (): bool => ! $this->currentUserCanManage())
|
||
->helperText(fn (): string => $this->helperTextFor('drift_severity_mapping'))
|
||
->hintAction($this->makeResetAction('drift_severity_mapping')),
|
||
]),
|
||
Section::make('Findings settings')
|
||
->key('findings_section')
|
||
->description($this->sectionDescription('findings', 'Configure workspace-wide SLA days by severity. Set one or more, or leave all empty to use the system default. Unset severities use their default.'))
|
||
->columns(2)
|
||
->afterHeader([
|
||
$this->makeResetAction('findings_sla_days')->label('Reset all SLA')->size('sm'),
|
||
])
|
||
->schema([
|
||
TextInput::make('findings_sla_critical')
|
||
->label('Critical severity')
|
||
->placeholder('Unset (uses default)')
|
||
->suffix('days')
|
||
->hint('1 – 3,650')
|
||
->numeric()
|
||
->integer()
|
||
->minValue(1)
|
||
->maxValue(3650)
|
||
->disabled(fn (): bool => ! $this->currentUserCanManage())
|
||
->helperText(fn (): string => $this->slaFieldHelperText('critical')),
|
||
TextInput::make('findings_sla_high')
|
||
->label('High severity')
|
||
->placeholder('Unset (uses default)')
|
||
->suffix('days')
|
||
->hint('1 – 3,650')
|
||
->numeric()
|
||
->integer()
|
||
->minValue(1)
|
||
->maxValue(3650)
|
||
->disabled(fn (): bool => ! $this->currentUserCanManage())
|
||
->helperText(fn (): string => $this->slaFieldHelperText('high')),
|
||
TextInput::make('findings_sla_medium')
|
||
->label('Medium severity')
|
||
->placeholder('Unset (uses default)')
|
||
->suffix('days')
|
||
->hint('1 – 3,650')
|
||
->numeric()
|
||
->integer()
|
||
->minValue(1)
|
||
->maxValue(3650)
|
||
->disabled(fn (): bool => ! $this->currentUserCanManage())
|
||
->helperText(fn (): string => $this->slaFieldHelperText('medium')),
|
||
TextInput::make('findings_sla_low')
|
||
->label('Low severity')
|
||
->placeholder('Unset (uses default)')
|
||
->suffix('days')
|
||
->hint('1 – 3,650')
|
||
->numeric()
|
||
->integer()
|
||
->minValue(1)
|
||
->maxValue(3650)
|
||
->disabled(fn (): bool => ! $this->currentUserCanManage())
|
||
->helperText(fn (): string => $this->slaFieldHelperText('low')),
|
||
]),
|
||
Section::make('Operations settings')
|
||
->description($this->sectionDescription('operations', 'Workspace controls for operations retention and thresholds.'))
|
||
->schema([
|
||
TextInput::make('operations_operation_run_retention_days')
|
||
->label('Operation run retention')
|
||
->placeholder('Unset (uses default)')
|
||
->suffix('days')
|
||
->hint('7 – 3,650')
|
||
->numeric()
|
||
->integer()
|
||
->minValue(7)
|
||
->maxValue(3650)
|
||
->disabled(fn (): bool => ! $this->currentUserCanManage())
|
||
->helperText(fn (): string => $this->helperTextFor('operations_operation_run_retention_days'))
|
||
->hintAction($this->makeResetAction('operations_operation_run_retention_days')),
|
||
TextInput::make('operations_stuck_run_threshold_minutes')
|
||
->label('Stuck run threshold')
|
||
->placeholder('Unset (uses default)')
|
||
->suffix('minutes')
|
||
->hint('0 – 10,080')
|
||
->numeric()
|
||
->integer()
|
||
->minValue(0)
|
||
->maxValue(10080)
|
||
->disabled(fn (): bool => ! $this->currentUserCanManage())
|
||
->helperText(fn (): string => $this->helperTextFor('operations_stuck_run_threshold_minutes'))
|
||
->hintAction($this->makeResetAction('operations_stuck_run_threshold_minutes')),
|
||
]),
|
||
]);
|
||
}
|
||
|
||
public function save(): void
|
||
{
|
||
$user = auth()->user();
|
||
|
||
if (! $user instanceof User) {
|
||
abort(403);
|
||
}
|
||
|
||
$this->authorizeWorkspaceManage($user);
|
||
|
||
$this->resetValidation();
|
||
|
||
$this->composeSlaSubFieldsIntoData();
|
||
|
||
[$normalizedValues, $validationErrors] = $this->normalizedInputValues();
|
||
|
||
if ($validationErrors !== []) {
|
||
throw ValidationException::withMessages($validationErrors);
|
||
}
|
||
|
||
$writer = app(SettingsWriter::class);
|
||
$changedSettingsCount = 0;
|
||
|
||
foreach (self::SETTING_FIELDS as $field => $setting) {
|
||
$incomingValue = $normalizedValues[$field] ?? null;
|
||
$currentOverride = $this->workspaceOverrideForField($field);
|
||
|
||
if ($incomingValue === null) {
|
||
if ($currentOverride === null) {
|
||
continue;
|
||
}
|
||
|
||
$writer->resetWorkspaceSetting(
|
||
actor: $user,
|
||
workspace: $this->workspace,
|
||
domain: $setting['domain'],
|
||
key: $setting['key'],
|
||
);
|
||
|
||
$changedSettingsCount++;
|
||
|
||
continue;
|
||
}
|
||
|
||
if ($this->valuesEqual($incomingValue, $currentOverride)) {
|
||
continue;
|
||
}
|
||
|
||
$writer->updateWorkspaceSetting(
|
||
actor: $user,
|
||
workspace: $this->workspace,
|
||
domain: $setting['domain'],
|
||
key: $setting['key'],
|
||
value: $incomingValue,
|
||
);
|
||
|
||
$changedSettingsCount++;
|
||
}
|
||
|
||
$this->loadFormState();
|
||
|
||
Notification::make()
|
||
->title($changedSettingsCount > 0 ? 'Workspace settings saved' : 'No settings changes to save')
|
||
->success()
|
||
->send();
|
||
}
|
||
|
||
public function resetSetting(string $field): void
|
||
{
|
||
$user = auth()->user();
|
||
|
||
if (! $user instanceof User) {
|
||
abort(403);
|
||
}
|
||
|
||
$this->authorizeWorkspaceManage($user);
|
||
|
||
$setting = $this->settingForField($field);
|
||
|
||
if ($this->workspaceOverrideForField($field) === null) {
|
||
Notification::make()
|
||
->title('Setting already uses default')
|
||
->success()
|
||
->send();
|
||
|
||
return;
|
||
}
|
||
|
||
app(SettingsWriter::class)->resetWorkspaceSetting(
|
||
actor: $user,
|
||
workspace: $this->workspace,
|
||
domain: $setting['domain'],
|
||
key: $setting['key'],
|
||
);
|
||
|
||
$this->loadFormState();
|
||
|
||
Notification::make()
|
||
->title('Workspace setting reset to default')
|
||
->success()
|
||
->send();
|
||
}
|
||
|
||
private function loadFormState(): void
|
||
{
|
||
$resolver = app(SettingsResolver::class);
|
||
|
||
$data = [];
|
||
$workspaceOverrides = [];
|
||
$resolvedSettings = [];
|
||
|
||
foreach (self::SETTING_FIELDS as $field => $setting) {
|
||
$resolved = $resolver->resolveDetailed(
|
||
workspace: $this->workspace,
|
||
domain: $setting['domain'],
|
||
key: $setting['key'],
|
||
);
|
||
|
||
$workspaceValue = $resolved['workspace_value'];
|
||
|
||
$workspaceOverrides[$field] = $workspaceValue;
|
||
$resolvedSettings[$field] = [
|
||
'source' => $resolved['source'],
|
||
'value' => $resolved['value'],
|
||
'system_default' => $resolved['system_default'],
|
||
];
|
||
|
||
$data[$field] = $workspaceValue === null
|
||
? (in_array($field, self::KEYVALUE_FIELDS, true) ? [] : null)
|
||
: $this->formatValueForInput($field, $workspaceValue);
|
||
}
|
||
|
||
$this->decomposeSlaSubFields($data, $workspaceOverrides, $resolvedSettings);
|
||
|
||
$this->data = $data;
|
||
$this->workspaceOverrides = $workspaceOverrides;
|
||
$this->resolvedSettings = $resolvedSettings;
|
||
|
||
$this->loadDomainLastModified();
|
||
}
|
||
|
||
/**
|
||
* Load per-domain "last modified" metadata from workspace_settings.
|
||
*/
|
||
private function loadDomainLastModified(): void
|
||
{
|
||
$domains = array_unique(array_column(self::SETTING_FIELDS, 'domain'));
|
||
|
||
$records = WorkspaceSetting::query()
|
||
->where('workspace_id', (int) $this->workspace->getKey())
|
||
->whereIn('domain', $domains)
|
||
->whereNotNull('updated_by_user_id')
|
||
->with('updatedByUser:id,name')
|
||
->get();
|
||
|
||
$domainInfo = [];
|
||
|
||
foreach ($records as $record) {
|
||
/** @var WorkspaceSetting $record */
|
||
$domain = $record->domain;
|
||
$updatedAt = $record->updated_at;
|
||
|
||
if (! $updatedAt instanceof Carbon) {
|
||
continue;
|
||
}
|
||
|
||
if (isset($domainInfo[$domain]) && $domainInfo[$domain]['updated_at']->gte($updatedAt)) {
|
||
continue;
|
||
}
|
||
|
||
$user = $record->updatedByUser;
|
||
|
||
$domainInfo[$domain] = [
|
||
'user_name' => $user instanceof User ? $user->name : 'Unknown',
|
||
'updated_at' => $updatedAt,
|
||
];
|
||
}
|
||
|
||
$this->domainLastModified = $domainInfo;
|
||
}
|
||
|
||
/**
|
||
* Build a section description that appends "last modified" info when available.
|
||
*/
|
||
private function sectionDescription(string $domain, string $baseDescription): string
|
||
{
|
||
$meta = $this->domainLastModified[$domain] ?? null;
|
||
|
||
if (! is_array($meta)) {
|
||
return $baseDescription;
|
||
}
|
||
|
||
/** @var Carbon $updatedAt */
|
||
$updatedAt = $meta['updated_at'];
|
||
|
||
return sprintf(
|
||
'%s — Last modified by %s, %s.',
|
||
$baseDescription,
|
||
$meta['user_name'],
|
||
$updatedAt->diffForHumans(),
|
||
);
|
||
}
|
||
|
||
private function makeResetAction(string $field): Action
|
||
{
|
||
return Action::make('reset_'.$field)
|
||
->label('Reset')
|
||
->color('danger')
|
||
->requiresConfirmation()
|
||
->action(function () use ($field): void {
|
||
$this->resetSetting($field);
|
||
})
|
||
->disabled(fn (): bool => ! $this->currentUserCanManage() || ! $this->hasWorkspaceOverride($field))
|
||
->tooltip(function () use ($field): ?string {
|
||
if (! $this->currentUserCanManage()) {
|
||
return 'You do not have permission to manage workspace settings.';
|
||
}
|
||
|
||
if (! $this->hasWorkspaceOverride($field)) {
|
||
return 'No workspace override to reset.';
|
||
}
|
||
|
||
return null;
|
||
});
|
||
}
|
||
|
||
private function helperTextFor(string $field): string
|
||
{
|
||
$resolved = $this->resolvedSettings[$field] ?? null;
|
||
|
||
if (! is_array($resolved)) {
|
||
return '';
|
||
}
|
||
|
||
$effectiveValue = $this->formatValueForDisplay($field, $resolved['value'] ?? null);
|
||
|
||
if (! $this->hasWorkspaceOverride($field)) {
|
||
return sprintf(
|
||
'Unset. Effective value: %s (%s).',
|
||
$effectiveValue,
|
||
$this->sourceLabel((string) ($resolved['source'] ?? 'system_default')),
|
||
);
|
||
}
|
||
|
||
return sprintf('Effective value: %s.', $effectiveValue);
|
||
}
|
||
|
||
private function slaFieldHelperText(string $severity): string
|
||
{
|
||
$resolved = $this->resolvedSettings['findings_sla_days'] ?? null;
|
||
|
||
if (! is_array($resolved)) {
|
||
return '';
|
||
}
|
||
|
||
$effectiveValue = is_array($resolved['value'] ?? null)
|
||
? (int) ($resolved['value'][$severity] ?? 0)
|
||
: 0;
|
||
|
||
$systemDefault = is_array($resolved['system_default'] ?? null)
|
||
? (int) ($resolved['system_default'][$severity] ?? 0)
|
||
: 0;
|
||
|
||
if (! $this->hasWorkspaceOverride('findings_sla_days')) {
|
||
return sprintf('Default: %d days.', $systemDefault);
|
||
}
|
||
|
||
return sprintf('Effective: %d days.', $effectiveValue);
|
||
}
|
||
|
||
/**
|
||
* @return array{0: array<string, mixed>, 1: array<string, array<int, string>>}
|
||
*/
|
||
private function normalizedInputValues(): array
|
||
{
|
||
$normalizedValues = [];
|
||
$validationErrors = [];
|
||
|
||
foreach (self::SETTING_FIELDS as $field => $_setting) {
|
||
try {
|
||
$normalizedValues[$field] = $this->normalizeFieldInput(
|
||
field: $field,
|
||
value: $this->data[$field] ?? null,
|
||
);
|
||
} catch (ValidationException $exception) {
|
||
$messages = [];
|
||
|
||
foreach ($exception->errors() as $errorMessages) {
|
||
foreach ((array) $errorMessages as $message) {
|
||
$messages[] = (string) $message;
|
||
}
|
||
}
|
||
|
||
if ($field === 'findings_sla_days') {
|
||
$severityToField = array_flip(self::SLA_SUB_FIELDS);
|
||
|
||
$targeted = false;
|
||
|
||
foreach ($messages as $message) {
|
||
if (preg_match('/include "(?<severity>critical|high|medium|low)"/i', $message, $matches) === 1) {
|
||
$severity = strtolower((string) $matches['severity']);
|
||
$subField = $severityToField[$severity] ?? null;
|
||
|
||
if (is_string($subField)) {
|
||
$validationErrors['data.'.$subField] ??= [];
|
||
$validationErrors['data.'.$subField][] = $message;
|
||
$targeted = true;
|
||
}
|
||
}
|
||
}
|
||
|
||
if (! $targeted) {
|
||
foreach (self::SLA_SUB_FIELDS as $subField => $_severity) {
|
||
$validationErrors['data.'.$subField] = $messages !== []
|
||
? $messages
|
||
: ['Invalid value.'];
|
||
}
|
||
}
|
||
|
||
continue;
|
||
}
|
||
|
||
$validationErrors['data.'.$field] = $messages !== []
|
||
? $messages
|
||
: ['Invalid value.'];
|
||
}
|
||
}
|
||
|
||
return [$normalizedValues, $validationErrors];
|
||
}
|
||
|
||
private function normalizeFieldInput(string $field, mixed $value): mixed
|
||
{
|
||
$setting = $this->settingForField($field);
|
||
|
||
if ($value === null) {
|
||
return null;
|
||
}
|
||
|
||
if (is_string($value) && trim($value) === '') {
|
||
return null;
|
||
}
|
||
|
||
if (is_array($value) && $value === []) {
|
||
return null;
|
||
}
|
||
|
||
if ($setting['type'] === 'json') {
|
||
$value = $this->normalizeJsonInput($value);
|
||
|
||
if (in_array($field, self::KEYVALUE_FIELDS, true)) {
|
||
$value = $this->normalizeKeyValueInput($value);
|
||
|
||
if ($value === []) {
|
||
return null;
|
||
}
|
||
}
|
||
}
|
||
|
||
$definition = $this->settingDefinition($field);
|
||
|
||
$validator = Validator::make(
|
||
data: ['value' => $value],
|
||
rules: ['value' => $definition->rules],
|
||
);
|
||
|
||
if ($validator->fails()) {
|
||
throw ValidationException::withMessages($validator->errors()->toArray());
|
||
}
|
||
|
||
return $definition->normalize($validator->validated()['value']);
|
||
}
|
||
|
||
/**
|
||
* Normalize KeyValue component state.
|
||
*
|
||
* Filament's KeyValue UI keeps an empty row by default, which can submit as
|
||
* ['' => ''] and would otherwise fail validation. We treat empty rows as unset.
|
||
*
|
||
* @param array<mixed> $value
|
||
* @return array<string, mixed>
|
||
*/
|
||
private function normalizeKeyValueInput(array $value): array
|
||
{
|
||
$normalized = [];
|
||
|
||
foreach ($value as $key => $item) {
|
||
if (is_array($item) && array_key_exists('key', $item)) {
|
||
$rowKey = $item['key'];
|
||
$rowValue = $item['value'] ?? null;
|
||
|
||
if (! is_string($rowKey)) {
|
||
continue;
|
||
}
|
||
|
||
$trimmedKey = trim($rowKey);
|
||
|
||
if ($trimmedKey === '') {
|
||
continue;
|
||
}
|
||
|
||
if (is_string($rowValue)) {
|
||
$trimmedValue = trim($rowValue);
|
||
|
||
if ($trimmedValue === '') {
|
||
continue;
|
||
}
|
||
|
||
$normalized[$trimmedKey] = $trimmedValue;
|
||
|
||
continue;
|
||
}
|
||
|
||
if ($rowValue === null) {
|
||
continue;
|
||
}
|
||
|
||
$normalized[$trimmedKey] = $rowValue;
|
||
|
||
continue;
|
||
}
|
||
|
||
if (! is_string($key)) {
|
||
continue;
|
||
}
|
||
|
||
$trimmedKey = trim($key);
|
||
|
||
if ($trimmedKey === '') {
|
||
continue;
|
||
}
|
||
|
||
if (is_string($item)) {
|
||
$trimmedValue = trim($item);
|
||
|
||
if ($trimmedValue === '') {
|
||
continue;
|
||
}
|
||
|
||
$normalized[$trimmedKey] = $trimmedValue;
|
||
|
||
continue;
|
||
}
|
||
|
||
if ($item === null) {
|
||
continue;
|
||
}
|
||
|
||
$normalized[$trimmedKey] = $item;
|
||
}
|
||
|
||
return $normalized;
|
||
}
|
||
|
||
private function normalizeJsonInput(mixed $value): array
|
||
{
|
||
if (is_array($value)) {
|
||
return $value;
|
||
}
|
||
|
||
if (! is_string($value)) {
|
||
throw ValidationException::withMessages([
|
||
'value' => ['The value must be valid JSON.'],
|
||
]);
|
||
}
|
||
|
||
$decoded = json_decode($value, true);
|
||
|
||
if (json_last_error() !== JSON_ERROR_NONE) {
|
||
throw ValidationException::withMessages([
|
||
'value' => ['The value must be valid JSON.'],
|
||
]);
|
||
}
|
||
|
||
if (! is_array($decoded)) {
|
||
throw ValidationException::withMessages([
|
||
'value' => ['The value must be a JSON object.'],
|
||
]);
|
||
}
|
||
|
||
return $decoded;
|
||
}
|
||
|
||
private function valuesEqual(mixed $left, mixed $right): bool
|
||
{
|
||
if ($left === null || $right === null) {
|
||
return $left === $right;
|
||
}
|
||
|
||
if (is_array($left) && is_array($right)) {
|
||
return $this->encodeCanonicalArray($left) === $this->encodeCanonicalArray($right);
|
||
}
|
||
|
||
if (is_numeric($left) && is_numeric($right)) {
|
||
return (int) $left === (int) $right;
|
||
}
|
||
|
||
return $left === $right;
|
||
}
|
||
|
||
private function encodeCanonicalArray(array $value): string
|
||
{
|
||
$encoded = json_encode($this->sortNestedArray($value));
|
||
|
||
return is_string($encoded) ? $encoded : '';
|
||
}
|
||
|
||
/**
|
||
* @param array<mixed> $value
|
||
* @return array<mixed>
|
||
*/
|
||
private function sortNestedArray(array $value): array
|
||
{
|
||
foreach ($value as $key => $item) {
|
||
if (! is_array($item)) {
|
||
continue;
|
||
}
|
||
|
||
$value[$key] = $this->sortNestedArray($item);
|
||
}
|
||
|
||
ksort($value);
|
||
|
||
return $value;
|
||
}
|
||
|
||
private function formatValueForInput(string $field, mixed $value): mixed
|
||
{
|
||
$setting = $this->settingForField($field);
|
||
|
||
if ($setting['type'] === 'json') {
|
||
if (! is_array($value)) {
|
||
return null;
|
||
}
|
||
|
||
if (in_array($field, self::KEYVALUE_FIELDS, true)) {
|
||
return $value;
|
||
}
|
||
|
||
$encoded = json_encode($value, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES);
|
||
|
||
return is_string($encoded) ? $encoded : null;
|
||
}
|
||
|
||
return is_numeric($value) ? (int) $value : null;
|
||
}
|
||
|
||
private function formatValueForDisplay(string $field, mixed $value): string
|
||
{
|
||
$setting = $this->settingForField($field);
|
||
|
||
if ($setting['type'] === 'json') {
|
||
if (! is_array($value) || $value === []) {
|
||
return '{}';
|
||
}
|
||
|
||
$encoded = json_encode($value, JSON_UNESCAPED_SLASHES);
|
||
|
||
return is_string($encoded) ? $encoded : '{}';
|
||
}
|
||
|
||
return is_numeric($value) ? (string) (int) $value : 'null';
|
||
}
|
||
|
||
private function sourceLabel(string $source): string
|
||
{
|
||
return match ($source) {
|
||
'workspace_override' => 'workspace override',
|
||
'tenant_override' => 'tenant override',
|
||
default => 'system default',
|
||
};
|
||
}
|
||
|
||
/**
|
||
* @return array{domain: string, key: string, type: 'int'|'json'}
|
||
*/
|
||
private function settingForField(string $field): array
|
||
{
|
||
if (! isset(self::SETTING_FIELDS[$field])) {
|
||
throw ValidationException::withMessages([
|
||
'data' => [sprintf('Unknown settings field: %s', $field)],
|
||
]);
|
||
}
|
||
|
||
return self::SETTING_FIELDS[$field];
|
||
}
|
||
|
||
private function settingDefinition(string $field): SettingDefinition
|
||
{
|
||
$setting = $this->settingForField($field);
|
||
|
||
return app(SettingsRegistry::class)->require($setting['domain'], $setting['key']);
|
||
}
|
||
|
||
private function hasWorkspaceOverride(string $field): bool
|
||
{
|
||
return $this->workspaceOverrideForField($field) !== null;
|
||
}
|
||
|
||
private function workspaceOverrideForField(string $field): mixed
|
||
{
|
||
return $this->workspaceOverrides[$field] ?? null;
|
||
}
|
||
|
||
/**
|
||
* Decompose the findings_sla_days JSON setting into individual SLA sub-fields.
|
||
*
|
||
* @param array<string, mixed> $data
|
||
* @param array<string, mixed> $workspaceOverrides
|
||
* @param array<string, array{source: string, value: mixed, system_default: mixed}> $resolvedSettings
|
||
*/
|
||
private function decomposeSlaSubFields(array &$data, array &$workspaceOverrides, array &$resolvedSettings): void
|
||
{
|
||
$slaOverride = $workspaceOverrides['findings_sla_days'] ?? null;
|
||
$slaResolved = $resolvedSettings['findings_sla_days'] ?? null;
|
||
|
||
foreach (self::SLA_SUB_FIELDS as $subField => $severity) {
|
||
$data[$subField] = is_array($slaOverride) && isset($slaOverride[$severity])
|
||
? (int) $slaOverride[$severity]
|
||
: null;
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Re-compose individual SLA sub-fields back into the findings_sla_days data key before save.
|
||
*/
|
||
private function composeSlaSubFieldsIntoData(): void
|
||
{
|
||
$values = [];
|
||
$hasAnyValue = false;
|
||
|
||
foreach (self::SLA_SUB_FIELDS as $subField => $severity) {
|
||
$val = $this->data[$subField] ?? null;
|
||
|
||
if ($val !== null && (is_string($val) ? trim($val) !== '' : true)) {
|
||
$values[$severity] = (int) $val;
|
||
$hasAnyValue = true;
|
||
}
|
||
}
|
||
|
||
$this->data['findings_sla_days'] = $hasAnyValue ? $values : null;
|
||
}
|
||
|
||
private function currentUserCanManage(): bool
|
||
{
|
||
$user = auth()->user();
|
||
|
||
if (! $user instanceof User || ! $this->workspace instanceof Workspace) {
|
||
return false;
|
||
}
|
||
|
||
/** @var WorkspaceCapabilityResolver $resolver */
|
||
$resolver = app(WorkspaceCapabilityResolver::class);
|
||
|
||
return $resolver->isMember($user, $this->workspace)
|
||
&& $resolver->can($user, $this->workspace, Capabilities::WORKSPACE_SETTINGS_MANAGE);
|
||
}
|
||
|
||
private function authorizeWorkspaceView(User $user): void
|
||
{
|
||
/** @var WorkspaceCapabilityResolver $resolver */
|
||
$resolver = app(WorkspaceCapabilityResolver::class);
|
||
|
||
if (! $resolver->isMember($user, $this->workspace)) {
|
||
abort(404);
|
||
}
|
||
|
||
if (! $resolver->can($user, $this->workspace, Capabilities::WORKSPACE_SETTINGS_VIEW)) {
|
||
abort(403);
|
||
}
|
||
}
|
||
|
||
private function authorizeWorkspaceManage(User $user): void
|
||
{
|
||
/** @var WorkspaceCapabilityResolver $resolver */
|
||
$resolver = app(WorkspaceCapabilityResolver::class);
|
||
|
||
if (! $resolver->isMember($user, $this->workspace)) {
|
||
abort(404);
|
||
}
|
||
|
||
if (! $resolver->can($user, $this->workspace, Capabilities::WORKSPACE_SETTINGS_MANAGE)) {
|
||
abort(403);
|
||
}
|
||
}
|
||
}
|