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:
@@ -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,
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user