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:
@@ -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)
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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()) : [])],
|
||||
|
||||
@@ -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()))],
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -42,7 +42,6 @@ final class FormSchema extends Model
|
||||
'name',
|
||||
'slug',
|
||||
'purpose',
|
||||
'custom_purpose_slug',
|
||||
'description',
|
||||
'is_published',
|
||||
'submission_mode',
|
||||
|
||||
@@ -6,7 +6,6 @@ namespace Database\Factories\FormBuilder;
|
||||
|
||||
use App\Enums\FormBuilder\FormPurpose;
|
||||
use App\Enums\FormBuilder\FormSchemaSnapshotMode;
|
||||
use App\Enums\FormBuilder\FormSubmissionMode;
|
||||
use App\Models\FormBuilder\FormSchema;
|
||||
use App\Models\Organisation;
|
||||
use Illuminate\Database\Eloquent\Factories\Factory;
|
||||
@@ -22,9 +21,9 @@ final class FormSchemaFactory extends Factory
|
||||
{
|
||||
$purpose = fake()->randomElement([
|
||||
FormPurpose::EVENT_REGISTRATION,
|
||||
FormPurpose::FEEDBACK,
|
||||
FormPurpose::INCIDENT_REPORT,
|
||||
FormPurpose::USER_PROFILE,
|
||||
FormPurpose::POST_EVENT_EVALUATION,
|
||||
]);
|
||||
$name = 'Formulier '.fake('nl_NL')->words(2, true);
|
||||
|
||||
@@ -35,7 +34,6 @@ final class FormSchemaFactory extends Factory
|
||||
'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(),
|
||||
@@ -48,15 +46,6 @@ final class FormSchemaFactory extends Factory
|
||||
];
|
||||
}
|
||||
|
||||
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]);
|
||||
@@ -67,7 +56,6 @@ final class FormSchemaFactory extends Factory
|
||||
return $this->state(fn () => [
|
||||
'purpose' => $purpose,
|
||||
'submission_mode' => $purpose->defaultSubmissionMode(),
|
||||
'custom_purpose_slug' => $purpose === FormPurpose::CUSTOM ? 'custom-'.Str::lower(Str::random(6)) : null,
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,95 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
/**
|
||||
* WS-2 — Purge form_schemas rows whose `purpose` is no longer in the
|
||||
* v1.0 PurposeRegistry (7-slug vocabulary) OR whose `purpose = 'custom'`.
|
||||
*
|
||||
* Child tables cascade via their existing FK constraints (cascadeOnDelete)
|
||||
* on form_schemas/form_submissions.
|
||||
*
|
||||
* `down()` is intentionally a hard failure — we do not support reversing
|
||||
* a purge migration (pre-launch, no production data).
|
||||
*/
|
||||
return new class extends Migration
|
||||
{
|
||||
/** @var list<string> */
|
||||
private const VALID_PURPOSES = [
|
||||
'event_registration',
|
||||
'artist_advance',
|
||||
'supplier_intake',
|
||||
'post_event_evaluation',
|
||||
'incident_report',
|
||||
'signature_contract',
|
||||
'user_profile',
|
||||
];
|
||||
|
||||
public function up(): void
|
||||
{
|
||||
$invalidPurposes = DB::table('form_schemas')
|
||||
->whereNotIn('purpose', self::VALID_PURPOSES)
|
||||
->pluck('purpose')
|
||||
->unique()
|
||||
->values()
|
||||
->all();
|
||||
|
||||
if ($invalidPurposes === []) {
|
||||
echo "[purge_invalid_form_purposes] no schemas with invalid purposes — nothing to delete.\n";
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$schemaIds = DB::table('form_schemas')
|
||||
->whereNotIn('purpose', self::VALID_PURPOSES)
|
||||
->pluck('id')
|
||||
->all();
|
||||
|
||||
$submissionIds = DB::table('form_submissions')
|
||||
->whereIn('form_schema_id', $schemaIds)
|
||||
->pluck('id')
|
||||
->all();
|
||||
|
||||
$counts = [
|
||||
'form_schemas' => count($schemaIds),
|
||||
'form_submissions' => count($submissionIds),
|
||||
'form_fields' => DB::table('form_fields')->whereIn('form_schema_id', $schemaIds)->count(),
|
||||
'form_schema_sections' => DB::table('form_schema_sections')->whereIn('form_schema_id', $schemaIds)->count(),
|
||||
'form_values' => $submissionIds === [] ? 0
|
||||
: DB::table('form_values')->whereIn('form_submission_id', $submissionIds)->count(),
|
||||
'form_value_options' => $submissionIds === [] ? 0
|
||||
: DB::table('form_value_options')
|
||||
->whereIn('form_value_id', DB::table('form_values')->whereIn('form_submission_id', $submissionIds)->select('id'))
|
||||
->count(),
|
||||
'form_submission_section_statuses' => $submissionIds === [] ? 0
|
||||
: DB::table('form_submission_section_statuses')->whereIn('form_submission_id', $submissionIds)->count(),
|
||||
'form_submission_delegations' => $submissionIds === [] ? 0
|
||||
: DB::table('form_submission_delegations')->whereIn('form_submission_id', $submissionIds)->count(),
|
||||
'form_schema_webhooks' => DB::table('form_schema_webhooks')->whereIn('form_schema_id', $schemaIds)->count(),
|
||||
'form_webhook_deliveries' => $submissionIds === [] ? 0
|
||||
: DB::table('form_webhook_deliveries')->whereIn('form_submission_id', $submissionIds)->count(),
|
||||
];
|
||||
|
||||
echo sprintf(
|
||||
"[purge_invalid_form_purposes] purging schemas with invalid purposes: %s\n",
|
||||
implode(', ', $invalidPurposes),
|
||||
);
|
||||
foreach ($counts as $table => $n) {
|
||||
echo " {$table}: {$n}\n";
|
||||
}
|
||||
|
||||
// FK cascade does the rest — delete only the parent rows.
|
||||
DB::table('form_schemas')->whereIn('id', $schemaIds)->delete();
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
throw new \RuntimeException(
|
||||
'purge_invalid_form_purposes cannot be reversed — this migration '
|
||||
.'deletes rows permanently. Restore from backup or run migrate:fresh.'
|
||||
);
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,38 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
/**
|
||||
* WS-2 — Drop the `custom_purpose_slug` escape from `form_schemas`.
|
||||
*
|
||||
* v1.0 Purpose registry replaces the `custom` purpose + per-organisation
|
||||
* slug construct with a fixed set of seven purposes. See
|
||||
* ARCH-CONSOLIDATION-2026-04.md §3 besluit 4 and ARCH-FORM-BUILDER.md
|
||||
* §17.3 (Purpose registry).
|
||||
*
|
||||
* `down()` is intentionally a hard failure — we do not support reversing
|
||||
* this migration (pre-launch, no production data).
|
||||
*/
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
Schema::table('form_schemas', function (Blueprint $table): void {
|
||||
$table->dropIndex('fs_custom_purpose_slug_idx');
|
||||
$table->dropColumn('custom_purpose_slug');
|
||||
});
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
throw new \RuntimeException(
|
||||
'drop_custom_purpose_slug_from_form_schemas cannot be reversed — '
|
||||
.'the custom purpose escape is retired in v1.0. Restore from '
|
||||
.'backup or run migrate:fresh.'
|
||||
);
|
||||
}
|
||||
};
|
||||
Reference in New Issue
Block a user