refactor(form-builder): drop custom purpose escape from schemas

Reduces the FormPurpose vocabulary from 22 variants + a `custom` escape
to the seven v1.0 purposes registered in the new PurposeRegistry.

- Purge migration deletes any form_schemas row whose `purpose` is not
  in the v1.0 set (cascades through form_fields, form_submissions,
  form_values, form_value_options, form_schema_sections,
  form_submission_section_statuses, form_submission_delegations,
  form_schema_webhooks, form_webhook_deliveries via existing FK).
- Drop migration removes the `custom_purpose_slug` column + its index.
- Both migrations declare their `down()` as a hard failure — we do not
  support reversing a purge (pre-launch, no production data).
- `FormPurpose` enum slims to the seven cases; the legacy helpers
  (defaultSubmissionMode / defaultSubjectType / allowsPublicAccess)
  now delegate to PurposeRegistry so callers keep working.
- FormSchema fillable / FormSchemaResource / StoreFormSchemaRequest /
  UpdateFormSchemaRequest / FormSchemaFactory drop every reference to
  `custom_purpose_slug` and the `custom` purpose.
- VerifyFormsDataIntegrity drops the custom-slug mismatch check and
  sources the subject-type allow-list from PurposeRegistry.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-04-24 14:35:37 +02:00
parent e93207765b
commit b9343f6eec
9 changed files with 162 additions and 120 deletions

View File

@@ -6,6 +6,7 @@ namespace App\Console\Commands;
use App\Enums\FormBuilder\FormFieldType;
use App\Enums\FormBuilder\FormPurpose;
use App\FormBuilder\Purposes\PurposeRegistry;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
@@ -68,18 +69,6 @@ final class VerifyFormsDataIntegrity extends Command
->whereNotIn('purpose', $purposeValues)
->count();
$customMismatch = DB::table('form_schemas')
->where(function ($q): void {
$q->where(function ($q): void {
$q->where('purpose', FormPurpose::CUSTOM->value)
->whereNull('custom_purpose_slug');
})->orWhere(function ($q): void {
$q->where('purpose', '!=', FormPurpose::CUSTOM->value)
->whereNotNull('custom_purpose_slug');
});
})
->count();
$publicTokenDupes = DB::table('form_schemas')
->whereNotNull('public_token')
->select('public_token')
@@ -87,9 +76,9 @@ final class VerifyFormsDataIntegrity extends Command
->havingRaw('COUNT(*) > 1')
->count();
if ($invalidPurpose > 0 || $customMismatch > 0 || $publicTokenDupes > 0) {
if ($invalidPurpose > 0 || $publicTokenDupes > 0) {
$this->recordFailure('Schema coherence',
"{$invalidPurpose} invalid purpose, {$customMismatch} custom_purpose_slug mismatches, {$publicTokenDupes} duplicate public_tokens"
"{$invalidPurpose} invalid purpose, {$publicTokenDupes} duplicate public_tokens"
);
return;
@@ -190,7 +179,7 @@ final class VerifyFormsDataIntegrity extends Command
})
->count();
$subjectTypes = array_keys((array) config('form_subjects', []));
$subjectTypes = app(PurposeRegistry::class)->allSubjectTypes();
$invalidSubjectType = DB::table('form_submissions')
->whereNotNull('subject_type')
->whereNotIn('subject_type', $subjectTypes)

View File

@@ -4,30 +4,25 @@ declare(strict_types=1);
namespace App\Enums\FormBuilder;
/**
* v1.0 purpose vocabulary the seven purposes registered in
* `config/form_builder/purposes.php` (ARCH-CONSOLIDATION §3 besluit 4).
*
* Prefer `App\FormBuilder\Purposes\PurposeRegistry` for lookups: label,
* subject_type, default submission mode, public-access flag and
* required bindings all live there. This enum is kept for type-safety
* on the `form_schemas.purpose` column and for code paths that match
* a purpose by identity.
*/
enum FormPurpose: string
{
case EVENT_REGISTRATION = 'event_registration';
case USER_PROFILE = 'user_profile';
case ARTIST_PROFILE = 'artist_profile';
case COMPANY_PROFILE = 'company_profile';
case ARTIST_ADVANCE = 'artist_advance';
case SUPPLIER_INTAKE = 'supplier_intake';
case INCIDENT_REPORT = 'incident_report';
case FEEDBACK = 'feedback';
case POST_EVENT_EVALUATION = 'post_event_evaluation';
case INCIDENT_REPORT = 'incident_report';
case SIGNATURE_CONTRACT = 'signature_contract';
case SIGNATURE_CODE_OF_CONDUCT = 'signature_code_of_conduct';
case SIGNATURE_RECEIPT = 'signature_receipt';
case ABSENCE_REPORT = 'absence_report';
case CHECK_OUT_INVENTORY = 'check_out_inventory';
case PUBLIC_COMPLAINT = 'public_complaint';
case PUBLIC_PRESS_REQUEST = 'public_press_request';
case PUBLIC_RSVP = 'public_rsvp';
case ONBOARDING_WIZARD = 'onboarding_wizard';
case EVENT_SETUP_WIZARD = 'event_setup_wizard';
case COMPANY_CUSTOM = 'company_custom';
case ARTIST_CUSTOM = 'artist_custom';
case CUSTOM = 'custom';
case USER_PROFILE = 'user_profile';
/**
* @return array<int, string>
@@ -37,83 +32,24 @@ enum FormPurpose: string
return array_map(fn (self $case): string => $case->value, self::cases());
}
/**
* Default subject_type for each purpose per ARCH §3.1.
* null = subject may be null (public or ambiguous).
*/
public function defaultSubjectType(): ?string
{
return match ($this) {
self::EVENT_REGISTRATION,
self::POST_EVENT_EVALUATION,
self::SIGNATURE_RECEIPT,
self::ABSENCE_REPORT,
self::CHECK_OUT_INVENTORY => 'person',
self::USER_PROFILE,
self::SIGNATURE_CODE_OF_CONDUCT => 'user',
self::ARTIST_PROFILE,
self::ARTIST_ADVANCE,
self::ARTIST_CUSTOM => 'artist',
self::COMPANY_PROFILE,
self::SUPPLIER_INTAKE,
self::COMPANY_CUSTOM => 'company',
self::ONBOARDING_WIZARD => 'organisation',
self::EVENT_SETUP_WIZARD => 'event',
self::INCIDENT_REPORT,
self::FEEDBACK,
self::SIGNATURE_CONTRACT => 'user',
self::PUBLIC_COMPLAINT,
self::PUBLIC_PRESS_REQUEST,
self::PUBLIC_RSVP,
self::CUSTOM => null,
};
}
public function defaultSubmissionMode(): FormSubmissionMode
{
return match ($this) {
self::EVENT_REGISTRATION,
self::ARTIST_ADVANCE,
self::SUPPLIER_INTAKE => FormSubmissionMode::DRAFT_SINGLE,
return app(\App\FormBuilder\Purposes\PurposeRegistry::class)
->get($this->value)
->defaultSubmissionMode;
}
self::INCIDENT_REPORT,
self::FEEDBACK,
self::SIGNATURE_RECEIPT,
self::ABSENCE_REPORT,
self::CHECK_OUT_INVENTORY,
self::PUBLIC_COMPLAINT,
self::PUBLIC_PRESS_REQUEST => FormSubmissionMode::MULTIPLE,
self::USER_PROFILE,
self::ARTIST_PROFILE,
self::COMPANY_PROFILE,
self::POST_EVENT_EVALUATION,
self::SIGNATURE_CONTRACT,
self::SIGNATURE_CODE_OF_CONDUCT,
self::PUBLIC_RSVP,
self::ONBOARDING_WIZARD,
self::EVENT_SETUP_WIZARD,
self::COMPANY_CUSTOM,
self::ARTIST_CUSTOM,
self::CUSTOM => FormSubmissionMode::SINGLE,
};
public function defaultSubjectType(): string
{
return app(\App\FormBuilder\Purposes\PurposeRegistry::class)
->get($this->value)
->subjectType;
}
public function allowsPublicAccess(): bool
{
return match ($this) {
self::PUBLIC_COMPLAINT,
self::PUBLIC_PRESS_REQUEST,
self::PUBLIC_RSVP,
self::ARTIST_ADVANCE,
self::SUPPLIER_INTAKE => true,
default => false,
};
return app(\App\FormBuilder\Purposes\PurposeRegistry::class)
->get($this->value)
->allowsPublicAccess;
}
}

View File

@@ -30,7 +30,6 @@ final class StoreFormSchemaRequest extends FormRequest
'name' => ['required', 'string', 'max:150'],
'slug' => ['nullable', 'string', 'max:150'],
'purpose' => ['required', Rule::in(FormPurpose::values())],
'custom_purpose_slug' => ['nullable', 'string', 'max:150', 'required_if:purpose,custom'],
'description' => ['nullable', 'string'],
'is_published' => ['boolean'],
'submission_mode' => ['nullable', Rule::in(FormSubmissionMode::cases() ? array_map(fn ($c) => $c->value, FormSubmissionMode::cases()) : [])],

View File

@@ -26,7 +26,6 @@ final class UpdateFormSchemaRequest extends FormRequest
'name' => ['sometimes', 'string', 'max:150'],
'slug' => ['sometimes', 'string', 'max:150'],
'purpose' => ['sometimes', Rule::in(FormPurpose::values())],
'custom_purpose_slug' => ['nullable', 'string', 'max:150'],
'description' => ['nullable', 'string'],
'is_published' => ['sometimes', 'boolean'],
'submission_mode' => ['sometimes', Rule::in(array_map(fn ($c) => $c->value, FormSubmissionMode::cases()))],

View File

@@ -40,7 +40,6 @@ final class FormSchemaResource extends JsonResource
'name' => $this->name,
'slug' => $this->slug,
'purpose' => $this->purpose instanceof \BackedEnum ? $this->purpose->value : $this->purpose,
'custom_purpose_slug' => $this->custom_purpose_slug,
'description' => $this->description,
'is_published' => (bool) $this->is_published,
'submission_mode' => $this->submission_mode instanceof \BackedEnum ? $this->submission_mode->value : $this->submission_mode,

View File

@@ -42,7 +42,6 @@ final class FormSchema extends Model
'name',
'slug',
'purpose',
'custom_purpose_slug',
'description',
'is_published',
'submission_mode',