Some checks failed
Main Confidence / confidence (push) Failing after 1m45s
## Summary - add the bounded workspace commercial lifecycle overlay from spec 251 on top of the existing entitlement substrate - expose audited commercial state inspection and mutation on the system workspace detail surface - gate onboarding activation and review-pack start actions through the shared lifecycle decision while preserving suspended read-only access to existing review, evidence, and generated-pack history - add focused Pest coverage plus the spec/plan/tasks/data-model/contract artifacts for the feature ## Validation - targeted Pest unit and feature lanes for lifecycle resolution, system-plane mutation, onboarding gating, review-pack enforcement, download preservation, customer review workspace access, and evidence snapshot access - `cd apps/platform && ./vendor/bin/sail bin pint --dirty --format agent` - integrated browser smoke on the system workspace detail and the preserved read-only review/evidence/review-pack surfaces ## Notes - branch: `251-commercial-entitlements-billing-state` - base: `dev` - commit: `606e9760` Co-authored-by: Ahmed Darrazi <ahmed.darrazi@live.de> Reviewed-on: #292
309 lines
11 KiB
PHP
309 lines
11 KiB
PHP
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
namespace App\Services\Settings;
|
|
|
|
use App\Models\PlatformUser;
|
|
use App\Models\Tenant;
|
|
use App\Models\TenantSetting;
|
|
use App\Models\User;
|
|
use App\Models\Workspace;
|
|
use App\Models\WorkspaceSetting;
|
|
use App\Services\Audit\WorkspaceAuditLogger;
|
|
use App\Services\Auth\WorkspaceCapabilityResolver;
|
|
use App\Services\Entitlements\WorkspaceCommercialLifecycleResolver;
|
|
use App\Support\Audit\AuditActionId;
|
|
use App\Support\Auth\Capabilities;
|
|
use App\Support\Auth\PlatformCapabilities;
|
|
use App\Support\Settings\SettingDefinition;
|
|
use App\Support\Settings\SettingsRegistry;
|
|
use Illuminate\Auth\Access\AuthorizationException;
|
|
use Illuminate\Support\Facades\DB;
|
|
use Illuminate\Support\Facades\Validator;
|
|
use Illuminate\Validation\ValidationException;
|
|
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
|
|
|
|
class SettingsWriter
|
|
{
|
|
public function __construct(
|
|
private SettingsRegistry $registry,
|
|
private SettingsResolver $resolver,
|
|
private WorkspaceAuditLogger $auditLogger,
|
|
private WorkspaceCapabilityResolver $workspaceCapabilityResolver,
|
|
) {}
|
|
|
|
public function updateWorkspaceSetting(User $actor, Workspace $workspace, string $domain, string $key, mixed $value): WorkspaceSetting
|
|
{
|
|
$this->authorizeManage($actor, $workspace);
|
|
|
|
$result = $this->persistWorkspaceSetting($workspace, $domain, $key, $value, (int) $actor->getKey());
|
|
|
|
$this->resolver->clearCache();
|
|
|
|
$afterValue = $this->resolver->resolveValue($workspace, $domain, $key);
|
|
|
|
$this->auditLogger->log(
|
|
workspace: $workspace,
|
|
action: AuditActionId::WorkspaceSettingUpdated->value,
|
|
context: [
|
|
'metadata' => [
|
|
'scope' => 'workspace',
|
|
'domain' => $domain,
|
|
'key' => $key,
|
|
'before_value' => $result['before_value'],
|
|
'after_value' => $afterValue,
|
|
],
|
|
],
|
|
actor: $actor,
|
|
resourceType: 'workspace_setting',
|
|
resourceId: $domain.'.'.$key,
|
|
);
|
|
|
|
return $result['setting'];
|
|
}
|
|
|
|
public function updateWorkspaceCommercialLifecycle(
|
|
PlatformUser $actor,
|
|
Workspace $workspace,
|
|
string $state,
|
|
string $reason,
|
|
): void {
|
|
$state = strtolower(trim($state));
|
|
$reason = trim($reason);
|
|
|
|
if (! $actor->hasCapability(PlatformCapabilities::COMMERCIAL_LIFECYCLE_MANAGE)) {
|
|
throw new AuthorizationException('Missing commercial lifecycle manage capability.');
|
|
}
|
|
|
|
if ($reason === '') {
|
|
throw ValidationException::withMessages([
|
|
'reason' => ['A rationale is required when changing commercial lifecycle state.'],
|
|
]);
|
|
}
|
|
|
|
DB::transaction(function () use ($actor, $workspace, $state, $reason): void {
|
|
$stateResult = $this->persistWorkspaceSetting(
|
|
workspace: $workspace,
|
|
domain: WorkspaceCommercialLifecycleResolver::SETTING_DOMAIN,
|
|
key: WorkspaceCommercialLifecycleResolver::SETTING_COMMERCIAL_LIFECYCLE_STATE,
|
|
value: $state,
|
|
updatedByUserId: null,
|
|
);
|
|
|
|
$reasonResult = $this->persistWorkspaceSetting(
|
|
workspace: $workspace,
|
|
domain: WorkspaceCommercialLifecycleResolver::SETTING_DOMAIN,
|
|
key: WorkspaceCommercialLifecycleResolver::SETTING_COMMERCIAL_LIFECYCLE_REASON,
|
|
value: $reason,
|
|
updatedByUserId: null,
|
|
);
|
|
|
|
$this->resolver->clearCache();
|
|
|
|
$afterState = $this->resolver->resolveValue(
|
|
$workspace,
|
|
WorkspaceCommercialLifecycleResolver::SETTING_DOMAIN,
|
|
WorkspaceCommercialLifecycleResolver::SETTING_COMMERCIAL_LIFECYCLE_STATE,
|
|
);
|
|
|
|
$afterReason = $this->resolver->resolveValue(
|
|
$workspace,
|
|
WorkspaceCommercialLifecycleResolver::SETTING_DOMAIN,
|
|
WorkspaceCommercialLifecycleResolver::SETTING_COMMERCIAL_LIFECYCLE_REASON,
|
|
);
|
|
|
|
$this->auditLogger->log(
|
|
workspace: $workspace,
|
|
action: AuditActionId::WorkspaceSettingUpdated->value,
|
|
context: [
|
|
'metadata' => [
|
|
'scope' => 'workspace',
|
|
'domain' => WorkspaceCommercialLifecycleResolver::SETTING_DOMAIN,
|
|
'key' => WorkspaceCommercialLifecycleResolver::SETTING_COMMERCIAL_LIFECYCLE_STATE,
|
|
'before_state' => $stateResult['before_value'],
|
|
'after_state' => $afterState,
|
|
'before_reason' => $reasonResult['before_value'],
|
|
'after_reason' => $afterReason,
|
|
],
|
|
],
|
|
actor: $actor,
|
|
resourceType: 'workspace_setting',
|
|
resourceId: WorkspaceCommercialLifecycleResolver::SETTING_DOMAIN.'.'.WorkspaceCommercialLifecycleResolver::SETTING_COMMERCIAL_LIFECYCLE_STATE,
|
|
targetLabel: 'Commercial lifecycle state',
|
|
);
|
|
});
|
|
}
|
|
|
|
public function resetWorkspaceSetting(User $actor, Workspace $workspace, string $domain, string $key): void
|
|
{
|
|
$this->authorizeManage($actor, $workspace);
|
|
|
|
$this->requireDefinition($domain, $key);
|
|
|
|
$existing = WorkspaceSetting::query()
|
|
->where('workspace_id', (int) $workspace->getKey())
|
|
->where('domain', $domain)
|
|
->where('key', $key)
|
|
->first();
|
|
|
|
$beforeValue = $existing instanceof WorkspaceSetting
|
|
? $this->decodeStoredValue($existing->getAttribute('value'))
|
|
: null;
|
|
|
|
if ($existing instanceof WorkspaceSetting) {
|
|
$existing->delete();
|
|
}
|
|
|
|
$this->resolver->clearCache();
|
|
|
|
$afterValue = $this->resolver->resolveValue($workspace, $domain, $key);
|
|
|
|
$this->auditLogger->log(
|
|
workspace: $workspace,
|
|
action: AuditActionId::WorkspaceSettingReset->value,
|
|
context: [
|
|
'metadata' => [
|
|
'scope' => 'workspace',
|
|
'domain' => $domain,
|
|
'key' => $key,
|
|
'before_value' => $beforeValue,
|
|
'after_value' => $afterValue,
|
|
],
|
|
],
|
|
actor: $actor,
|
|
resourceType: 'workspace_setting',
|
|
resourceId: $domain.'.'.$key,
|
|
);
|
|
}
|
|
|
|
public function updateTenantSetting(User $actor, Workspace $workspace, Tenant $tenant, string $domain, string $key, mixed $value): TenantSetting
|
|
{
|
|
$this->authorizeManage($actor, $workspace);
|
|
$this->assertTenantBelongsToWorkspace($workspace, $tenant);
|
|
|
|
$definition = $this->requireDefinition($domain, $key);
|
|
$normalizedValue = $this->validatedValue($definition, $value);
|
|
|
|
$setting = TenantSetting::query()->updateOrCreate([
|
|
'workspace_id' => (int) $workspace->getKey(),
|
|
'tenant_id' => (int) $tenant->getKey(),
|
|
'domain' => $domain,
|
|
'key' => $key,
|
|
], [
|
|
'value' => $normalizedValue,
|
|
'updated_by_user_id' => (int) $actor->getKey(),
|
|
]);
|
|
|
|
$this->resolver->clearCache();
|
|
|
|
return $setting;
|
|
}
|
|
|
|
public function resetTenantSetting(User $actor, Workspace $workspace, Tenant $tenant, string $domain, string $key): void
|
|
{
|
|
$this->authorizeManage($actor, $workspace);
|
|
$this->assertTenantBelongsToWorkspace($workspace, $tenant);
|
|
|
|
$this->requireDefinition($domain, $key);
|
|
|
|
TenantSetting::query()
|
|
->where('workspace_id', (int) $workspace->getKey())
|
|
->where('tenant_id', (int) $tenant->getKey())
|
|
->where('domain', $domain)
|
|
->where('key', $key)
|
|
->delete();
|
|
|
|
$this->resolver->clearCache();
|
|
}
|
|
|
|
private function requireDefinition(string $domain, string $key): SettingDefinition
|
|
{
|
|
$definition = $this->registry->find($domain, $key);
|
|
|
|
if ($definition instanceof SettingDefinition) {
|
|
return $definition;
|
|
}
|
|
|
|
throw ValidationException::withMessages([
|
|
'key' => [sprintf('Unknown setting key: %s.%s', $domain, $key)],
|
|
]);
|
|
}
|
|
|
|
/**
|
|
* @return array{setting: WorkspaceSetting, before_value: mixed}
|
|
*/
|
|
private function persistWorkspaceSetting(Workspace $workspace, string $domain, string $key, mixed $value, ?int $updatedByUserId): array
|
|
{
|
|
$definition = $this->requireDefinition($domain, $key);
|
|
$normalizedValue = $this->validatedValue($definition, $value);
|
|
|
|
$existing = WorkspaceSetting::query()
|
|
->where('workspace_id', (int) $workspace->getKey())
|
|
->where('domain', $domain)
|
|
->where('key', $key)
|
|
->first();
|
|
|
|
$beforeValue = $existing instanceof WorkspaceSetting
|
|
? $this->decodeStoredValue($existing->getAttribute('value'))
|
|
: null;
|
|
|
|
$setting = WorkspaceSetting::query()->updateOrCreate([
|
|
'workspace_id' => (int) $workspace->getKey(),
|
|
'domain' => $domain,
|
|
'key' => $key,
|
|
], [
|
|
'value' => $normalizedValue,
|
|
'updated_by_user_id' => $updatedByUserId,
|
|
]);
|
|
|
|
return [
|
|
'setting' => $setting,
|
|
'before_value' => $beforeValue,
|
|
];
|
|
}
|
|
|
|
private function validatedValue(SettingDefinition $definition, mixed $value): mixed
|
|
{
|
|
$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']);
|
|
}
|
|
|
|
private function authorizeManage(User $actor, Workspace $workspace): void
|
|
{
|
|
if (! $this->workspaceCapabilityResolver->isMember($actor, $workspace)) {
|
|
throw new NotFoundHttpException('Workspace not found.');
|
|
}
|
|
|
|
if (! $this->workspaceCapabilityResolver->can($actor, $workspace, Capabilities::WORKSPACE_SETTINGS_MANAGE)) {
|
|
throw new AuthorizationException('Missing workspace settings manage capability.');
|
|
}
|
|
}
|
|
|
|
private function assertTenantBelongsToWorkspace(Workspace $workspace, Tenant $tenant): void
|
|
{
|
|
if ((int) $tenant->workspace_id !== (int) $workspace->getKey()) {
|
|
throw new NotFoundHttpException('Tenant is outside the selected workspace scope.');
|
|
}
|
|
}
|
|
|
|
private function decodeStoredValue(mixed $value): mixed
|
|
{
|
|
if (! is_string($value)) {
|
|
return $value;
|
|
}
|
|
|
|
$decoded = json_decode($value, true);
|
|
|
|
return json_last_error() === JSON_ERROR_NONE ? $decoded : $value;
|
|
}
|
|
}
|