diff --git a/api/app/Console/Commands/VerifyFormsDataIntegrity.php b/api/app/Console/Commands/VerifyFormsDataIntegrity.php index 543cf39e..9fde1dbe 100644 --- a/api/app/Console/Commands/VerifyFormsDataIntegrity.php +++ b/api/app/Console/Commands/VerifyFormsDataIntegrity.php @@ -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) diff --git a/api/app/Enums/FormBuilder/FormPurpose.php b/api/app/Enums/FormBuilder/FormPurpose.php index 6410947a..e2e5c644 100644 --- a/api/app/Enums/FormBuilder/FormPurpose.php +++ b/api/app/Enums/FormBuilder/FormPurpose.php @@ -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 @@ -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; } } diff --git a/api/app/Http/Requests/Api/V1/FormBuilder/StoreFormSchemaRequest.php b/api/app/Http/Requests/Api/V1/FormBuilder/StoreFormSchemaRequest.php index 578cca90..cdadec36 100644 --- a/api/app/Http/Requests/Api/V1/FormBuilder/StoreFormSchemaRequest.php +++ b/api/app/Http/Requests/Api/V1/FormBuilder/StoreFormSchemaRequest.php @@ -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()) : [])], diff --git a/api/app/Http/Requests/Api/V1/FormBuilder/UpdateFormSchemaRequest.php b/api/app/Http/Requests/Api/V1/FormBuilder/UpdateFormSchemaRequest.php index 67d26863..574033c6 100644 --- a/api/app/Http/Requests/Api/V1/FormBuilder/UpdateFormSchemaRequest.php +++ b/api/app/Http/Requests/Api/V1/FormBuilder/UpdateFormSchemaRequest.php @@ -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()))], diff --git a/api/app/Http/Resources/FormBuilder/FormSchemaResource.php b/api/app/Http/Resources/FormBuilder/FormSchemaResource.php index 03aa41b1..1dc6a2f5 100644 --- a/api/app/Http/Resources/FormBuilder/FormSchemaResource.php +++ b/api/app/Http/Resources/FormBuilder/FormSchemaResource.php @@ -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, diff --git a/api/app/Models/FormBuilder/FormSchema.php b/api/app/Models/FormBuilder/FormSchema.php index 850bb335..16aefb6c 100644 --- a/api/app/Models/FormBuilder/FormSchema.php +++ b/api/app/Models/FormBuilder/FormSchema.php @@ -42,7 +42,6 @@ final class FormSchema extends Model 'name', 'slug', 'purpose', - 'custom_purpose_slug', 'description', 'is_published', 'submission_mode', diff --git a/api/database/factories/FormBuilder/FormSchemaFactory.php b/api/database/factories/FormBuilder/FormSchemaFactory.php index 9e91e923..f04a79ff 100644 --- a/api/database/factories/FormBuilder/FormSchemaFactory.php +++ b/api/database/factories/FormBuilder/FormSchemaFactory.php @@ -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, ]); } } diff --git a/api/database/migrations/2026_04_24_100000_purge_invalid_form_purposes.php b/api/database/migrations/2026_04_24_100000_purge_invalid_form_purposes.php new file mode 100644 index 00000000..15a1dd31 --- /dev/null +++ b/api/database/migrations/2026_04_24_100000_purge_invalid_form_purposes.php @@ -0,0 +1,95 @@ + */ + 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.' + ); + } +}; diff --git a/api/database/migrations/2026_04_24_100001_drop_custom_purpose_slug_from_form_schemas.php b/api/database/migrations/2026_04_24_100001_drop_custom_purpose_slug_from_form_schemas.php new file mode 100644 index 00000000..8293a7d9 --- /dev/null +++ b/api/database/migrations/2026_04_24_100001_drop_custom_purpose_slug_from_form_schemas.php @@ -0,0 +1,38 @@ +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.' + ); + } +};