feat(forms): add Eloquent models, observer, events, activity-log helpers
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) <noreply@anthropic.com>
This commit is contained in:
192
api/app/Observers/FormBuilder/FormValueObserver.php
Normal file
192
api/app/Observers/FormBuilder/FormValueObserver.php
Normal file
@@ -0,0 +1,192 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Observers\FormBuilder;
|
||||
|
||||
use App\Enums\FormBuilder\FormFieldType;
|
||||
use App\Enums\FormBuilder\FormValueStorageHint;
|
||||
use App\Models\FormBuilder\FormField;
|
||||
use App\Models\FormBuilder\FormValue;
|
||||
use App\Models\FormBuilder\FormValueOption;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
/**
|
||||
* Populates typed columns (value_indexed / value_number / value_date /
|
||||
* value_bool) on FormValue upsert, and rebuilds the form_value_options
|
||||
* pivot for multi-value filterable fields. See ARCH §7.2.
|
||||
*
|
||||
* The caller SHOULD eager-load the related FormField; we memoise on the
|
||||
* observer instance to avoid N+1 when saving many values in one batch.
|
||||
*
|
||||
* @var array<string, FormField|null>
|
||||
*/
|
||||
final class FormValueObserver
|
||||
{
|
||||
/** @var array<string, FormField|null> 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": <scalar> }
|
||||
if (is_array($raw) && array_key_exists('value', $raw) && is_scalar($raw['value'])) {
|
||||
return (string) $raw['value'];
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param mixed $raw
|
||||
* @return array<int, string>
|
||||
*/
|
||||
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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user