Adds direct tenant + event columns to form_submissions so rapportage-hot
aggregate queries (dashboards, CSV-exports, counts over thousands of rows
per org or per event) skip the form_schemas join. This is the single
denormalization exception per addendum Q2; every other form-builder child
table continues to resolve tenancy via FK-chain through its parent
(implemented in Commit 3).
Schema:
- form_submissions.organisation_id ULID FK → organisations, cascade delete, NOT NULL
- form_submissions.event_id ULID FK → events, null on delete, nullable
- Indexes: (organisation_id, status), (event_id, status)
Observer: App\Observers\FormBuilder\FormSubmissionObserver::creating
resolves both columns when the caller has not set them.
- organisation_id <- form_schema.organisation_id (always present —
form_schemas carries OrganisationScope's column directly)
- event_id <- schema.owner_id when owner_type === 'event'; else the
active route's {event} parameter; else null (user_profile /
signature_contract purposes)
The observer docblock spells out both resolution paths and is covered
by the observer test below.
Model: FormSubmission gains organisation_id + event_id in $fillable, a
belongsTo organisation() and belongsTo event() relation.
Factory: FormSubmissionFactory gains forOrganisation($org) and
forEvent($event) states for tests that need to override the observer's
automatic resolution (e.g. cross-org leakage scenarios in Commit 3).
Normal factory usage does not need the states — the observer populates
both fields on save.
Docs:
- SCHEMA.md §3.5.12 form_submissions table — organisation_id and event_id
inserted between form_schema_id and subject_type; indexes added;
addendum Q2 rationale paragraph at the bottom explaining why this is
the only denormalized form-builder child.
- ARCH-FORM-BUILDER.md §4.3 — mirror changes + rationale inline on the
columns and in the indexes list.
Tests: tests/Feature/FormBuilder/FormSubmissionObserverTest.php — 7 tests
covering organisation resolution from schema, event resolution from
event-owned schema, null event_id for non-event-owned schemas without
route context, route-based event resolution, organisation_id populated
on every create path (factory / new() / Model::create), index presence,
and belongsTo relations. 13 new assertions. Full suite: 984 passed
(2675 assertions).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
228 lines
9.1 KiB
PHP
228 lines
9.1 KiB
PHP
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
namespace App\Providers;
|
|
|
|
use App\FormBuilder\Purposes\PurposeRegistry;
|
|
use App\Models\Company;
|
|
use App\Models\CrowdList;
|
|
use App\Models\CrowdType;
|
|
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;
|
|
use App\Models\MfaEmailCode;
|
|
use App\Models\Organisation;
|
|
use App\Models\OrganisationEmailSettings;
|
|
use App\Models\OrganisationEmailTemplate;
|
|
use App\Models\Person;
|
|
use App\Models\PersonIdentityMatch;
|
|
use App\Models\PersonSectionPreference;
|
|
use App\Models\PersonTag;
|
|
use App\Models\Shift;
|
|
use App\Models\ShiftAssignment;
|
|
use App\Models\ShiftWaitlist;
|
|
use App\Models\TimeSlot;
|
|
use App\Models\TrustedDevice;
|
|
use App\Models\User;
|
|
use App\Models\UserInvitation;
|
|
use App\Models\UserOrganisationTag;
|
|
use App\Models\UserProfile;
|
|
use App\Models\VolunteerAvailability;
|
|
use App\Events\FormBuilder\FormSubmissionSubmitted;
|
|
use App\Listeners\FormBuilder\SyncTagPickerSelectionsOnSubmit;
|
|
use App\Listeners\FormBuilder\TriggerPersonIdentityMatchOnFormSubmit;
|
|
use App\Observers\FormBuilder\FormSubmissionObserver;
|
|
use App\Observers\FormBuilder\FormValueObserver;
|
|
use App\Observers\PersonObserver;
|
|
use App\Observers\UserObserver;
|
|
use Illuminate\Auth\Notifications\ResetPassword;
|
|
use Illuminate\Database\Eloquent\Relations\Relation;
|
|
use Illuminate\Support\ServiceProvider;
|
|
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
|
|
{
|
|
$this->registerMorphMap();
|
|
|
|
Person::observe(PersonObserver::class);
|
|
User::observe(UserObserver::class);
|
|
FormValue::observe(FormValueObserver::class);
|
|
\App\Models\FormBuilder\FormSubmission::observe(FormSubmissionObserver::class);
|
|
|
|
// ARCH §31.10 — FORM-02 TAG_PICKER sync listener.
|
|
\Illuminate\Support\Facades\Event::listen(
|
|
FormSubmissionSubmitted::class,
|
|
SyncTagPickerSelectionsOnSubmit::class,
|
|
);
|
|
|
|
// ARCH §31.1 — identity-match trigger on event_registration.
|
|
\Illuminate\Support\Facades\Event::listen(
|
|
FormSubmissionSubmitted::class,
|
|
TriggerPersonIdentityMatchOnFormSubmit::class,
|
|
);
|
|
|
|
ResetPassword::createUrlUsing(function ($user, string $token) {
|
|
return config('crewli.portal_url') . '/wachtwoord-resetten?token=' . $token . '&email=' . urlencode($user->email);
|
|
});
|
|
|
|
// Tag activity log entries with impersonation context
|
|
Activity::saving(function (Activity $activity) {
|
|
$request = request();
|
|
|
|
$impersonator = $request->attributes->get('impersonator');
|
|
$session = $request->attributes->get('impersonation_session');
|
|
|
|
if ($impersonator && $session) {
|
|
$properties = $activity->properties?->toArray() ?? [];
|
|
$properties['impersonated_by'] = [
|
|
'user_id' => $impersonator->id,
|
|
'name' => $impersonator->full_name,
|
|
'email' => $impersonator->email,
|
|
];
|
|
$properties['impersonation_session_id'] = $session->id;
|
|
$activity->properties = collect($properties);
|
|
}
|
|
});
|
|
}
|
|
|
|
/**
|
|
* 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,
|
|
));
|
|
}
|
|
}
|