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,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,
]);
}
}

View File

@@ -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.'
);
}
};

View File

@@ -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.'
);
}
};