From 85815ccb16b89d75d4724432715bbeb9e53569bf Mon Sep 17 00:00:00 2001 From: "bert.hausmans" Date: Fri, 17 Apr 2026 12:35:41 +0200 Subject: [PATCH] feat(forms): add Eloquent models, observer, events, activity-log helpers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase 4 of S1. Models (app/Models/FormBuilder/): FormSchema, FormSchemaSection, FormField, FormSubmission, FormValue, FormValueOption, FormTemplate, FormFieldLibrary, FormSchemaWebhook, FormWebhookDelivery, FormSubmissionSectionStatus, FormSubmissionDelegation. Plus UserProfile at app/Models/ (user-universal). OrganisationScope applied on: FormSchema, FormTemplate, FormFieldLibrary. FormSchemaWebhook documents inherited-scope discipline (OrganisationScope's strategies — organisation_id/event_id/festival_section_id — don't cover form_schema_id; direct queries would leak across orgs, so must go via $schema->webhooks()). User::profile()/getOrCreateProfile(), Event::formSchemas() (morphMany), Person::formSubmissions() (morphMany). Morph map enforced in AppServiceProvider with 28 keys covering every model that appears as activitylog subject/causer. Also updated OrganisationDashboardService (and its test) to query activitylog via getMorphClass() instead of FQCN. Activity log strategy: nuanced explicit calls (logSchemaChange on FormSchema, logFieldChange on FormField) — no LogsActivity trait. Suppression for bulk fixtures via App\Support\ActivityLog::suppressed(fn() => ...) which flips config('activitylog.enabled') around a callback. Both our explicit calls and spatie's trait on Organisation respect the flag via ActivityLogger::log(). FormValueObserver (app/Observers/FormBuilder/) populates value_indexed/ value_number/value_date/value_bool on save per field.value_storage_hint, rebuilds form_value_options pivot on multi-value filterable fields, cleans up on delete. Memoised field cache avoids N+1. Registered in AppServiceProvider. 9 lightweight event classes (app/Events/FormBuilder/) as SerializesModels containers — submission lifecycle signatures lock in for S2 services, no listeners yet. Factories for all models with Dutch fake data (fake('nl_NL')). FormSchema factory uses defaultSubmissionMode(); FormField factory uses recommendedValueStorageHint(). Tests: 9 new observer tests (all pass); full suite 910/910 (up from 901). Co-Authored-By: Claude Opus 4.7 (1M context) --- .../FormBuilder/FormSubmissionAnonymised.php | 19 ++ .../FormBuilder/FormSubmissionArchived.php | 19 ++ .../FormBuilder/FormSubmissionCreated.php | 19 ++ .../FormBuilder/FormSubmissionDeleted.php | 19 ++ .../FormSubmissionDraftUpdated.php | 19 ++ .../FormBuilder/FormSubmissionReviewed.php | 21 ++ .../FormSubmissionSectionReviewed.php | 23 ++ .../FormSubmissionSectionSubmitted.php | 21 ++ .../FormBuilder/FormSubmissionSubmitted.php | 19 ++ api/app/Models/Event.php | 6 + api/app/Models/FormBuilder/FormField.php | 120 +++++++++++ .../Models/FormBuilder/FormFieldLibrary.php | 68 ++++++ api/app/Models/FormBuilder/FormSchema.php | 150 +++++++++++++ .../Models/FormBuilder/FormSchemaSection.php | 60 ++++++ .../Models/FormBuilder/FormSchemaWebhook.php | 54 +++++ api/app/Models/FormBuilder/FormSubmission.php | 102 +++++++++ .../FormBuilder/FormSubmissionDelegation.php | 47 ++++ .../FormSubmissionSectionStatus.php | 46 ++++ api/app/Models/FormBuilder/FormTemplate.php | 49 +++++ api/app/Models/FormBuilder/FormValue.php | 54 +++++ .../Models/FormBuilder/FormValueOption.php | 43 ++++ .../FormBuilder/FormWebhookDelivery.php | 60 ++++++ api/app/Models/Person.php | 6 + api/app/Models/User.php | 11 + api/app/Models/UserProfile.php | 61 ++++++ .../FormBuilder/FormValueObserver.php | 192 +++++++++++++++++ api/app/Providers/AppServiceProvider.php | 78 +++++++ .../Services/OrganisationDashboardService.php | 2 +- api/app/Support/ActivityLog.php | 38 ++++ .../FormBuilder/FormFieldFactory.php | 76 +++++++ .../FormBuilder/FormFieldLibraryFactory.php | 48 +++++ .../FormBuilder/FormSchemaFactory.php | 72 +++++++ .../FormBuilder/FormSchemaSectionFactory.php | 35 +++ .../FormBuilder/FormSchemaWebhookFactory.php | 29 +++ .../FormSubmissionDelegationFactory.php | 28 +++ .../FormBuilder/FormSubmissionFactory.php | 43 ++++ .../FormSubmissionSectionStatusFactory.php | 26 +++ .../FormBuilder/FormTemplateFactory.php | 52 +++++ .../FormBuilder/FormValueFactory.php | 27 +++ .../FormBuilder/FormValueOptionFactory.php | 28 +++ .../FormWebhookDeliveryFactory.php | 30 +++ api/database/factories/UserProfileFactory.php | 33 +++ .../Feature/Organisation/OrganisationTest.php | 2 +- .../FormBuilder/FormValueObserverTest.php | 204 ++++++++++++++++++ 44 files changed, 2157 insertions(+), 2 deletions(-) create mode 100644 api/app/Events/FormBuilder/FormSubmissionAnonymised.php create mode 100644 api/app/Events/FormBuilder/FormSubmissionArchived.php create mode 100644 api/app/Events/FormBuilder/FormSubmissionCreated.php create mode 100644 api/app/Events/FormBuilder/FormSubmissionDeleted.php create mode 100644 api/app/Events/FormBuilder/FormSubmissionDraftUpdated.php create mode 100644 api/app/Events/FormBuilder/FormSubmissionReviewed.php create mode 100644 api/app/Events/FormBuilder/FormSubmissionSectionReviewed.php create mode 100644 api/app/Events/FormBuilder/FormSubmissionSectionSubmitted.php create mode 100644 api/app/Events/FormBuilder/FormSubmissionSubmitted.php create mode 100644 api/app/Models/FormBuilder/FormField.php create mode 100644 api/app/Models/FormBuilder/FormFieldLibrary.php create mode 100644 api/app/Models/FormBuilder/FormSchema.php create mode 100644 api/app/Models/FormBuilder/FormSchemaSection.php create mode 100644 api/app/Models/FormBuilder/FormSchemaWebhook.php create mode 100644 api/app/Models/FormBuilder/FormSubmission.php create mode 100644 api/app/Models/FormBuilder/FormSubmissionDelegation.php create mode 100644 api/app/Models/FormBuilder/FormSubmissionSectionStatus.php create mode 100644 api/app/Models/FormBuilder/FormTemplate.php create mode 100644 api/app/Models/FormBuilder/FormValue.php create mode 100644 api/app/Models/FormBuilder/FormValueOption.php create mode 100644 api/app/Models/FormBuilder/FormWebhookDelivery.php create mode 100644 api/app/Models/UserProfile.php create mode 100644 api/app/Observers/FormBuilder/FormValueObserver.php create mode 100644 api/app/Support/ActivityLog.php create mode 100644 api/database/factories/FormBuilder/FormFieldFactory.php create mode 100644 api/database/factories/FormBuilder/FormFieldLibraryFactory.php create mode 100644 api/database/factories/FormBuilder/FormSchemaFactory.php create mode 100644 api/database/factories/FormBuilder/FormSchemaSectionFactory.php create mode 100644 api/database/factories/FormBuilder/FormSchemaWebhookFactory.php create mode 100644 api/database/factories/FormBuilder/FormSubmissionDelegationFactory.php create mode 100644 api/database/factories/FormBuilder/FormSubmissionFactory.php create mode 100644 api/database/factories/FormBuilder/FormSubmissionSectionStatusFactory.php create mode 100644 api/database/factories/FormBuilder/FormTemplateFactory.php create mode 100644 api/database/factories/FormBuilder/FormValueFactory.php create mode 100644 api/database/factories/FormBuilder/FormValueOptionFactory.php create mode 100644 api/database/factories/FormBuilder/FormWebhookDeliveryFactory.php create mode 100644 api/database/factories/UserProfileFactory.php create mode 100644 api/tests/Unit/Observers/FormBuilder/FormValueObserverTest.php diff --git a/api/app/Events/FormBuilder/FormSubmissionAnonymised.php b/api/app/Events/FormBuilder/FormSubmissionAnonymised.php new file mode 100644 index 00000000..d8a77eec --- /dev/null +++ b/api/app/Events/FormBuilder/FormSubmissionAnonymised.php @@ -0,0 +1,19 @@ +hasMany(CrowdList::class); } + public function formSchemas(): MorphMany + { + return $this->morphMany(\App\Models\FormBuilder\FormSchema::class, 'owner'); + } + public function parent(): BelongsTo { return $this->belongsTo(Event::class, 'parent_event_id'); diff --git a/api/app/Models/FormBuilder/FormField.php b/api/app/Models/FormBuilder/FormField.php new file mode 100644 index 00000000..93202cfd --- /dev/null +++ b/api/app/Models/FormBuilder/FormField.php @@ -0,0 +1,120 @@ + */ + protected $casts = [ + 'options' => 'array', + 'validation_rules' => 'array', + 'binding' => 'array', + 'conditional_logic' => 'array', + 'role_restrictions' => 'array', + 'translations' => 'array', + 'is_required' => 'bool', + 'is_filterable' => 'bool', + 'is_portal_visible' => 'bool', + 'is_admin_only' => 'bool', + 'is_unique' => 'bool', + 'is_pii' => 'bool', + 'review_required' => 'bool', + 'display_width' => FormFieldDisplayWidth::class, + 'value_storage_hint' => FormValueStorageHint::class, + 'sort_order' => 'int', + ]; + + public function schema(): BelongsTo + { + return $this->belongsTo(FormSchema::class, 'form_schema_id'); + } + + public function section(): BelongsTo + { + return $this->belongsTo(FormSchemaSection::class, 'form_schema_section_id'); + } + + public function libraryField(): BelongsTo + { + return $this->belongsTo(FormFieldLibrary::class, 'library_field_id'); + } + + public function values(): HasMany + { + return $this->hasMany(FormValue::class); + } + + /** + * Nuanced activity log (ARCH §17.1; S1 Phase 4b). Callers choose which + * events are worth logging — e.g. created/deleted/restored, field_type + * changed (value storage changes), binding changed, is_pii toggled, + * is_filterable toggled (triggers backfill), structural options changes. + * NOT logged (noise): label/help_text/sort_order/conditional_logic/ + * translations. + * + * Bulk-fixture suppression: the activitylog.enabled config key is the + * kill-switch. Seeders and one-shot commands wrap themselves in + * App\Support\ActivityLog::suppressed(...). activity()->log() becomes + * a silent no-op while disabled, so no guard is needed here. + * + * @param array $properties + */ + public function logFieldChange(string $event, array $properties = []): void + { + activity() + ->performedOn($this) + ->withProperties($properties) + ->log($event); + } +} diff --git a/api/app/Models/FormBuilder/FormFieldLibrary.php b/api/app/Models/FormBuilder/FormFieldLibrary.php new file mode 100644 index 00000000..cf67bb72 --- /dev/null +++ b/api/app/Models/FormBuilder/FormFieldLibrary.php @@ -0,0 +1,68 @@ + */ + protected $casts = [ + 'options' => 'array', + 'validation_rules' => 'array', + 'default_binding' => 'array', + 'translations' => 'array', + 'default_is_required' => 'bool', + 'default_is_filterable' => 'bool', + 'is_system' => 'bool', + 'is_active' => 'bool', + 'usage_count' => 'int', + ]; + + public function organisation(): BelongsTo + { + return $this->belongsTo(Organisation::class); + } + + public function fields(): HasMany + { + return $this->hasMany(FormField::class, 'library_field_id'); + } +} diff --git a/api/app/Models/FormBuilder/FormSchema.php b/api/app/Models/FormBuilder/FormSchema.php new file mode 100644 index 00000000..5e162158 --- /dev/null +++ b/api/app/Models/FormBuilder/FormSchema.php @@ -0,0 +1,150 @@ + */ + protected $casts = [ + 'purpose' => FormPurpose::class, + 'submission_mode' => FormSubmissionMode::class, + 'snapshot_mode' => FormSchemaSnapshotMode::class, + 'is_published' => 'bool', + 'freeze_on_submit' => 'bool', + 'section_level_submit' => 'bool', + 'auto_save_enabled' => 'bool', + 'settings' => 'array', + 'submission_deadline' => 'datetime', + 'public_token_rotated_at' => 'datetime', + 'edit_lock_expires_at' => 'datetime', + 'version' => 'int', + 'retention_days' => 'int', + 'max_submissions' => 'int', + ]; + + public function organisation(): BelongsTo + { + return $this->belongsTo(Organisation::class); + } + + public function owner(): MorphTo + { + return $this->morphTo(); + } + + public function fields(): HasMany + { + return $this->hasMany(FormField::class); + } + + public function sections(): HasMany + { + return $this->hasMany(FormSchemaSection::class); + } + + public function submissions(): HasMany + { + return $this->hasMany(FormSubmission::class); + } + + public function webhooks(): HasMany + { + return $this->hasMany(FormSchemaWebhook::class); + } + + public function createdBy(): BelongsTo + { + return $this->belongsTo(User::class, 'created_by_user_id'); + } + + public function lastUpdatedBy(): BelongsTo + { + return $this->belongsTo(User::class, 'last_updated_by_user_id'); + } + + public function editLockUser(): BelongsTo + { + return $this->belongsTo(User::class, 'edit_lock_user_id'); + } + + /** + * Nuanced activity log (ARCH §17.1; S1 Phase 4b). Callers choose which + * events are worth logging — e.g. created/deleted/restored, published + * toggled, purpose changed, freeze_on_submit toggled, retention_days + * changed, consent_version changed, public_token rotated, snapshot_mode + * changed. NOT logged (noise): name/description/slug, settings, locale. + * + * Bulk-fixture suppression: the activitylog.enabled config key is the + * kill-switch. Seeders and one-shot commands wrap themselves in + * App\Support\ActivityLog::suppressed(...). activity()->log() becomes + * a silent no-op while disabled, so no guard is needed here. + * + * @param array $properties + */ + public function logSchemaChange(string $event, array $properties = []): void + { + activity() + ->performedOn($this) + ->withProperties($properties) + ->log($event); + } +} diff --git a/api/app/Models/FormBuilder/FormSchemaSection.php b/api/app/Models/FormBuilder/FormSchemaSection.php new file mode 100644 index 00000000..52eeb760 --- /dev/null +++ b/api/app/Models/FormBuilder/FormSchemaSection.php @@ -0,0 +1,60 @@ + */ + protected $casts = [ + 'submit_independent' => 'bool', + 'required_for_schema_submit' => 'bool', + 'sort_order' => 'int', + ]; + + public function schema(): BelongsTo + { + return $this->belongsTo(FormSchema::class, 'form_schema_id'); + } + + public function dependsOnSection(): BelongsTo + { + return $this->belongsTo(self::class, 'depends_on_section_id'); + } + + public function fields(): HasMany + { + return $this->hasMany(FormField::class); + } + + public function submissionStatuses(): HasMany + { + return $this->hasMany(FormSubmissionSectionStatus::class); + } +} diff --git a/api/app/Models/FormBuilder/FormSchemaWebhook.php b/api/app/Models/FormBuilder/FormSchemaWebhook.php new file mode 100644 index 00000000..4b566f11 --- /dev/null +++ b/api/app/Models/FormBuilder/FormSchemaWebhook.php @@ -0,0 +1,54 @@ +webhooks() or join on form_schema_id. Direct + * queries will leak across organisations. + */ +final class FormSchemaWebhook extends Model +{ + use HasFactory; + use HasUlids; + + protected $fillable = [ + 'form_schema_id', + 'name', + 'trigger_event', + 'url', + 'secret', + 'is_active', + ]; + + /** @var array */ + protected $casts = [ + 'url' => 'encrypted', + 'secret' => 'encrypted', + 'is_active' => 'bool', + ]; + + public function schema(): BelongsTo + { + return $this->belongsTo(FormSchema::class, 'form_schema_id'); + } + + public function deliveries(): HasMany + { + return $this->hasMany(FormWebhookDelivery::class); + } +} diff --git a/api/app/Models/FormBuilder/FormSubmission.php b/api/app/Models/FormBuilder/FormSubmission.php new file mode 100644 index 00000000..288e389f --- /dev/null +++ b/api/app/Models/FormBuilder/FormSubmission.php @@ -0,0 +1,102 @@ + */ + protected $casts = [ + 'status' => FormSubmissionStatus::class, + 'review_status' => FormSubmissionReviewStatus::class, + 'schema_snapshot' => 'array', + 'is_test' => 'bool', + 'submitted_at' => 'datetime', + 'reviewed_at' => 'datetime', + 'anonymised_at' => 'datetime', + 'opened_at' => 'datetime', + 'first_interacted_at' => 'datetime', + 'public_submitter_ip_anonymised_at' => 'datetime', + 'schema_version_at_submit' => 'int', + 'submission_duration_seconds' => 'int', + 'auto_save_count' => 'int', + ]; + + public function schema(): BelongsTo + { + return $this->belongsTo(FormSchema::class, 'form_schema_id'); + } + + public function subject(): MorphTo + { + return $this->morphTo(); + } + + public function submittedBy(): BelongsTo + { + return $this->belongsTo(User::class, 'submitted_by_user_id'); + } + + public function reviewedBy(): BelongsTo + { + return $this->belongsTo(User::class, 'reviewed_by_user_id'); + } + + public function values(): HasMany + { + return $this->hasMany(FormValue::class); + } + + public function sectionStatuses(): HasMany + { + return $this->hasMany(FormSubmissionSectionStatus::class); + } + + public function delegations(): HasMany + { + return $this->hasMany(FormSubmissionDelegation::class); + } +} diff --git a/api/app/Models/FormBuilder/FormSubmissionDelegation.php b/api/app/Models/FormBuilder/FormSubmissionDelegation.php new file mode 100644 index 00000000..c72b591d --- /dev/null +++ b/api/app/Models/FormBuilder/FormSubmissionDelegation.php @@ -0,0 +1,47 @@ + */ + protected $casts = [ + 'granted_at' => 'datetime', + 'revoked_at' => 'datetime', + ]; + + public function submission(): BelongsTo + { + return $this->belongsTo(FormSubmission::class, 'form_submission_id'); + } + + public function delegatedTo(): BelongsTo + { + return $this->belongsTo(User::class, 'delegated_to_user_id'); + } + + public function delegatedBy(): BelongsTo + { + return $this->belongsTo(User::class, 'delegated_by_user_id'); + } +} diff --git a/api/app/Models/FormBuilder/FormSubmissionSectionStatus.php b/api/app/Models/FormBuilder/FormSubmissionSectionStatus.php new file mode 100644 index 00000000..2d2f2ea5 --- /dev/null +++ b/api/app/Models/FormBuilder/FormSubmissionSectionStatus.php @@ -0,0 +1,46 @@ + */ + protected $casts = [ + 'submitted_at' => 'datetime', + 'reviewed_at' => 'datetime', + ]; + + public function submission(): BelongsTo + { + return $this->belongsTo(FormSubmission::class, 'form_submission_id'); + } + + public function section(): BelongsTo + { + return $this->belongsTo(FormSchemaSection::class, 'form_schema_section_id'); + } + + public function reviewedBy(): BelongsTo + { + return $this->belongsTo(User::class, 'reviewed_by_user_id'); + } +} diff --git a/api/app/Models/FormBuilder/FormTemplate.php b/api/app/Models/FormBuilder/FormTemplate.php new file mode 100644 index 00000000..18ce9b5a --- /dev/null +++ b/api/app/Models/FormBuilder/FormTemplate.php @@ -0,0 +1,49 @@ + */ + protected $casts = [ + 'purpose' => FormPurpose::class, + 'schema_snapshot' => 'array', + 'is_active' => 'bool', + 'is_system' => 'bool', + ]; + + public function organisation(): BelongsTo + { + return $this->belongsTo(Organisation::class); + } +} diff --git a/api/app/Models/FormBuilder/FormValue.php b/api/app/Models/FormBuilder/FormValue.php new file mode 100644 index 00000000..8d9ab198 --- /dev/null +++ b/api/app/Models/FormBuilder/FormValue.php @@ -0,0 +1,54 @@ + */ + protected $casts = [ + 'value' => 'array', + 'value_number' => 'decimal:4', + 'value_date' => 'date', + 'value_bool' => 'bool', + 'value_anonymised' => 'bool', + ]; + + public function submission(): BelongsTo + { + return $this->belongsTo(FormSubmission::class, 'form_submission_id'); + } + + public function field(): BelongsTo + { + return $this->belongsTo(FormField::class, 'form_field_id'); + } + + public function options(): HasMany + { + return $this->hasMany(FormValueOption::class); + } +} diff --git a/api/app/Models/FormBuilder/FormValueOption.php b/api/app/Models/FormBuilder/FormValueOption.php new file mode 100644 index 00000000..d768165d --- /dev/null +++ b/api/app/Models/FormBuilder/FormValueOption.php @@ -0,0 +1,43 @@ +belongsTo(FormValue::class, 'form_value_id'); + } + + public function field(): BelongsTo + { + return $this->belongsTo(FormField::class, 'form_field_id'); + } + + public function submission(): BelongsTo + { + return $this->belongsTo(FormSubmission::class, 'form_submission_id'); + } +} diff --git a/api/app/Models/FormBuilder/FormWebhookDelivery.php b/api/app/Models/FormBuilder/FormWebhookDelivery.php new file mode 100644 index 00000000..315da84f --- /dev/null +++ b/api/app/Models/FormBuilder/FormWebhookDelivery.php @@ -0,0 +1,60 @@ + */ + protected $casts = [ + 'status' => FormWebhookDeliveryStatus::class, + 'payload_snapshot' => 'array', + 'last_attempt_at' => 'datetime', + 'next_retry_at' => 'datetime', + 'delivered_at' => 'datetime', + 'failed_permanently_at' => 'datetime', + 'attempts' => 'int', + 'response_status' => 'int', + ]; + + public function webhook(): BelongsTo + { + return $this->belongsTo(FormSchemaWebhook::class, 'form_schema_webhook_id'); + } + + public function submission(): BelongsTo + { + return $this->belongsTo(FormSubmission::class, 'form_submission_id'); + } +} diff --git a/api/app/Models/Person.php b/api/app/Models/Person.php index 6e50d4a3..0e3041c5 100644 --- a/api/app/Models/Person.php +++ b/api/app/Models/Person.php @@ -14,6 +14,7 @@ use Illuminate\Database\Eloquent\Relations\BelongsTo; use Illuminate\Database\Eloquent\Relations\BelongsToMany; use Illuminate\Database\Eloquent\Relations\HasMany; use Illuminate\Database\Eloquent\Relations\HasOne; +use Illuminate\Database\Eloquent\Relations\MorphMany; use Illuminate\Database\Eloquent\SoftDeletes; final class Person extends Model @@ -109,6 +110,11 @@ final class Person extends Model return $this->hasMany(PersonFieldValue::class); } + public function formSubmissions(): MorphMany + { + return $this->morphMany(\App\Models\FormBuilder\FormSubmission::class, 'subject'); + } + public function sectionPreferences(): HasMany { return $this->hasMany(PersonSectionPreference::class); diff --git a/api/app/Models/User.php b/api/app/Models/User.php index 100da495..a2d45c87 100644 --- a/api/app/Models/User.php +++ b/api/app/Models/User.php @@ -8,6 +8,7 @@ use Illuminate\Database\Eloquent\Concerns\HasUlids; use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Relations\BelongsToMany; use Illuminate\Database\Eloquent\Relations\HasMany; +use Illuminate\Database\Eloquent\Relations\HasOne; use Illuminate\Database\Eloquent\SoftDeletes; use Illuminate\Foundation\Auth\User as Authenticatable; use Illuminate\Notifications\Notifiable; @@ -87,6 +88,16 @@ final class User extends Authenticatable return $this->hasMany(UserInvitation::class, 'invited_by_user_id'); } + public function profile(): HasOne + { + return $this->hasOne(UserProfile::class); + } + + public function getOrCreateProfile(): UserProfile + { + return $this->profile()->firstOrCreate([]); + } + public function identityMatches(): HasMany { return $this->hasMany(PersonIdentityMatch::class, 'matched_user_id'); diff --git a/api/app/Models/UserProfile.php b/api/app/Models/UserProfile.php new file mode 100644 index 00000000..4239c893 --- /dev/null +++ b/api/app/Models/UserProfile.php @@ -0,0 +1,61 @@ + + */ + protected $casts = [ + 'reliability_score' => 'decimal:2', + 'is_ambassador' => 'bool', + 'settings' => 'array', + ]; + + public function user(): BelongsTo + { + return $this->belongsTo(User::class); + } + + /** + * Computed: the most recent submitted_at across submissions whose subject + * is this user (non-test only). Null when user has no submissions. + */ + protected function lastSubmittedAt(): Attribute + { + return Attribute::make( + get: fn () => FormSubmission::query() + ->where('subject_type', 'user') + ->where('subject_id', $this->user_id) + ->where('status', FormSubmissionStatus::SUBMITTED) + ->where('is_test', false) + ->max('submitted_at'), + ); + } +} diff --git a/api/app/Observers/FormBuilder/FormValueObserver.php b/api/app/Observers/FormBuilder/FormValueObserver.php new file mode 100644 index 00000000..4b8b47ee --- /dev/null +++ b/api/app/Observers/FormBuilder/FormValueObserver.php @@ -0,0 +1,192 @@ + + */ +final class FormValueObserver +{ + /** @var array Memoised field lookups for this observer lifetime. */ + private array $fieldCache = []; + + public function saving(FormValue $value): void + { + $field = $this->resolveField($value); + if ($field === null) { + return; + } + + $this->resetTypedColumns($value); + + // value stores the canonical payload (always JSON). Typed columns + // are derived from it based on the field's storage hint. + $raw = $value->value; + $scalar = $this->extractScalar($raw); + + match ($field->value_storage_hint) { + FormValueStorageHint::STRING => $value->value_indexed = $this->truncateIndexed($scalar), + FormValueStorageHint::NUMBER => $value->value_number = is_numeric($scalar) ? (float) $scalar : null, + FormValueStorageHint::DATE => $value->value_date = $this->castDate($scalar), + FormValueStorageHint::BOOL => $value->value_bool = $scalar === null ? null : (bool) $scalar, + FormValueStorageHint::JSON => null, + }; + + // Single-value filterable fields: ensure value_indexed is populated + // regardless of storage hint, so filter queries hit one index. + if ($field->is_filterable && ! $this->isMultiValueType($field)) { + if ($value->value_indexed === null) { + $value->value_indexed = $this->truncateIndexed($scalar); + } + } + } + + public function saved(FormValue $value): void + { + $field = $this->resolveField($value); + if ($field === null) { + return; + } + + if (! $field->is_filterable) { + // Not filterable — ensure no stale pivot rows linger. + FormValueOption::where('form_value_id', $value->id)->delete(); + + return; + } + + if (! $this->isMultiValueType($field)) { + return; + } + + FormValueOption::where('form_value_id', $value->id)->delete(); + + $options = $this->extractOptions($value->value); + if ($options === []) { + return; + } + + $rows = array_map(fn (string $opt): array => [ + 'form_value_id' => $value->id, + 'form_field_id' => $value->form_field_id, + 'form_submission_id' => $value->form_submission_id, + 'option_value' => Str::limit($opt, 255, ''), + ], $options); + + FormValueOption::insert($rows); + } + + public function deleted(FormValue $value): void + { + // Cascade handles FK delete at DB layer, but pivot rows without + // cascade-parent are cheap to clean explicitly. + FormValueOption::where('form_value_id', $value->id)->delete(); + } + + private function resolveField(FormValue $value): ?FormField + { + if ($value->relationLoaded('field')) { + return $value->getRelation('field'); + } + + $key = (string) $value->form_field_id; + if (! array_key_exists($key, $this->fieldCache)) { + $this->fieldCache[$key] = FormField::query()->find($value->form_field_id); + } + + return $this->fieldCache[$key]; + } + + private function resetTypedColumns(FormValue $value): void + { + $value->value_indexed = null; + $value->value_number = null; + $value->value_date = null; + $value->value_bool = null; + } + + /** + * @param mixed $raw + */ + private function extractScalar($raw): ?string + { + if ($raw === null || $raw === []) { + return null; + } + if (is_scalar($raw)) { + return (string) $raw; + } + // Conventional shape: { "value": } + if (is_array($raw) && array_key_exists('value', $raw) && is_scalar($raw['value'])) { + return (string) $raw['value']; + } + + return null; + } + + /** + * @param mixed $raw + * @return array + */ + private function extractOptions($raw): array + { + if (is_array($raw)) { + // MULTISELECT / CHECKBOX_LIST: list of scalars OR { value: [...] } + if (array_is_list($raw)) { + return array_values(array_map(fn ($v) => (string) $v, array_filter($raw, 'is_scalar'))); + } + if (array_key_exists('value', $raw) && is_array($raw['value'])) { + return array_values(array_map(fn ($v) => (string) $v, array_filter($raw['value'], 'is_scalar'))); + } + } + + return []; + } + + private function truncateIndexed(?string $value): ?string + { + if ($value === null) { + return null; + } + if (mb_strlen($value) > 255) { + return mb_substr($value, 0, 255); + } + + return $value; + } + + private function castDate(?string $value): ?string + { + if ($value === null || $value === '') { + return null; + } + $ts = strtotime($value); + + return $ts === false ? null : date('Y-m-d', $ts); + } + + private function isMultiValueType(FormField $field): bool + { + return in_array($field->field_type, [ + FormFieldType::MULTISELECT->value, + FormFieldType::CHECKBOX_LIST->value, + FormFieldType::TAG_PICKER->value, + ], true); + } +} diff --git a/api/app/Providers/AppServiceProvider.php b/api/app/Providers/AppServiceProvider.php index 794c7a89..73747c15 100644 --- a/api/app/Providers/AppServiceProvider.php +++ b/api/app/Providers/AppServiceProvider.php @@ -4,9 +4,42 @@ declare(strict_types=1); namespace App\Providers; +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\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\PersonFieldValue; +use App\Models\PersonIdentityMatch; +use App\Models\PersonSectionPreference; +use App\Models\PersonTag; +use App\Models\RegistrationFieldTemplate; +use App\Models\RegistrationFormField; +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\FormBuilder\FormValue; +use App\Models\VolunteerAvailability; +use App\Observers\FormBuilder\FormValueObserver; use App\Observers\PersonObserver; use Illuminate\Auth\Notifications\ResetPassword; +use Illuminate\Database\Eloquent\Relations\Relation; use Illuminate\Support\ServiceProvider; use Spatie\Activitylog\Models\Activity; @@ -19,7 +52,52 @@ class AppServiceProvider extends ServiceProvider 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_field_value' => PersonFieldValue::class, + 'person_identity_match' => PersonIdentityMatch::class, + 'person_section_preference' => PersonSectionPreference::class, + 'person_tag' => PersonTag::class, + 'registration_field_template' => RegistrationFieldTemplate::class, + 'registration_form_field' => RegistrationFormField::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, + ]); + Person::observe(PersonObserver::class); + FormValue::observe(FormValueObserver::class); ResetPassword::createUrlUsing(function ($user, string $token) { return config('crewli.portal_url') . '/wachtwoord-resetten?token=' . $token . '&email=' . urlencode($user->email); diff --git a/api/app/Services/OrganisationDashboardService.php b/api/app/Services/OrganisationDashboardService.php index 35491007..19142917 100644 --- a/api/app/Services/OrganisationDashboardService.php +++ b/api/app/Services/OrganisationDashboardService.php @@ -112,7 +112,7 @@ final class OrganisationDashboardService private function recentActivity(Organisation $organisation): array { return Activity::query() - ->where('subject_type', Organisation::class) + ->where('subject_type', (new Organisation)->getMorphClass()) ->where('subject_id', $organisation->id) ->with('causer') ->latest() diff --git a/api/app/Support/ActivityLog.php b/api/app/Support/ActivityLog.php new file mode 100644 index 00000000..9ae94538 --- /dev/null +++ b/api/app/Support/ActivityLog.php @@ -0,0 +1,38 @@ + false]); + + try { + return $callback(); + } finally { + config(['activitylog.enabled' => $previous]); + } + } +} diff --git a/api/database/factories/FormBuilder/FormFieldFactory.php b/api/database/factories/FormBuilder/FormFieldFactory.php new file mode 100644 index 00000000..3026c217 --- /dev/null +++ b/api/database/factories/FormBuilder/FormFieldFactory.php @@ -0,0 +1,76 @@ + */ +final class FormFieldFactory extends Factory +{ + protected $model = FormField::class; + + /** @return array */ + public function definition(): array + { + $fieldType = fake()->randomElement([ + FormFieldType::TEXT, + FormFieldType::TEXTAREA, + FormFieldType::EMAIL, + FormFieldType::NUMBER, + FormFieldType::BOOLEAN, + FormFieldType::SELECT, + ]); + $label = fake('nl_NL')->randomElement([ + 'Voornaam', 'Achternaam', 'E-mail', 'Telefoon', 'Opmerkingen', + 'Shirtmaat', 'Allergieën', 'Motivatie', 'Geboortedatum', + ]); + + return [ + 'form_schema_id' => FormSchema::factory(), + 'form_schema_section_id' => null, + 'library_field_id' => null, + 'field_type' => $fieldType->value, + 'slug' => Str::slug($label).'-'.Str::lower(Str::random(4)), + 'label' => $label, + 'help_text' => fake()->boolean(30) ? fake('nl_NL')->sentence() : null, + 'options' => $fieldType === FormFieldType::SELECT + ? ['Optie A', 'Optie B', 'Optie C'] + : null, + 'validation_rules' => null, + 'is_required' => fake()->boolean(40), + 'is_filterable' => false, + 'is_portal_visible' => true, + 'is_admin_only' => false, + 'is_unique' => false, + 'is_pii' => false, + 'display_width' => FormFieldDisplayWidth::FULL, + 'binding' => null, + 'conditional_logic' => null, + 'role_restrictions' => null, + 'translations' => null, + 'value_storage_hint' => $fieldType->recommendedValueStorageHint(), + 'review_required' => false, + 'sort_order' => 0, + ]; + } + + public function ofType(FormFieldType $type): static + { + return $this->state(fn () => [ + 'field_type' => $type->value, + 'value_storage_hint' => $type->recommendedValueStorageHint(), + ]); + } + + public function filterable(): static + { + return $this->state(fn () => ['is_filterable' => true]); + } +} diff --git a/api/database/factories/FormBuilder/FormFieldLibraryFactory.php b/api/database/factories/FormBuilder/FormFieldLibraryFactory.php new file mode 100644 index 00000000..6576b333 --- /dev/null +++ b/api/database/factories/FormBuilder/FormFieldLibraryFactory.php @@ -0,0 +1,48 @@ + */ +final class FormFieldLibraryFactory extends Factory +{ + protected $model = FormFieldLibrary::class; + + /** @return array */ + public function definition(): array + { + $name = fake('nl_NL')->randomElement([ + 'Shirtmaat (standaard)', 'Dieet (standaard)', + 'Noodcontact (standaard)', 'Motivatie (standaard)', + ]); + + return [ + 'organisation_id' => Organisation::factory(), + 'name' => $name, + 'slug' => Str::slug($name).'-'.Str::lower(Str::random(4)), + 'field_type' => FormFieldType::TEXT->value, + 'label' => fake('nl_NL')->words(2, true), + 'help_text' => null, + 'options' => null, + 'validation_rules' => null, + 'default_is_required' => false, + 'default_is_filterable' => false, + 'default_binding' => null, + 'translations' => null, + 'description' => fake('nl_NL')->sentence(), + 'is_active' => true, + ]; + } + + public function system(): static + { + return $this->state(fn () => ['is_system' => true]); + } +} diff --git a/api/database/factories/FormBuilder/FormSchemaFactory.php b/api/database/factories/FormBuilder/FormSchemaFactory.php new file mode 100644 index 00000000..b1a859fa --- /dev/null +++ b/api/database/factories/FormBuilder/FormSchemaFactory.php @@ -0,0 +1,72 @@ + */ +final class FormSchemaFactory extends Factory +{ + protected $model = FormSchema::class; + + /** @return array */ + public function definition(): array + { + $purpose = fake()->randomElement([ + FormPurpose::EVENT_REGISTRATION, + FormPurpose::FEEDBACK, + FormPurpose::INCIDENT_REPORT, + FormPurpose::USER_PROFILE, + ]); + $name = 'Formulier '.fake('nl_NL')->words(2, true); + + return [ + 'organisation_id' => Organisation::factory(), + 'owner_type' => null, + 'owner_id' => null, + 'name' => $name, + 'slug' => Str::slug($name).'-'.Str::lower(Str::random(4)), + 'purpose' => $purpose, + 'custom_purpose_slug' => null, + 'description' => fake('nl_NL')->sentence(), + 'is_published' => false, + 'submission_mode' => $purpose->defaultSubmissionMode(), + 'locale' => 'nl', + 'snapshot_mode' => FormSchemaSnapshotMode::NEVER, + 'freeze_on_submit' => false, + 'section_level_submit' => false, + 'auto_save_enabled' => false, + ]; + } + + public function custom(string $slug): static + { + return $this->state(fn () => [ + 'purpose' => FormPurpose::CUSTOM, + 'submission_mode' => FormSubmissionMode::SINGLE, + 'custom_purpose_slug' => $slug, + ]); + } + + public function published(): static + { + return $this->state(fn () => ['is_published' => true]); + } + + public function forPurpose(FormPurpose $purpose): static + { + return $this->state(fn () => [ + 'purpose' => $purpose, + 'submission_mode' => $purpose->defaultSubmissionMode(), + 'custom_purpose_slug' => $purpose === FormPurpose::CUSTOM ? 'custom-'.Str::lower(Str::random(6)) : null, + ]); + } +} diff --git a/api/database/factories/FormBuilder/FormSchemaSectionFactory.php b/api/database/factories/FormBuilder/FormSchemaSectionFactory.php new file mode 100644 index 00000000..07235796 --- /dev/null +++ b/api/database/factories/FormBuilder/FormSchemaSectionFactory.php @@ -0,0 +1,35 @@ + */ +final class FormSchemaSectionFactory extends Factory +{ + protected $model = FormSchemaSection::class; + + /** @return array */ + public function definition(): array + { + $name = fake('nl_NL')->randomElement([ + 'Algemene gegevens', 'Contactgegevens', 'Technische rider', + 'Productie', 'Catering', 'Transport', 'Accreditatie', + ]); + + return [ + 'form_schema_id' => FormSchema::factory(), + 'slug' => Str::slug($name).'-'.Str::lower(Str::random(4)), + 'name' => $name, + 'description' => fake('nl_NL')->sentence(), + 'sort_order' => 0, + 'submit_independent' => true, + 'required_for_schema_submit' => true, + ]; + } +} diff --git a/api/database/factories/FormBuilder/FormSchemaWebhookFactory.php b/api/database/factories/FormBuilder/FormSchemaWebhookFactory.php new file mode 100644 index 00000000..ad646564 --- /dev/null +++ b/api/database/factories/FormBuilder/FormSchemaWebhookFactory.php @@ -0,0 +1,29 @@ + */ +final class FormSchemaWebhookFactory extends Factory +{ + protected $model = FormSchemaWebhook::class; + + /** @return array */ + public function definition(): array + { + return [ + 'form_schema_id' => FormSchema::factory(), + 'name' => 'Webhook '.fake()->words(2, true), + 'trigger_event' => 'submission_submitted', + 'url' => 'https://webhook.example.com/'.Str::lower(Str::random(12)), + 'secret' => Str::random(32), + 'is_active' => true, + ]; + } +} diff --git a/api/database/factories/FormBuilder/FormSubmissionDelegationFactory.php b/api/database/factories/FormBuilder/FormSubmissionDelegationFactory.php new file mode 100644 index 00000000..8b6f01c4 --- /dev/null +++ b/api/database/factories/FormBuilder/FormSubmissionDelegationFactory.php @@ -0,0 +1,28 @@ + */ +final class FormSubmissionDelegationFactory extends Factory +{ + protected $model = FormSubmissionDelegation::class; + + /** @return array */ + public function definition(): array + { + return [ + 'form_submission_id' => FormSubmission::factory(), + 'delegated_to_user_id' => User::factory(), + 'delegated_by_user_id' => User::factory(), + 'granted_at' => now(), + 'message' => fake('nl_NL')->sentence(), + ]; + } +} diff --git a/api/database/factories/FormBuilder/FormSubmissionFactory.php b/api/database/factories/FormBuilder/FormSubmissionFactory.php new file mode 100644 index 00000000..911a0478 --- /dev/null +++ b/api/database/factories/FormBuilder/FormSubmissionFactory.php @@ -0,0 +1,43 @@ + */ +final class FormSubmissionFactory extends Factory +{ + protected $model = FormSubmission::class; + + /** @return array */ + public function definition(): array + { + return [ + 'form_schema_id' => FormSchema::factory(), + 'subject_type' => null, + 'subject_id' => null, + 'submitted_by_user_id' => null, + 'status' => FormSubmissionStatus::DRAFT, + 'is_test' => false, + 'submitted_in_locale' => 'nl', + ]; + } + + public function submitted(): static + { + return $this->state(fn () => [ + 'status' => FormSubmissionStatus::SUBMITTED, + 'submitted_at' => now(), + ]); + } + + public function test(): static + { + return $this->state(fn () => ['is_test' => true]); + } +} diff --git a/api/database/factories/FormBuilder/FormSubmissionSectionStatusFactory.php b/api/database/factories/FormBuilder/FormSubmissionSectionStatusFactory.php new file mode 100644 index 00000000..9bff63b0 --- /dev/null +++ b/api/database/factories/FormBuilder/FormSubmissionSectionStatusFactory.php @@ -0,0 +1,26 @@ + */ +final class FormSubmissionSectionStatusFactory extends Factory +{ + protected $model = FormSubmissionSectionStatus::class; + + /** @return array */ + public function definition(): array + { + return [ + 'form_submission_id' => FormSubmission::factory(), + 'form_schema_section_id' => FormSchemaSection::factory(), + 'status' => 'draft', + ]; + } +} diff --git a/api/database/factories/FormBuilder/FormTemplateFactory.php b/api/database/factories/FormBuilder/FormTemplateFactory.php new file mode 100644 index 00000000..48d50276 --- /dev/null +++ b/api/database/factories/FormBuilder/FormTemplateFactory.php @@ -0,0 +1,52 @@ + */ +final class FormTemplateFactory extends Factory +{ + protected $model = FormTemplate::class; + + /** @return array */ + public function definition(): array + { + $name = 'Template '.fake('nl_NL')->words(2, true); + + return [ + 'organisation_id' => Organisation::factory(), + 'name' => $name, + 'slug' => Str::slug($name).'-'.Str::lower(Str::random(4)), + 'purpose' => FormPurpose::EVENT_REGISTRATION, + 'description' => fake('nl_NL')->sentence(), + 'schema_snapshot' => [ + 'schema_version' => 1, + 'snapshot_created_at' => now()->toIso8601String(), + 'schema' => [ + 'name' => $name, + 'slug' => Str::slug($name), + 'purpose' => FormPurpose::EVENT_REGISTRATION->value, + 'locale' => 'nl', + 'freeze_on_submit' => false, + 'section_level_submit' => false, + 'settings' => [], + ], + 'sections' => [], + 'fields' => [], + ], + 'is_active' => true, + ]; + } + + public function system(): static + { + return $this->state(fn () => ['is_system' => true]); + } +} diff --git a/api/database/factories/FormBuilder/FormValueFactory.php b/api/database/factories/FormBuilder/FormValueFactory.php new file mode 100644 index 00000000..38df0daa --- /dev/null +++ b/api/database/factories/FormBuilder/FormValueFactory.php @@ -0,0 +1,27 @@ + */ +final class FormValueFactory extends Factory +{ + protected $model = FormValue::class; + + /** @return array */ + public function definition(): array + { + return [ + 'form_submission_id' => FormSubmission::factory(), + 'form_field_id' => FormField::factory(), + 'value' => ['value' => fake('nl_NL')->word()], + 'value_anonymised' => false, + ]; + } +} diff --git a/api/database/factories/FormBuilder/FormValueOptionFactory.php b/api/database/factories/FormBuilder/FormValueOptionFactory.php new file mode 100644 index 00000000..d2131f46 --- /dev/null +++ b/api/database/factories/FormBuilder/FormValueOptionFactory.php @@ -0,0 +1,28 @@ + */ +final class FormValueOptionFactory extends Factory +{ + protected $model = FormValueOption::class; + + /** @return array */ + public function definition(): array + { + return [ + 'form_value_id' => FormValue::factory(), + 'form_field_id' => FormField::factory(), + 'form_submission_id' => FormSubmission::factory(), + 'option_value' => fake()->word(), + ]; + } +} diff --git a/api/database/factories/FormBuilder/FormWebhookDeliveryFactory.php b/api/database/factories/FormBuilder/FormWebhookDeliveryFactory.php new file mode 100644 index 00000000..d3095e46 --- /dev/null +++ b/api/database/factories/FormBuilder/FormWebhookDeliveryFactory.php @@ -0,0 +1,30 @@ + */ +final class FormWebhookDeliveryFactory extends Factory +{ + protected $model = FormWebhookDelivery::class; + + /** @return array */ + public function definition(): array + { + return [ + 'form_schema_webhook_id' => FormSchemaWebhook::factory(), + 'form_submission_id' => FormSubmission::factory(), + 'trigger_event' => 'submission_submitted', + 'status' => FormWebhookDeliveryStatus::PENDING, + 'attempts' => 0, + 'payload_snapshot' => ['event' => 'submission_submitted'], + ]; + } +} diff --git a/api/database/factories/UserProfileFactory.php b/api/database/factories/UserProfileFactory.php new file mode 100644 index 00000000..93f55263 --- /dev/null +++ b/api/database/factories/UserProfileFactory.php @@ -0,0 +1,33 @@ + */ +final class UserProfileFactory extends Factory +{ + /** @return array */ + public function definition(): array + { + return [ + 'user_id' => User::factory(), + 'bio' => fake('nl_NL')->sentence(10), + 'photo_url' => null, + 'emergency_contact_name' => fake('nl_NL')->name(), + 'emergency_contact_phone' => fake('nl_NL')->phoneNumber(), + 'reliability_score' => fake()->randomFloat(2, 3.00, 5.00), + 'is_ambassador' => false, + 'settings' => null, + ]; + } + + public function ambassador(): static + { + return $this->state(fn () => ['is_ambassador' => true]); + } +} diff --git a/api/tests/Feature/Organisation/OrganisationTest.php b/api/tests/Feature/Organisation/OrganisationTest.php index 87751341..00f953c9 100644 --- a/api/tests/Feature/Organisation/OrganisationTest.php +++ b/api/tests/Feature/Organisation/OrganisationTest.php @@ -300,7 +300,7 @@ class OrganisationTest extends TestCase ])->assertOk(); $activities = Activity::where('log_name', 'organisation') - ->where('subject_type', Organisation::class) + ->where('subject_type', 'organisation') ->where('subject_id', $org->id) ->get(); diff --git a/api/tests/Unit/Observers/FormBuilder/FormValueObserverTest.php b/api/tests/Unit/Observers/FormBuilder/FormValueObserverTest.php new file mode 100644 index 00000000..7bf0ffce --- /dev/null +++ b/api/tests/Unit/Observers/FormBuilder/FormValueObserverTest.php @@ -0,0 +1,204 @@ +schema = FormSchema::factory()->create(); + $this->submission = FormSubmission::factory()->create([ + 'form_schema_id' => $this->schema->id, + ]); + } + + public function test_string_hint_populates_value_indexed(): void + { + $field = FormField::factory()->for($this->schema, 'schema')->create([ + 'field_type' => FormFieldType::TEXT->value, + 'value_storage_hint' => FormValueStorageHint::STRING, + ]); + + $value = FormValue::create([ + 'form_submission_id' => $this->submission->id, + 'form_field_id' => $field->id, + 'value' => ['value' => 'Bert Hausmans'], + ]); + + $this->assertSame('Bert Hausmans', $value->fresh()->value_indexed); + $this->assertNull($value->value_number); + $this->assertNull($value->value_date); + $this->assertNull($value->value_bool); + } + + public function test_number_hint_populates_value_number(): void + { + $field = FormField::factory()->for($this->schema, 'schema')->create([ + 'field_type' => FormFieldType::NUMBER->value, + 'value_storage_hint' => FormValueStorageHint::NUMBER, + ]); + + $value = FormValue::create([ + 'form_submission_id' => $this->submission->id, + 'form_field_id' => $field->id, + 'value' => ['value' => 42.5], + ]); + + $this->assertEqualsWithDelta(42.5, (float) $value->fresh()->value_number, 0.0001); + $this->assertNull($value->fresh()->value_indexed); + } + + public function test_date_hint_populates_value_date(): void + { + $field = FormField::factory()->for($this->schema, 'schema')->create([ + 'field_type' => FormFieldType::DATE->value, + 'value_storage_hint' => FormValueStorageHint::DATE, + ]); + + $value = FormValue::create([ + 'form_submission_id' => $this->submission->id, + 'form_field_id' => $field->id, + 'value' => ['value' => '2026-07-04'], + ]); + + $this->assertSame('2026-07-04', $value->fresh()->value_date->format('Y-m-d')); + } + + public function test_bool_hint_populates_value_bool(): void + { + $field = FormField::factory()->for($this->schema, 'schema')->create([ + 'field_type' => FormFieldType::BOOLEAN->value, + 'value_storage_hint' => FormValueStorageHint::BOOL, + ]); + + $value = FormValue::create([ + 'form_submission_id' => $this->submission->id, + 'form_field_id' => $field->id, + 'value' => ['value' => true], + ]); + + $this->assertTrue($value->fresh()->value_bool); + } + + public function test_json_hint_leaves_typed_columns_null(): void + { + $field = FormField::factory()->for($this->schema, 'schema')->create([ + 'field_type' => FormFieldType::TABLE_ROWS->value, + 'value_storage_hint' => FormValueStorageHint::JSON, + ]); + + $value = FormValue::create([ + 'form_submission_id' => $this->submission->id, + 'form_field_id' => $field->id, + 'value' => [['col_a' => 'x', 'col_b' => 'y']], + ]); + + $fresh = $value->fresh(); + $this->assertNull($fresh->value_indexed); + $this->assertNull($fresh->value_number); + $this->assertNull($fresh->value_date); + $this->assertNull($fresh->value_bool); + } + + public function test_filterable_multiselect_rebuilds_pivot(): void + { + $field = FormField::factory()->for($this->schema, 'schema')->create([ + 'field_type' => FormFieldType::MULTISELECT->value, + 'value_storage_hint' => FormValueStorageHint::JSON, + 'is_filterable' => true, + ]); + + $value = FormValue::create([ + 'form_submission_id' => $this->submission->id, + 'form_field_id' => $field->id, + 'value' => ['XS', 'M', 'XXL'], + ]); + + $options = FormValueOption::where('form_value_id', $value->id) + ->pluck('option_value') + ->sort() + ->values() + ->all(); + + $this->assertSame(['M', 'XS', 'XXL'], $options); + } + + public function test_resave_rebuilds_pivot_idempotently(): void + { + $field = FormField::factory()->for($this->schema, 'schema')->create([ + 'field_type' => FormFieldType::MULTISELECT->value, + 'is_filterable' => true, + ]); + + $value = FormValue::create([ + 'form_submission_id' => $this->submission->id, + 'form_field_id' => $field->id, + 'value' => ['A', 'B'], + ]); + + $this->assertSame(2, FormValueOption::where('form_value_id', $value->id)->count()); + + // Resave with different options. + $value->value = ['C']; + $value->save(); + + $options = FormValueOption::where('form_value_id', $value->id) + ->pluck('option_value') + ->all(); + + $this->assertSame(['C'], $options); + } + + public function test_non_filterable_field_does_not_populate_pivot(): void + { + $field = FormField::factory()->for($this->schema, 'schema')->create([ + 'field_type' => FormFieldType::MULTISELECT->value, + 'is_filterable' => false, + ]); + + $value = FormValue::create([ + 'form_submission_id' => $this->submission->id, + 'form_field_id' => $field->id, + 'value' => ['A', 'B'], + ]); + + $this->assertSame(0, FormValueOption::where('form_value_id', $value->id)->count()); + } + + public function test_single_value_filterable_populates_value_indexed_even_when_hint_is_json(): void + { + $field = FormField::factory()->for($this->schema, 'schema')->create([ + 'field_type' => FormFieldType::SELECT->value, + 'value_storage_hint' => FormValueStorageHint::JSON, + 'is_filterable' => true, + ]); + + $value = FormValue::create([ + 'form_submission_id' => $this->submission->id, + 'form_field_id' => $field->id, + 'value' => ['value' => 'beta'], + ]); + + $this->assertSame('beta', $value->fresh()->value_indexed); + } +}