*/ public function configsFor(FormField|FormFieldLibrary $owner): Collection { $type = $this->ownerTypeFor($owner); return FormFieldConfig::query() ->where('owner_type', $type) ->where('owner_id', $owner->getKey()) ->get(); } /** * @param list}> $specs */ public function replaceConfigs(FormField|FormFieldLibrary $owner, array $specs): void { $this->assertSpecsValid($specs); $ownerType = $this->ownerTypeFor($owner); DB::transaction(function () use ($owner, $ownerType, $specs): void { FormFieldConfig::query() ->withoutGlobalScopes() ->where('owner_type', $ownerType) ->where('owner_id', $owner->getKey()) ->delete(); foreach ($specs as $spec) { FormFieldConfig::query()->withoutGlobalScopes()->create([ 'owner_type' => $ownerType, 'owner_id' => $owner->getKey(), 'config_type' => $spec['config_type'], 'parameters' => $spec['parameters'] ?? [], ]); } if ($owner instanceof FormField) { $owner->logFieldChange('field.configs_replaced', [ 'count' => count($specs), ]); } }); } public function copyConfigs(FormFieldLibrary $from, FormField $to): void { $configs = $this->configsFor($from); if ($configs->isEmpty()) { return; } DB::transaction(function () use ($configs, $to): void { foreach ($configs as $config) { FormFieldConfig::query()->withoutGlobalScopes()->create([ 'owner_type' => 'form_field', 'owner_id' => $to->id, 'config_type' => $config->config_type instanceof FormFieldConfigType ? $config->config_type->value : (string) $config->config_type, 'parameters' => (array) $config->parameters, ]); } }); } /** * Serialise a config collection into the nested-object JSON shape * consumed by snapshot writer and API resources. Returns `null` on * empty (matches the contract pattern WS-5b introduced on the * validation-rules service). * * Shape per config_type: * - tag_categories → `{"categories": [string]}` * - storage_disk → `{"disk": string}` * * The external envelope is `{: }`: * `{"tag_categories": {"categories": ["Veiligheid"]}, * "storage_disk": {"disk": "local"}}` * * @param Collection $configs * @return array>|null */ public function toJsonShape(Collection $configs): ?array { if ($configs->isEmpty()) { return null; } // ULID id sort = insertion-order semantics, deterministic across DB // engines. Without it, MySQL returns rows in unspecified order and // schema_snapshot bytes drift across re-emits — breaks audit replay. $out = []; foreach ($configs->sortBy('id') as $config) { $type = $config->config_type instanceof FormFieldConfigType ? $config->config_type->value : (string) $config->config_type; $out[$type] = (array) $config->parameters; } return $out; } /** @param list> $specs */ public function assertSpecsValid(array $specs): void { foreach ($specs as $spec) { $this->assertSpecValid($spec); } } private function ownerTypeFor(FormField|FormFieldLibrary $owner): string { return $owner instanceof FormField ? 'form_field' : 'form_field_library'; } /** @param array $spec */ private function assertSpecValid(array $spec): void { $raw = (string) ($spec['config_type'] ?? ''); $enum = FormFieldConfigType::tryFrom($raw); if ($enum === null) { throw new UnknownValidationRuleTypeException( "Config config_type '{$raw}' is not a registered FormFieldConfigType case.", ); } $params = (array) ($spec['parameters'] ?? []); switch ($enum) { case FormFieldConfigType::TagCategories: if (! isset($params['categories']) || ! is_array($params['categories'])) { throw new UnknownValidationRuleTypeException( "Config 'tag_categories' requires parameters.categories (array of strings).", ); } foreach ($params['categories'] as $cat) { if (! is_string($cat) || $cat === '') { throw new UnknownValidationRuleTypeException( "Config 'tag_categories' parameters.categories must be non-empty strings.", ); } } return; case FormFieldConfigType::StorageDisk: if (! isset($params['disk']) || ! is_string($params['disk']) || $params['disk'] === '') { throw new UnknownValidationRuleTypeException( "Config 'storage_disk' requires non-empty string parameters.disk.", ); } return; } } }