feat(form-builder): introduce purpose registry and definition value object
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) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,15 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\FormBuilder\Purposes\Exceptions;
|
||||
|
||||
use RuntimeException;
|
||||
|
||||
final class PurposeNotFoundException extends RuntimeException
|
||||
{
|
||||
public static function forSlug(string $slug): self
|
||||
{
|
||||
return new self("Purpose '{$slug}' is not registered in config('form_builder.purposes').");
|
||||
}
|
||||
}
|
||||
25
api/app/FormBuilder/Purposes/PurposeDefinition.php
Normal file
25
api/app/FormBuilder/Purposes/PurposeDefinition.php
Normal file
@@ -0,0 +1,25 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\FormBuilder\Purposes;
|
||||
|
||||
use App\Enums\FormBuilder\FormSubmissionMode;
|
||||
|
||||
final readonly class PurposeDefinition
|
||||
{
|
||||
/**
|
||||
* @param list<string> $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,
|
||||
) {}
|
||||
}
|
||||
85
api/app/FormBuilder/Purposes/PurposeRegistry.php
Normal file
85
api/app/FormBuilder/Purposes/PurposeRegistry.php
Normal file
@@ -0,0 +1,85 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\FormBuilder\Purposes;
|
||||
|
||||
use App\Enums\FormBuilder\FormSubmissionMode;
|
||||
use App\FormBuilder\Purposes\Exceptions\PurposeNotFoundException;
|
||||
use Illuminate\Contracts\Config\Repository as ConfigRepository;
|
||||
|
||||
final class PurposeRegistry
|
||||
{
|
||||
/** @var array<string, PurposeDefinition>|null */
|
||||
private ?array $cache = null;
|
||||
|
||||
public function __construct(private readonly ConfigRepository $config) {}
|
||||
|
||||
/** @return array<string, PurposeDefinition> keyed by slug */
|
||||
public function all(): array
|
||||
{
|
||||
if ($this->cache !== null) {
|
||||
return $this->cache;
|
||||
}
|
||||
|
||||
/** @var array<string, array<string, mixed>> $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<string> 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<string> 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,
|
||||
)));
|
||||
}
|
||||
}
|
||||
@@ -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<string, class-string|string>
|
||||
*/
|
||||
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,
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
85
api/config/form_builder/purposes.php
Normal file
85
api/config/form_builder/purposes.php
Normal file
@@ -0,0 +1,85 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Enums\FormBuilder\FormSubmissionMode;
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Form Builder — Purpose registry (v1.0)
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| Declarative set of purposes served by the universal form builder. v1.0
|
||||
| ships seven purposes. Adding a new purpose requires a code change (this
|
||||
| file) plus, typically, a new listener — per ARCH-CONSOLIDATION §3
|
||||
| besluit 4. Purposes are not user-defined.
|
||||
|
|
||||
| Each purpose declares:
|
||||
| - label (NL): organizer-facing label
|
||||
| - subject_type: morph alias used for form_submissions.subject_type
|
||||
| - default_submission_mode: FormSubmissionMode enum case
|
||||
| - allows_public_access: schema-level public submission without token
|
||||
| - required_bindings: list of "{entity}.{attribute}" binding paths
|
||||
| that must be present on a schema before publish
|
||||
|
|
||||
*/
|
||||
|
||||
return [
|
||||
|
||||
'event_registration' => [
|
||||
'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' => [],
|
||||
],
|
||||
|
||||
];
|
||||
Reference in New Issue
Block a user