resolvePurpose($data['purpose'] ?? null); $data['organisation_id'] = $organisation->id; $data['slug'] = $this->ensureUniqueSlug($organisation, $data['slug'] ?? Str::slug($data['name'] ?? 'formulier')); $data['purpose'] = $purpose->value; $data['submission_mode'] ??= $purpose->defaultSubmissionMode()->value; $data['version'] = 1; $data['created_by_user_id'] = $actor->id; $data['last_updated_by_user_id'] = $actor->id; /** @var FormSchema $schema */ $schema = FormSchema::create($data); $schema->logSchemaChange('schema.created', ['purpose' => $purpose->value]); return $schema->refresh(); }); } public function update(FormSchema $schema, array $data, User $actor): FormSchema { return DB::transaction(function () use ($schema, $data, $actor): FormSchema { if (isset($data['slug']) && $data['slug'] !== $schema->slug) { $data['slug'] = $this->ensureUniqueSlug($schema->organisation, $data['slug'], $schema->id); } $before = $schema->toArray(); $schema->fill($data); if ($this->isStructuralChange($schema)) { $schema->version = (int) $schema->version + 1; } $schema->last_updated_by_user_id = $actor->id; $schema->save(); $schema->logSchemaChange('schema.updated', [ 'old_version' => $before['version'] ?? null, 'new_version' => $schema->version, ]); return $schema->refresh(); }); } public function duplicate(FormSchema $source, User $actor, ?string $nameOverride = null): FormSchema { return DB::transaction(function () use ($source, $actor, $nameOverride): FormSchema { $name = $nameOverride ?? ($source->name.' (kopie)'); $copy = $source->replicate([ 'public_token', 'public_token_previous', 'public_token_rotated_at', 'edit_lock_user_id', 'edit_lock_expires_at', ]); $copy->name = $name; $copy->slug = $this->ensureUniqueSlug($source->organisation, Str::slug($name)); $copy->version = 1; $copy->is_published = false; $copy->created_by_user_id = $actor->id; $copy->last_updated_by_user_id = $actor->id; $copy->save(); // Copy sections (id mapping) then fields (pointing at new section ids). $sectionIdMap = []; foreach ($source->sections as $section) { $newSection = $section->replicate(); $newSection->form_schema_id = $copy->id; $newSection->save(); $sectionIdMap[$section->id] = $newSection->id; } foreach ($source->fields as $field) { $newField = $field->replicate(); $newField->form_schema_id = $copy->id; if ($field->form_schema_section_id && isset($sectionIdMap[$field->form_schema_section_id])) { $newField->form_schema_section_id = $sectionIdMap[$field->form_schema_section_id]; } $newField->save(); } $copy->logSchemaChange('schema.duplicated', ['source_schema_id' => $source->id]); return $copy->refresh(); }); } public function publish(FormSchema $schema, User $actor): FormSchema { $this->assertRequiredBindingsPresent($schema); $this->assertPublishGuardsSatisfied($schema); $schema->is_published = true; $schema->last_updated_by_user_id = $actor->id; $schema->save(); $schema->logSchemaChange('schema.published'); return $schema->refresh(); } /** * RFC-WS-6 §3 (Q13) — runs after assertRequiredBindingsPresent(). * Collects every guard violation (not first-fail) so the builder UI * can surface all problems in one 422 response. */ private function assertPublishGuardsSatisfied(FormSchema $schema): void { $purposeValue = $schema->purpose->value; if (! $this->purposeRegistry->has($purposeValue)) { return; } // Eager-load relations needed by guards (avoid N+1). $schema->loadMissing(['fields.bindings', 'fields.configs', 'sections']); $provider = $this->purposeRegistry->guardProviderFor($purposeValue); $violations = []; foreach ($provider->publishGuards() as $guard) { $result = $guard->evaluate($schema); if (! $result->passed) { $violations[] = $result; } } if ($violations === []) { return; } usort( $violations, static fn (PublishGuardResult $a, PublishGuardResult $b): int => strcmp($a->guardCode, $b->guardCode), ); throw new PublishGuardViolationException($purposeValue, $violations); } /** * Verify that every `required_bindings` path declared by the schema's * purpose is bound by at least one field on the schema. * * Binding paths follow Pattern A notation (`{entity}.{attribute}`). * Sourced from the relational `form_field_bindings` table (WS-5a; * ARCH §6.7, §17.3). */ private function assertRequiredBindingsPresent(FormSchema $schema): void { $purposeValue = $schema->purpose instanceof FormPurpose ? $schema->purpose->value : (string) $schema->purpose; if (! $this->purposeRegistry->has($purposeValue)) { return; } $required = $this->purposeRegistry->get($purposeValue)->requiredBindings; if ($required === []) { return; } $fieldIds = $schema->fields()->pluck('id'); $bound = FormFieldBinding::query() ->withoutGlobalScopes() ->where('owner_type', 'form_field') ->whereIn('owner_id', $fieldIds) ->get(['target_entity', 'target_attribute']) ->map(fn (FormFieldBinding $b) => $b->target_entity.'.'.$b->target_attribute) ->unique() ->all(); $missing = array_values(array_diff($required, $bound)); if ($missing !== []) { throw new PurposeRequirementsNotMetException($purposeValue, $missing); } } public function unpublish(FormSchema $schema, User $actor): FormSchema { $schema->is_published = false; $schema->last_updated_by_user_id = $actor->id; $schema->save(); $schema->logSchemaChange('schema.unpublished'); return $schema->refresh(); } public function rotatePublicToken(FormSchema $schema, User $actor, int $graceDays = 7): FormSchema { return DB::transaction(function () use ($schema, $actor, $graceDays): FormSchema { $previous = $schema->public_token; $schema->public_token = (string) Str::ulid(); $schema->public_token_previous = $previous; $schema->public_token_rotated_at = now(); $schema->last_updated_by_user_id = $actor->id; $schema->save(); $schema->logSchemaChange('schema.public_token_rotated', [ 'grace_days' => $graceDays, 'previous_token_present' => $previous !== null, ]); return $schema->refresh(); }); } public function acquireEditLock(FormSchema $schema, User $user, int $ttlMinutes = 10): FormSchema { return DB::transaction(function () use ($schema, $user, $ttlMinutes): FormSchema { $now = now(); $holderId = $schema->edit_lock_user_id; $expiresAt = $schema->edit_lock_expires_at; if ($holderId !== null && $holderId !== $user->id && $expiresAt !== null && $expiresAt->greaterThan($now)) { throw new EditLockConflictException( User::query()->find($holderId), $expiresAt, ); } $schema->edit_lock_user_id = $user->id; $schema->edit_lock_expires_at = $now->copy()->addMinutes($ttlMinutes); $schema->save(); return $schema->refresh(); }); } public function releaseEditLock(FormSchema $schema, User $user): FormSchema { if ($schema->edit_lock_user_id !== null && $schema->edit_lock_user_id !== $user->id && ! $user->hasRole('org_admin')) { throw new EditLockConflictException( User::query()->find($schema->edit_lock_user_id), $schema->edit_lock_expires_at, ); } $schema->edit_lock_user_id = null; $schema->edit_lock_expires_at = null; $schema->save(); return $schema->refresh(); } public function delete(FormSchema $schema, User $actor, ?string $confirmedName = null): void { $hasSubmissions = FormSubmission::query() ->where('form_schema_id', $schema->id) ->exists(); if ($hasSubmissions) { if ($confirmedName !== $schema->name) { throw DestructiveConfirmationRequiredException::forName($schema->name); } } DB::transaction(function () use ($schema, $actor): void { $schema->last_updated_by_user_id = $actor->id; $schema->save(); $schema->logSchemaChange('schema.deleted'); $schema->delete(); }); } public function bumpVersion(FormSchema $schema): void { $schema->version = (int) $schema->version + 1; $schema->save(); } public function hasSubmittedSubmissions(FormSchema $schema): bool { return FormSubmission::query() ->where('form_schema_id', $schema->id) ->where('status', FormSubmissionStatus::SUBMITTED->value) ->exists(); } private function resolvePurpose(mixed $purpose): FormPurpose { if ($purpose instanceof FormPurpose) { return $purpose; } return FormPurpose::from((string) $purpose); } private function ensureUniqueSlug(?Organisation $organisation, string $slug, ?string $excludeId = null): string { $orgId = $organisation?->id; if ($orgId === null) { return $slug; } $base = Str::slug($slug); $candidate = $base; $i = 2; while (FormSchema::withoutGlobalScopes() ->where('organisation_id', $orgId) ->where('slug', $candidate) ->when($excludeId !== null, fn ($q) => $q->where('id', '!=', $excludeId)) ->exists() ) { $candidate = $base.'-'.$i; $i++; } return $candidate; } private function isStructuralChange(FormSchema $schema): bool { return $schema->isDirty(['purpose', 'submission_mode', 'locale', 'freeze_on_submit', 'snapshot_mode', 'section_level_submit', 'consent_version']); } }