From e93207765be0d9ba0919b09fda9a60d6661a80a6 Mon Sep 17 00:00:00 2001 From: "bert.hausmans" Date: Fri, 24 Apr 2026 14:35:18 +0200 Subject: [PATCH] feat(form-builder): introduce purpose registry and definition value object MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Lands the v1.0 purpose registry (WS-2 of the consolidation sprint) as a first-class concept: a `PurposeDefinition` value object, a `PurposeRegistry` service keyed by slug, and a declarative `config/form_builder/purposes.php` registry with exactly the seven purposes from ARCH-CONSOLIDATION §6.4. Also rebuilds the morph-map in `AppServiceProvider::boot` into three labelled blocks: (1) domain subject types derived from `PurposeRegistry::allSubjectTypes()`, (2) non-purpose domain types hardcoded with comments (form_schemas owner_types, activity-log subjects), (3) framework types (spatie/activitylog; Sanctum stays absent per addendum Q4). Co-Authored-By: Claude Opus 4.7 (1M context) --- .../Exceptions/PurposeNotFoundException.php | 15 ++ .../Purposes/PurposeDefinition.php | 25 +++ .../FormBuilder/Purposes/PurposeRegistry.php | 85 ++++++++ api/app/Providers/AppServiceProvider.php | 200 ++++++++++++------ api/config/form_builder/purposes.php | 85 ++++++++ 5 files changed, 341 insertions(+), 69 deletions(-) create mode 100644 api/app/FormBuilder/Purposes/Exceptions/PurposeNotFoundException.php create mode 100644 api/app/FormBuilder/Purposes/PurposeDefinition.php create mode 100644 api/app/FormBuilder/Purposes/PurposeRegistry.php create mode 100644 api/config/form_builder/purposes.php diff --git a/api/app/FormBuilder/Purposes/Exceptions/PurposeNotFoundException.php b/api/app/FormBuilder/Purposes/Exceptions/PurposeNotFoundException.php new file mode 100644 index 00000000..95cacce3 --- /dev/null +++ b/api/app/FormBuilder/Purposes/Exceptions/PurposeNotFoundException.php @@ -0,0 +1,15 @@ + $requiredBindings Binding paths in `{entity}.{attribute}` form + * (Pattern A notation used in `form_fields.binding` JSON). + * Consumed by the pre-publish check (FormSchemaService::publish) + * and, after WS-5/WS-6, by `form_field_bindings`. + */ + public function __construct( + public string $slug, + public string $label, + public string $subjectType, + public FormSubmissionMode $defaultSubmissionMode, + public bool $allowsPublicAccess, + public array $requiredBindings, + ) {} +} diff --git a/api/app/FormBuilder/Purposes/PurposeRegistry.php b/api/app/FormBuilder/Purposes/PurposeRegistry.php new file mode 100644 index 00000000..e8d98baf --- /dev/null +++ b/api/app/FormBuilder/Purposes/PurposeRegistry.php @@ -0,0 +1,85 @@ +|null */ + private ?array $cache = null; + + public function __construct(private readonly ConfigRepository $config) {} + + /** @return array keyed by slug */ + public function all(): array + { + if ($this->cache !== null) { + return $this->cache; + } + + /** @var array> $raw */ + $raw = (array) $this->config->get('form_builder.purposes', []); + + $definitions = []; + foreach ($raw as $slug => $attrs) { + $mode = $attrs['default_submission_mode'] ?? null; + if (! $mode instanceof FormSubmissionMode) { + throw new \InvalidArgumentException( + "Purpose '{$slug}' has invalid default_submission_mode; expected FormSubmissionMode enum case." + ); + } + + $definitions[(string) $slug] = new PurposeDefinition( + slug: (string) $slug, + label: (string) ($attrs['label'] ?? ''), + subjectType: (string) ($attrs['subject_type'] ?? ''), + defaultSubmissionMode: $mode, + allowsPublicAccess: (bool) ($attrs['allows_public_access'] ?? false), + requiredBindings: array_values((array) ($attrs['required_bindings'] ?? [])), + ); + } + + return $this->cache = $definitions; + } + + public function get(string $slug): PurposeDefinition + { + $all = $this->all(); + if (! isset($all[$slug])) { + throw PurposeNotFoundException::forSlug($slug); + } + + return $all[$slug]; + } + + public function has(string $slug): bool + { + return isset($this->all()[$slug]); + } + + /** @return list unique sorted list of subject_type aliases */ + public function allSubjectTypes(): array + { + $types = array_values(array_unique(array_map( + static fn (PurposeDefinition $p): string => $p->subjectType, + $this->all(), + ))); + sort($types); + + return $types; + } + + /** @return list slugs with allows_public_access === true */ + public function publicAccessibleSlugs(): array + { + return array_values(array_keys(array_filter( + $this->all(), + static fn (PurposeDefinition $p): bool => $p->allowsPublicAccess, + ))); + } +} diff --git a/api/app/Providers/AppServiceProvider.php b/api/app/Providers/AppServiceProvider.php index 8e54cdf8..3189c6d0 100644 --- a/api/app/Providers/AppServiceProvider.php +++ b/api/app/Providers/AppServiceProvider.php @@ -4,6 +4,7 @@ declare(strict_types=1); namespace App\Providers; +use App\FormBuilder\Purposes\PurposeRegistry; use App\Models\Company; use App\Models\CrowdList; use App\Models\CrowdType; @@ -11,6 +12,18 @@ use App\Models\EmailChangeRequest; use App\Models\EmailLog; use App\Models\Event; use App\Models\FestivalSection; +use App\Models\FormBuilder\FormField; +use App\Models\FormBuilder\FormFieldLibrary; +use App\Models\FormBuilder\FormSchema; +use App\Models\FormBuilder\FormSchemaSection; +use App\Models\FormBuilder\FormSchemaWebhook; +use App\Models\FormBuilder\FormSubmission; +use App\Models\FormBuilder\FormSubmissionDelegation; +use App\Models\FormBuilder\FormSubmissionSectionStatus; +use App\Models\FormBuilder\FormTemplate; +use App\Models\FormBuilder\FormValue; +use App\Models\FormBuilder\FormValueOption; +use App\Models\FormBuilder\FormWebhookDelivery; use App\Models\ImpersonationSession; use App\Models\Location; use App\Models\MfaBackupCode; @@ -31,18 +44,6 @@ use App\Models\User; use App\Models\UserInvitation; use App\Models\UserOrganisationTag; use App\Models\UserProfile; -use App\Models\FormBuilder\FormField; -use App\Models\FormBuilder\FormFieldLibrary; -use App\Models\FormBuilder\FormSchema; -use App\Models\FormBuilder\FormSchemaSection; -use App\Models\FormBuilder\FormSchemaWebhook; -use App\Models\FormBuilder\FormSubmission; -use App\Models\FormBuilder\FormSubmissionDelegation; -use App\Models\FormBuilder\FormSubmissionSectionStatus; -use App\Models\FormBuilder\FormTemplate; -use App\Models\FormBuilder\FormValue; -use App\Models\FormBuilder\FormValueOption; -use App\Models\FormBuilder\FormWebhookDelivery; use App\Models\VolunteerAvailability; use App\Events\FormBuilder\FormSubmissionSubmitted; use App\Listeners\FormBuilder\SyncTagPickerSelectionsOnSubmit; @@ -57,69 +58,35 @@ use Spatie\Activitylog\Models\Activity; class AppServiceProvider extends ServiceProvider { + /** + * FQCN lookup for every `subject_type` alias that PurposeRegistry may + * return. Single source of truth for domain-subject → model mapping, + * consumed by the morph-map build in boot(). The `artist` class is + * declared as a string because the Artist model is not yet landed; + * it is safe to register the morph alias (lazily resolved). + * + * @var array + */ + private const PURPOSE_SUBJECT_FQCN = [ + 'person' => Person::class, + 'user' => User::class, + 'company' => Company::class, + 'artist' => 'App\\Models\\Artist', + ]; + public function register(): void { - // + $this->mergeConfigFrom( + base_path('config/form_builder/purposes.php'), + 'form_builder.purposes', + ); + + $this->app->singleton(PurposeRegistry::class); } public function boot(): void { - // Morph map: explicit keys for every class that can end up in a - // polymorphic column. Before S1 there were no morphTo/morphMany - // relations, but spatie/activitylog stores subject/causer as morph - // columns — so every model passed to performedOn()/causedBy() MUST - // be registered. Keep form-builder subject_types in sync with - // config/form_subjects.php. - Relation::enforceMorphMap([ - // Form-builder subject types - 'event' => Event::class, - 'user' => User::class, - 'user_profile' => UserProfile::class, - 'person' => Person::class, - 'company' => Company::class, - 'organisation' => Organisation::class, - // 'artist' added when artist module lands - - // Additional models used as activity-log subjects/causers - 'crowd_list' => CrowdList::class, - 'crowd_type' => CrowdType::class, - 'email_change_request' => EmailChangeRequest::class, - 'email_log' => EmailLog::class, - 'festival_section' => FestivalSection::class, - 'impersonation_session' => ImpersonationSession::class, - 'location' => Location::class, - 'mfa_backup_code' => MfaBackupCode::class, - 'mfa_email_code' => MfaEmailCode::class, - 'organisation_email_settings' => OrganisationEmailSettings::class, - 'organisation_email_template' => OrganisationEmailTemplate::class, - 'person_identity_match' => PersonIdentityMatch::class, - 'person_section_preference' => PersonSectionPreference::class, - 'person_tag' => PersonTag::class, - 'shift' => Shift::class, - 'shift_assignment' => ShiftAssignment::class, - 'shift_waitlist' => ShiftWaitlist::class, - 'time_slot' => TimeSlot::class, - 'trusted_device' => TrustedDevice::class, - 'user_invitation' => UserInvitation::class, - 'user_organisation_tag' => UserOrganisationTag::class, - 'volunteer_availability' => VolunteerAvailability::class, - - // Form-builder models — used as activity-log subjects via the - // logSchemaChange / logFieldChange helpers, and (in S2+) as - // polymorphic webhook payload subjects. - 'form_schema' => FormSchema::class, - 'form_schema_section' => FormSchemaSection::class, - 'form_field' => FormField::class, - 'form_field_library' => FormFieldLibrary::class, - 'form_submission' => FormSubmission::class, - 'form_submission_section_status' => FormSubmissionSectionStatus::class, - 'form_submission_delegation' => FormSubmissionDelegation::class, - 'form_value' => FormValue::class, - 'form_value_option' => FormValueOption::class, - 'form_template' => FormTemplate::class, - 'form_schema_webhook' => FormSchemaWebhook::class, - 'form_webhook_delivery' => FormWebhookDelivery::class, - ]); + $this->registerMorphMap(); Person::observe(PersonObserver::class); User::observe(UserObserver::class); @@ -160,4 +127,99 @@ class AppServiceProvider extends ServiceProvider } }); } + + /** + * Morph-map is built from three labelled blocks: + * + * 1. Domain subject types — derived from PurposeRegistry. These are + * the aliases allowed on `form_submissions.subject_type`. + * 2. Non-purpose domain types — form_schemas owner_types that are + * not subjects (`organisation`, `event`, `user_profile`) plus + * domain models referenced as activity-log subjects/causers. + * 3. Framework types — spatie/activitylog subjects. + * + * Sanctum's `personal_access_tokens.tokenable_type` is intentionally + * absent (addendum Q4: framework polymorphie uses framework defaults, + * not the domain morph-map). `MorphMapAlignmentTest` guards blocks + * 1 and 2 against drift. + */ + private function registerMorphMap(): void + { + /** @var PurposeRegistry $registry */ + $registry = $this->app->make(PurposeRegistry::class); + + // Block 1 — domain subject types, derived from PurposeRegistry. + $domainSubjectTypes = []; + foreach ($registry->allSubjectTypes() as $alias) { + $fqcn = self::PURPOSE_SUBJECT_FQCN[$alias] ?? null; + if ($fqcn === null) { + throw new \RuntimeException( + "No FQCN mapping for purpose subject_type '{$alias}'. " + .'Add it to AppServiceProvider::PURPOSE_SUBJECT_FQCN.' + ); + } + $domainSubjectTypes[$alias] = $fqcn; + } + + // Block 2 — non-purpose domain types. + $nonPurposeDomainTypes = [ + // form_schemas owner_type candidates that are not subject_types. + 'organisation' => Organisation::class, + 'event' => Event::class, + 'user_profile' => UserProfile::class, + + // Domain models referenced as activity-log subjects/causers. + 'crowd_list' => CrowdList::class, + 'crowd_type' => CrowdType::class, + 'email_change_request' => EmailChangeRequest::class, + 'email_log' => EmailLog::class, + 'festival_section' => FestivalSection::class, + 'impersonation_session' => ImpersonationSession::class, + 'location' => Location::class, + 'mfa_backup_code' => MfaBackupCode::class, + 'mfa_email_code' => MfaEmailCode::class, + 'organisation_email_settings' => OrganisationEmailSettings::class, + 'organisation_email_template' => OrganisationEmailTemplate::class, + 'person_identity_match' => PersonIdentityMatch::class, + 'person_section_preference' => PersonSectionPreference::class, + 'person_tag' => PersonTag::class, + 'shift' => Shift::class, + 'shift_assignment' => ShiftAssignment::class, + 'shift_waitlist' => ShiftWaitlist::class, + 'time_slot' => TimeSlot::class, + 'trusted_device' => TrustedDevice::class, + 'user_invitation' => UserInvitation::class, + 'user_organisation_tag' => UserOrganisationTag::class, + 'volunteer_availability' => VolunteerAvailability::class, + + // Form-builder models used as activity-log subjects and (S2+) + // polymorphic webhook payload subjects. + 'form_schema' => FormSchema::class, + 'form_schema_section' => FormSchemaSection::class, + 'form_field' => FormField::class, + 'form_field_library' => FormFieldLibrary::class, + 'form_submission' => FormSubmission::class, + 'form_submission_section_status' => FormSubmissionSectionStatus::class, + 'form_submission_delegation' => FormSubmissionDelegation::class, + 'form_value' => FormValue::class, + 'form_value_option' => FormValueOption::class, + 'form_template' => FormTemplate::class, + 'form_schema_webhook' => FormSchemaWebhook::class, + 'form_webhook_delivery' => FormWebhookDelivery::class, + ]; + + // Block 3 — framework types (spatie/activitylog). Sanctum is absent + // on purpose; see class-level docblock. + $frameworkTypes = [ + // activitylog causer/subject fall-back entries are not needed + // here because every framework morph value lands on a registered + // domain alias above. Reserved for future framework extensions. + ]; + + Relation::enforceMorphMap(array_merge( + $domainSubjectTypes, + $nonPurposeDomainTypes, + $frameworkTypes, + )); + } } diff --git a/api/config/form_builder/purposes.php b/api/config/form_builder/purposes.php new file mode 100644 index 00000000..7a6f023b --- /dev/null +++ b/api/config/form_builder/purposes.php @@ -0,0 +1,85 @@ + [ + 'label' => 'Aanmelding vrijwilligers/crew', + 'subject_type' => 'person', + 'default_submission_mode' => FormSubmissionMode::SINGLE, + 'allows_public_access' => true, + 'required_bindings' => ['person.email', 'person.first_name', 'person.last_name'], + ], + + 'artist_advance' => [ + 'label' => 'Artiest advance', + 'subject_type' => 'artist', + 'default_submission_mode' => FormSubmissionMode::DRAFT_SINGLE, + 'allows_public_access' => false, + 'required_bindings' => [], + ], + + 'supplier_intake' => [ + 'label' => 'Leverancier intake', + 'subject_type' => 'company', + 'default_submission_mode' => FormSubmissionMode::SINGLE, + 'allows_public_access' => false, + 'required_bindings' => ['company.name'], + ], + + 'post_event_evaluation' => [ + 'label' => 'Evaluatie na afloop', + 'subject_type' => 'person', + 'default_submission_mode' => FormSubmissionMode::SINGLE, + 'allows_public_access' => false, + 'required_bindings' => [], + ], + + 'incident_report' => [ + 'label' => 'Incident-melding', + 'subject_type' => 'person', + 'default_submission_mode' => FormSubmissionMode::MULTIPLE, + 'allows_public_access' => false, + 'required_bindings' => [], + ], + + 'signature_contract' => [ + 'label' => 'Contract-ondertekening', + 'subject_type' => 'user', + 'default_submission_mode' => FormSubmissionMode::SINGLE, + 'allows_public_access' => false, + 'required_bindings' => [], + ], + + 'user_profile' => [ + 'label' => 'Profiel-update', + 'subject_type' => 'user', + 'default_submission_mode' => FormSubmissionMode::SINGLE, + 'allows_public_access' => false, + 'required_bindings' => [], + ], + +];