TenantAtlas/apps/platform/app/Services/Settings/SettingsWriter.php
Ahmed Darrazi b7a587495f
Some checks failed
PR Fast Feedback / fast-feedback (pull_request) Failing after 17m36s
chore: commit all local changes
2026-05-04 23:02:19 +02:00

435 lines
16 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\Models\WorkspaceSubscription;
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\Carbon;
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',
);
});
}
/**
* @param array<string, mixed> $attributes
*/
public function updateWorkspaceSubscription(
PlatformUser $actor,
Workspace $workspace,
array $attributes,
): WorkspaceSubscription {
$this->authorizeCommercialLifecycleManage($actor);
$validator = Validator::make($attributes, [
'state' => ['required', 'string', 'in:'.implode(',', WorkspaceSubscription::stateIds())],
'billing_reference' => ['nullable', 'string', 'max:191'],
'trial_ends_at' => ['nullable', 'date'],
'current_period_starts_at' => ['nullable', 'date'],
'current_period_ends_at' => ['nullable', 'date'],
'status_reason' => ['required', 'string', 'max:500'],
]);
if ($validator->fails()) {
throw ValidationException::withMessages($validator->errors()->toArray());
}
$validated = $validator->validated();
$state = (string) $validated['state'];
if ($state === WorkspaceSubscription::STATE_TRIAL && blank($validated['trial_ends_at'] ?? null)) {
throw ValidationException::withMessages([
'trial_ends_at' => ['A trial end date is required for trial subscriptions.'],
]);
}
if (in_array($state, [
WorkspaceSubscription::STATE_ACTIVE,
WorkspaceSubscription::STATE_PAST_DUE,
WorkspaceSubscription::STATE_CANCEL_AT_PERIOD_END,
], true) && blank($validated['current_period_starts_at'] ?? null)) {
throw ValidationException::withMessages([
'current_period_starts_at' => ['A current period start date is required for this subscription state.'],
]);
}
if (in_array($state, [
WorkspaceSubscription::STATE_ACTIVE,
WorkspaceSubscription::STATE_PAST_DUE,
WorkspaceSubscription::STATE_CANCEL_AT_PERIOD_END,
WorkspaceSubscription::STATE_ENDED,
], true) && blank($validated['current_period_ends_at'] ?? null)) {
throw ValidationException::withMessages([
'current_period_ends_at' => ['A current period end date is required for this subscription state.'],
]);
}
return DB::transaction(function () use ($actor, $workspace, $validated): WorkspaceSubscription {
$workspace->loadMissing('subscription');
$before = $workspace->subscription instanceof WorkspaceSubscription
? $this->workspaceSubscriptionAuditPayload($workspace->subscription)
: null;
$subscription = WorkspaceSubscription::query()->updateOrCreate(
['workspace_id' => (int) $workspace->getKey()],
[
'state' => (string) $validated['state'],
'billing_reference' => filled($validated['billing_reference'] ?? null)
? trim((string) $validated['billing_reference'])
: null,
'trial_ends_at' => filled($validated['trial_ends_at'] ?? null)
? Carbon::parse((string) $validated['trial_ends_at'])
: null,
'current_period_starts_at' => filled($validated['current_period_starts_at'] ?? null)
? Carbon::parse((string) $validated['current_period_starts_at'])
: null,
'current_period_ends_at' => filled($validated['current_period_ends_at'] ?? null)
? Carbon::parse((string) $validated['current_period_ends_at'])
: null,
'status_reason' => trim((string) $validated['status_reason']),
],
);
$workspace->setRelation('subscription', $subscription->fresh());
$this->auditLogger->log(
workspace: $workspace,
action: AuditActionId::WorkspaceSubscriptionUpdated,
context: [
'metadata' => [
'before' => $before,
'after' => $this->workspaceSubscriptionAuditPayload($subscription),
],
],
actor: $actor,
resourceType: 'workspace_subscription',
resourceId: (string) $subscription->getKey(),
targetLabel: 'Current workspace subscription',
summary: 'Workspace subscription updated',
);
return $subscription;
});
}
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 authorizeCommercialLifecycleManage(PlatformUser $actor): void
{
if (! $actor->hasCapability(PlatformCapabilities::COMMERCIAL_LIFECYCLE_MANAGE)) {
throw new AuthorizationException('Missing commercial lifecycle 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;
}
/**
* @return array<string, mixed>
*/
private function workspaceSubscriptionAuditPayload(WorkspaceSubscription $subscription): array
{
return [
'state' => $subscription->state,
'billing_reference' => $subscription->billing_reference,
'trial_ends_at' => $subscription->trial_ends_at?->toAtomString(),
'current_period_starts_at' => $subscription->current_period_starts_at?->toAtomString(),
'current_period_ends_at' => $subscription->current_period_ends_at?->toAtomString(),
'status_reason' => $subscription->status_reason,
];
}
}