From e17fc7c2f4f92def45b72e9ac39473d937d340e4 Mon Sep 17 00:00:00 2001 From: "bert.hausmans" Date: Sat, 25 Apr 2026 01:58:19 +0200 Subject: [PATCH] chore: remove dead legacy form-data migration commands MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit MigrateLegacyFormsData and VerifyFormsDataIntegrity exist to migrate the pre-form-builder registration_form_fields / registration_field_templates / person_field_values tables into the current form_* tables. Those legacy tables have been dropped from the dev database (verified via the 2026_04_20 drop_remaining_legacy_registration_tables migration), which means the migrator's top-of-handle() guard always short-circuits the run. The verify command is only reachable via MigrateLegacyFormsData::verify() — also dead with its caller gone. CLAUDE.md delete > adapt. These commands would also break the WS-5d commit 5 column drop: MigrateLegacyFormsData:225 writes \$rff->options straight to form_fields.options, which will not exist after commit 5. Cleaning up before WS-5d starts keeps the dev tree consistent throughout the refactor. The two stale comment references in FormBuilderDevSeeder (header docblock and seedSubmissionsForEvent docblock) plus the migration docblock that mentions the migrator self-skip behaviour are scrubbed in the same commit so no orphan references remain. No production data exists; no migration safety net is being removed. Tests: 1158 → 1158 green (no test coverage existed for these commands; they were truly orphaned). Co-Authored-By: Claude Opus 4.7 (1M context) --- .../Commands/MigrateLegacyFormsData.php | 472 ------------------ .../Commands/VerifyFormsDataIntegrity.php | 388 -------------- ...p_remaining_legacy_registration_tables.php | 5 +- api/database/seeders/FormBuilderDevSeeder.php | 3 +- 4 files changed, 2 insertions(+), 866 deletions(-) delete mode 100644 api/app/Console/Commands/MigrateLegacyFormsData.php delete mode 100644 api/app/Console/Commands/VerifyFormsDataIntegrity.php diff --git a/api/app/Console/Commands/MigrateLegacyFormsData.php b/api/app/Console/Commands/MigrateLegacyFormsData.php deleted file mode 100644 index cab7048e..00000000 --- a/api/app/Console/Commands/MigrateLegacyFormsData.php +++ /dev/null @@ -1,472 +0,0 @@ - 'TEXT', - 'textarea' => 'TEXTAREA', - 'select' => 'SELECT', - 'multiselect' => 'MULTISELECT', - 'checkbox' => 'CHECKBOX_LIST', - 'radio' => 'RADIO', - 'boolean' => 'BOOLEAN', - 'number' => 'NUMBER', - 'tag_picker' => 'TAG_PICKER', - 'heading' => 'HEADING', - ]; - - private const PII_SLUG_PATTERNS = [ - 'email', 'phone', 'telefoon', 'adres', 'address', - 'emergency_contact', 'noodcontact', 'noodnummer', - 'geboort', 'birthdate', 'birth_date', 'dob', - 'allergie', 'allergy', 'medisch', 'medical', - 'dieet', 'diet', - 'toegangs', 'access', - 'bsn', 'social_security', - 'iban', 'bank', - ]; - - private const PII_FIELD_TYPES = ['EMAIL', 'PHONE']; - - public function handle(): int - { - $dryRun = (bool) $this->option('dry-run'); - $verifyOnly = (bool) $this->option('verify-only'); - - if (! Schema::hasTable('registration_form_fields')) { - $this->info('No legacy data to migrate (registration_form_fields table absent). Skipping.'); - - return self::SUCCESS; - } - - if ($verifyOnly) { - return $this->verify(); - } - - return ActivityLog::suppressed(function () use ($dryRun): int { - $this->info($dryRun ? 'DRY RUN — no data will be written.' : 'Starting legacy data migration…'); - - $orgIds = DB::table('registration_form_fields') - ->join('events', 'registration_form_fields.event_id', '=', 'events.id') - ->distinct() - ->pluck('events.organisation_id'); - - $totals = ['schemas' => 0, 'fields' => 0, 'submissions' => 0, 'values' => 0, 'templates' => 0]; - - foreach ($orgIds as $orgId) { - $orgName = DB::table('organisations')->where('id', $orgId)->value('name') ?? $orgId; - $this->info("Migrating organisation {$orgName}…"); - - if ($dryRun) { - $this->runForOrganisation($orgId, $totals, true); - - continue; - } - - DB::transaction(function () use ($orgId, &$totals): void { - $this->runForOrganisation($orgId, $totals, false); - }); - } - - // Templates are org-scoped but not per-event. - foreach (DB::table('registration_field_templates')->select('organisation_id')->distinct()->pluck('organisation_id') as $orgId) { - if ($dryRun) { - $this->migrateTemplatesForOrg($orgId, $totals, true); - - continue; - } - DB::transaction(function () use ($orgId, &$totals): void { - $this->migrateTemplatesForOrg($orgId, $totals, false); - }); - } - - $this->newLine(); - $this->info('Summary:'); - $this->table( - ['What', 'Count'], - [ - ['form_schemas', $totals['schemas']], - ['form_fields', $totals['fields']], - ['form_submissions', $totals['submissions']], - ['form_values', $totals['values']], - ['form_templates', $totals['templates']], - ] - ); - - if ($dryRun) { - return self::SUCCESS; - } - - $this->newLine(); - $this->info('Running verification…'); - - return $this->verify(); - }); - } - - /** - * @param array $totals - */ - private function runForOrganisation(string $orgId, array &$totals, bool $dryRun): void - { - $eventIds = DB::table('registration_form_fields') - ->join('events', 'registration_form_fields.event_id', '=', 'events.id') - ->where('events.organisation_id', $orgId) - ->distinct() - ->pluck('registration_form_fields.event_id'); - - foreach ($eventIds as $eventId) { - $event = DB::table('events')->where('id', $eventId)->first(); - if ($event === null) { - continue; - } - - $existingSchema = DB::table('form_schemas') - ->where('organisation_id', $orgId) - ->where('owner_type', 'event') - ->where('owner_id', $eventId) - ->where('purpose', FormPurpose::EVENT_REGISTRATION->value) - ->first(); - - if ($existingSchema !== null) { - $this->line(" Event '{$event->name}': schema already exists, skipping (idempotent)."); - - continue; - } - - $isPublished = in_array( - $event->status, - ['registration_open', 'buildup', 'showday', 'teardown', 'closed'], - true - ); - - $createdByUserId = DB::table('event_user_roles') - ->where('event_id', $eventId) - ->where('role', 'admin') - ->value('user_id'); - - $schemaId = (string) Str::ulid(); - $schemaSlug = $this->dedupeSlug( - $orgId, - Str::slug(($event->slug ?? $event->name).'-registratie') - ); - - if (! $dryRun) { - DB::table('form_schemas')->insert([ - 'id' => $schemaId, - 'organisation_id' => $orgId, - 'owner_type' => 'event', - 'owner_id' => $eventId, - 'name' => $event->name.' registratie', - 'slug' => $schemaSlug, - 'purpose' => FormPurpose::EVENT_REGISTRATION->value, - 'description' => null, - 'is_published' => $isPublished, - 'submission_mode' => FormSubmissionMode::DRAFT_SINGLE->value, - 'locale' => 'nl', - 'version' => 1, - 'snapshot_mode' => FormSchemaSnapshotMode::NEVER->value, - 'freeze_on_submit' => false, - 'section_level_submit' => false, - 'auto_save_enabled' => false, - 'created_by_user_id' => $createdByUserId, - 'created_at' => now(), - 'updated_at' => now(), - ]); - } - - $totals['schemas']++; - - // Fields — preserve legacy id → new id mapping for form_values. - $fieldMap = []; - $fieldCount = 0; - foreach (DB::table('registration_form_fields')->where('event_id', $eventId)->orderBy('sort_order')->get() as $rff) { - $newFieldType = self::FIELD_TYPE_MAP[$rff->field_type] ?? 'TEXT'; - $hint = $this->hintFor($newFieldType); - $isPii = $this->isPii($newFieldType, $rff->slug); - - $newFieldId = (string) Str::ulid(); - $fieldMap[$rff->id] = ['id' => $newFieldId, 'type' => $newFieldType, 'hint' => $hint]; - - if (! $dryRun) { - DB::table('form_fields')->insert([ - 'id' => $newFieldId, - 'form_schema_id' => $schemaId, - 'field_type' => $newFieldType, - 'slug' => $rff->slug, - 'label' => $rff->label, - 'help_text' => $rff->help_text, - 'options' => $rff->options, - 'is_required' => (bool) $rff->is_required, - 'is_filterable' => (bool) $rff->is_filterable, - 'is_portal_visible' => (bool) $rff->is_portal_visible, - 'is_admin_only' => (bool) $rff->is_admin_only, - 'is_unique' => false, - 'is_pii' => $isPii, - 'display_width' => $rff->display_width ?: 'full', - 'value_storage_hint' => $hint, - 'review_required' => false, - 'sort_order' => (int) $rff->sort_order, - 'created_at' => $rff->created_at ?: now(), - 'updated_at' => $rff->updated_at ?: now(), - ]); - } - $fieldCount++; - $totals['fields']++; - } - - // Submissions: one per distinct person who has field values for this event. - $personIds = DB::table('person_field_values') - ->join('registration_form_fields', 'person_field_values.registration_form_field_id', '=', 'registration_form_fields.id') - ->where('registration_form_fields.event_id', $eventId) - ->distinct() - ->pluck('person_field_values.person_id'); - - $submissionCount = 0; - foreach ($personIds as $personId) { - $person = DB::table('persons')->where('id', $personId)->first(); - if ($person === null) { - continue; - } - $submissionStatus = in_array( - $person->status, - ['applied', 'approved', 'no_show'], - true - ) - ? FormSubmissionStatus::SUBMITTED - : FormSubmissionStatus::DRAFT; - - $firstValueCreatedAt = DB::table('person_field_values') - ->join('registration_form_fields', 'person_field_values.registration_form_field_id', '=', 'registration_form_fields.id') - ->where('person_field_values.person_id', $personId) - ->where('registration_form_fields.event_id', $eventId) - ->min('person_field_values.id'); // id ordering matches creation on AI PKs - - $submissionId = (string) Str::ulid(); - $submittedAt = $submissionStatus === FormSubmissionStatus::SUBMITTED - ? ($firstValueCreatedAt ? now() : now()) - : null; - - if (! $dryRun) { - DB::table('form_submissions')->insert([ - 'id' => $submissionId, - 'form_schema_id' => $schemaId, - 'subject_type' => 'person', - 'subject_id' => $personId, - 'submitted_by_user_id' => $person->user_id ?? null, - 'status' => $submissionStatus->value, - 'is_test' => false, - 'submitted_in_locale' => 'nl', - 'submitted_at' => $submittedAt, - 'schema_version_at_submit' => $submissionStatus === FormSubmissionStatus::SUBMITTED ? 1 : null, - 'auto_save_count' => 0, - 'created_at' => now(), - 'updated_at' => now(), - ]); - } - - // Values for this person. - $pfvRows = DB::table('person_field_values') - ->join('registration_form_fields', 'person_field_values.registration_form_field_id', '=', 'registration_form_fields.id') - ->where('person_field_values.person_id', $personId) - ->where('registration_form_fields.event_id', $eventId) - ->select([ - 'person_field_values.id', - 'person_field_values.registration_form_field_id', - 'person_field_values.value', - 'person_field_values.selected_options', - ]) - ->get(); - - $searchParts = []; - foreach ($pfvRows as $pfv) { - $map = $fieldMap[$pfv->registration_form_field_id] ?? null; - if ($map === null) { - continue; - } - - $valueJson = $this->wrapValue($map['type'], $pfv->value, $pfv->selected_options); - - if (! $dryRun) { - $fv = FormValue::create([ - 'form_submission_id' => $submissionId, - 'form_field_id' => $map['id'], - 'value' => $valueJson, - ]); - unset($fv); - } - - if (in_array($map['type'], ['TEXT', 'TEXTAREA', 'EMAIL', 'URL'], true) && is_string($pfv->value)) { - $searchParts[] = $pfv->value; - } - - $totals['values']++; - } - - if (! $dryRun && $searchParts !== []) { - DB::table('form_submissions') - ->where('id', $submissionId) - ->update(['search_index' => Str::limit(implode(' ', $searchParts), 10000, '')]); - } - - $submissionCount++; - $totals['submissions']++; - } - - $this->line(" Event '{$event->name}': {$fieldCount} fields → 1 schema, {$submissionCount} submissions"); - } - } - - /** - * @param array $totals - */ - private function migrateTemplatesForOrg(string $orgId, array &$totals, bool $dryRun): void - { - $templates = DB::table('registration_field_templates')->where('organisation_id', $orgId)->get(); - foreach ($templates as $tpl) { - $existing = DB::table('form_templates') - ->where('organisation_id', $orgId) - ->where('slug', $tpl->slug) - ->exists(); - if ($existing) { - continue; - } - - $newType = self::FIELD_TYPE_MAP[$tpl->field_type] ?? 'TEXT'; - $snapshot = [ - 'schema_version' => 1, - 'snapshot_created_at' => now()->toIso8601String(), - 'schema' => [ - 'name' => $tpl->label.' (template)', - 'slug' => $tpl->slug, - 'purpose' => FormPurpose::EVENT_REGISTRATION->value, - 'description' => null, - 'locale' => 'nl', - 'freeze_on_submit' => false, - 'section_level_submit' => false, - 'settings' => [], - ], - 'sections' => [], - 'fields' => [[ - 'id' => $tpl->id, - 'slug' => $tpl->slug, - 'field_type' => $newType, - 'label' => $tpl->label, - 'help_text' => $tpl->help_text, - 'section_slug' => null, - 'options' => $tpl->options ? json_decode($tpl->options, true) : null, - 'validation_rules' => null, - 'is_required' => (bool) $tpl->is_required, - 'is_filterable' => (bool) $tpl->is_filterable, - 'is_pii' => $this->isPii($newType, $tpl->slug), - 'binding' => null, - 'conditional_logic' => null, - 'translations' => null, - 'value_storage_hint' => $this->hintFor($newType), - 'sort_order' => (int) $tpl->sort_order, - ]], - ]; - - if (! $dryRun) { - DB::table('form_templates')->insert([ - 'id' => (string) Str::ulid(), - 'organisation_id' => $orgId, - 'name' => $tpl->label, - 'slug' => $tpl->slug, - 'purpose' => FormPurpose::EVENT_REGISTRATION->value, - 'description' => null, - 'schema_snapshot' => json_encode($snapshot, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES), - 'is_system' => (bool) $tpl->is_system, - 'is_active' => (bool) $tpl->is_active, - 'created_at' => $tpl->created_at ?: now(), - 'updated_at' => $tpl->updated_at ?: now(), - ]); - } - - $totals['templates']++; - } - } - - private function hintFor(string $fieldTypeValue): string - { - $case = FormFieldType::tryFrom($fieldTypeValue); - - return ($case?->recommendedValueStorageHint() ?? FormValueStorageHint::JSON)->value; - } - - private function isPii(string $fieldType, string $slug): bool - { - if (in_array($fieldType, self::PII_FIELD_TYPES, true)) { - return true; - } - $lower = strtolower($slug); - foreach (self::PII_SLUG_PATTERNS as $pattern) { - if (str_contains($lower, $pattern)) { - return true; - } - } - - return false; - } - - /** - * Build the canonical JSON-wrapped payload for a form_values row. - */ - private function wrapValue(string $fieldType, ?string $rawValue, ?string $selectedOptionsJson): array - { - if (in_array($fieldType, ['MULTISELECT', 'CHECKBOX_LIST', 'TAG_PICKER'], true)) { - if ($selectedOptionsJson !== null && $selectedOptionsJson !== '') { - $decoded = json_decode($selectedOptionsJson, true); - if (is_array($decoded)) { - return $decoded; - } - } - - return []; - } - - return ['value' => $rawValue]; - } - - private function dedupeSlug(string $orgId, string $base): string - { - $slug = $base; - $i = 1; - while (DB::table('form_schemas')->where('organisation_id', $orgId)->where('slug', $slug)->exists()) { - $slug = $base.'-'.(++$i); - } - - return $slug; - } - - private function verify(): int - { - return $this->call('forms:verify-data-integrity'); - } -} diff --git a/api/app/Console/Commands/VerifyFormsDataIntegrity.php b/api/app/Console/Commands/VerifyFormsDataIntegrity.php deleted file mode 100644 index 6a540078..00000000 --- a/api/app/Console/Commands/VerifyFormsDataIntegrity.php +++ /dev/null @@ -1,388 +0,0 @@ -checkSchemaCoherence(); - $this->checkFieldCoherence(); - $this->checkSubmissionCoherence(); - $this->checkValueCoherence(); - $this->checkUserProfileCoherence(); - $this->checkDataMigrationCounts(); - $this->checkOrphans(); - $this->checkRelationConsistency(); - - if ((bool) $this->option('strict')) { - $this->checkStrictReachability(); - } - - return $this->hasFailure ? self::FAILURE : self::SUCCESS; - } - - private function recordPass(string $check, string $note = ''): void - { - $this->info("[PASS] {$check}".($note !== '' ? ": {$note}" : '')); - } - - private function recordFailure(string $check, string $detail, string $fix = ''): void - { - $this->hasFailure = true; - $this->error("[FAIL] {$check}: {$detail}"); - if ($fix !== '') { - $this->line(" → Suggested fix: {$fix}"); - } - } - - // ---- 1. schemas ---- - private function checkSchemaCoherence(): void - { - $total = DB::table('form_schemas')->count(); - $purposeValues = FormPurpose::values(); - - $invalidPurpose = DB::table('form_schemas') - ->whereNotIn('purpose', $purposeValues) - ->count(); - - $publicTokenDupes = DB::table('form_schemas') - ->whereNotNull('public_token') - ->select('public_token') - ->groupBy('public_token') - ->havingRaw('COUNT(*) > 1') - ->count(); - - if ($invalidPurpose > 0 || $publicTokenDupes > 0) { - $this->recordFailure('Schema coherence', - "{$invalidPurpose} invalid purpose, {$publicTokenDupes} duplicate public_tokens" - ); - - return; - } - - $this->recordPass('Schema coherence', "{$total} schemas verified"); - } - - // ---- 2. fields ---- - private function checkFieldCoherence(): void - { - $total = DB::table('form_fields')->count(); - - $orphanSchema = DB::table('form_fields') - ->leftJoin('form_schemas', 'form_fields.form_schema_id', '=', 'form_schemas.id') - ->whereNull('form_schemas.id') - ->count(); - - $builtInTypes = FormFieldType::values(); - $customTypes = array_keys((array) config('form_builder.custom_field_types', [])); - $validTypes = array_merge($builtInTypes, $customTypes); - - $invalidType = DB::table('form_fields') - ->whereNotIn('field_type', $validTypes) - ->count(); - - $nonFilterableMarkedFilterable = DB::table('form_fields') - ->where('is_filterable', true) - ->whereIn('field_type', [ - FormFieldType::TEXTAREA->value, - FormFieldType::FILE_UPLOAD->value, - FormFieldType::IMAGE_UPLOAD->value, - FormFieldType::SIGNATURE->value, - FormFieldType::HEADING->value, - FormFieldType::PARAGRAPH->value, - FormFieldType::TABLE_ROWS->value, - FormFieldType::SECTION_PRIORITY->value, - FormFieldType::AVAILABILITY_PICKER->value, - ]) - ->count(); - - // slug uniqueness within schema (non-deleted) - $dupSlugs = DB::table('form_fields') - ->whereNull('deleted_at') - ->select('form_schema_id', 'slug') - ->groupBy('form_schema_id', 'slug') - ->havingRaw('COUNT(*) > 1') - ->count(); - - // Binding registry cross-check (WS-5a: relational form_field_bindings). - $registry = (array) config('form_binding', []); - $badBindings = 0; - $bindingRows = DB::table('form_field_bindings') - ->select(['mode', 'target_entity', 'target_attribute']) - ->get(); - foreach ($bindingRows as $row) { - if (! in_array($row->mode, ['entity_owned', 'mirrored'], true)) { - $badBindings++; - - continue; - } - if (! isset($registry[$row->target_entity][$row->target_attribute])) { - $badBindings++; - - continue; - } - if (($registry[$row->target_entity][$row->target_attribute]['writable'] ?? false) !== true) { - $badBindings++; - } - } - - if ($orphanSchema > 0 || $invalidType > 0 || $nonFilterableMarkedFilterable > 0 || $dupSlugs > 0 || $badBindings > 0) { - $this->recordFailure('Field coherence', - "{$orphanSchema} orphan schema, {$invalidType} invalid field_type, {$nonFilterableMarkedFilterable} unfilterable-but-marked, {$dupSlugs} duplicate slugs, {$badBindings} invalid bindings" - ); - - return; - } - - $this->recordPass('Field coherence', "{$total} fields verified"); - } - - // ---- 3. submissions ---- - private function checkSubmissionCoherence(): void - { - $total = DB::table('form_submissions')->count(); - - $subjectMismatch = DB::table('form_submissions') - ->where(function ($q): void { - $q->whereNotNull('subject_type')->whereNull('subject_id'); - }) - ->orWhere(function ($q): void { - $q->whereNull('subject_type')->whereNotNull('subject_id'); - }) - ->count(); - - $subjectTypes = app(PurposeRegistry::class)->allSubjectTypes(); - $invalidSubjectType = DB::table('form_submissions') - ->whereNotNull('subject_type') - ->whereNotIn('subject_type', $subjectTypes) - ->count(); - - $submittedWithoutTs = DB::table('form_submissions') - ->where('status', 'submitted') - ->whereNull('submitted_at') - ->count(); - - $draftWithTs = DB::table('form_submissions') - ->where('status', 'draft') - ->whereNotNull('submitted_at') - ->count(); - - if ($subjectMismatch > 0 || $invalidSubjectType > 0 || $submittedWithoutTs > 0 || $draftWithTs > 0) { - $this->recordFailure('Submission coherence', - "{$subjectMismatch} subject_type/id mismatch, {$invalidSubjectType} invalid subject_type, {$submittedWithoutTs} submitted without submitted_at, {$draftWithTs} draft with submitted_at" - ); - - return; - } - - $this->recordPass('Submission coherence', "{$total} submissions verified"); - } - - // ---- 4. values ---- - private function checkValueCoherence(): void - { - $total = DB::table('form_values')->count(); - - $orphanSub = DB::table('form_values') - ->leftJoin('form_submissions', 'form_values.form_submission_id', '=', 'form_submissions.id') - ->whereNull('form_submissions.id') - ->count(); - - $orphanField = DB::table('form_values') - ->leftJoin('form_fields', 'form_values.form_field_id', '=', 'form_fields.id') - ->whereNull('form_fields.id') - ->count(); - - $dup = DB::table('form_values') - ->select('form_submission_id', 'form_field_id') - ->groupBy('form_submission_id', 'form_field_id') - ->havingRaw('COUNT(*) > 1') - ->count(); - - $longIndexed = DB::table('form_values') - ->whereNotNull('value_indexed') - ->whereRaw('CHAR_LENGTH(value_indexed) > 255') - ->count(); - - $nonFilterableIndexed = DB::table('form_values') - ->join('form_fields', 'form_values.form_field_id', '=', 'form_fields.id') - ->whereNotNull('form_values.value_indexed') - ->where('form_fields.is_filterable', false) - ->count(); - - $multiValueIndexed = DB::table('form_values') - ->join('form_fields', 'form_values.form_field_id', '=', 'form_fields.id') - ->whereNotNull('form_values.value_indexed') - ->whereIn('form_fields.field_type', [ - FormFieldType::MULTISELECT->value, - FormFieldType::CHECKBOX_LIST->value, - FormFieldType::TAG_PICKER->value, - ]) - ->count(); - - if ($orphanSub > 0 || $orphanField > 0 || $dup > 0 || $longIndexed > 0 || $multiValueIndexed > 0 || $nonFilterableIndexed > 0) { - $this->recordFailure('Value coherence', - "{$orphanSub} orphan submission, {$orphanField} orphan field, {$dup} duplicate pairs, {$longIndexed} over-length value_indexed, {$multiValueIndexed} multi-value rows with value_indexed set, {$nonFilterableIndexed} value_indexed set on non-filterable field", - 'observer should only populate value_indexed when field.is_filterable=true — re-save affected rows to let FormValueObserver reconcile' - ); - - return; - } - - $this->recordPass('Value coherence', "{$total} values verified"); - } - - // ---- 5. user profiles ---- - private function checkUserProfileCoherence(): void - { - $userCount = DB::table('users')->whereNull('deleted_at')->count(); - $profilesCount = DB::table('user_profiles')->count(); - - $missing = DB::table('users') - ->whereNull('deleted_at') - ->whereNotIn('id', DB::table('user_profiles')->select('user_id')) - ->count(); - - $orphans = DB::table('user_profiles') - ->whereNotIn('user_id', DB::table('users')->select('id')) - ->count(); - - $outOfRange = DB::table('user_profiles') - ->where(function ($q): void { - $q->where('reliability_score', '<', 0.00) - ->orWhere('reliability_score', '>', 5.00); - }) - ->count(); - - if ($missing > 0 || $orphans > 0 || $outOfRange > 0) { - $this->recordFailure('User profile coherence', - "{$missing} users without profile, {$orphans} orphan profiles, {$outOfRange} score out of [0,5]" - ); - - return; - } - - $this->recordPass('User profile coherence', "{$profilesCount} profiles (users: {$userCount})"); - } - - // ---- 6. data migration counts (only if legacy tables present) ---- - private function checkDataMigrationCounts(): void - { - if (! Schema::hasTable('registration_form_fields')) { - $this->recordPass('Data migration counts', 'legacy tables already dropped, skipping'); - - return; - } - - $legacyFields = DB::table('registration_form_fields')->count(); - $legacyValues = DB::table('person_field_values')->count(); - $legacyTemplates = DB::table('registration_field_templates')->count(); - - $newFields = DB::table('form_fields')->whereNull('deleted_at')->count(); - $newValues = DB::table('form_values')->count(); - $newTemplates = DB::table('form_templates')->count(); - - if ($legacyFields === 0 && $legacyValues === 0 && $legacyTemplates === 0) { - $this->recordPass('Data migration counts', 'legacy tables are empty, nothing to verify'); - - return; - } - - $ok = $newFields >= $legacyFields && $newValues >= $legacyValues && $newTemplates >= $legacyTemplates; - - if (! $ok) { - $this->recordFailure('Data migration counts', - "fields: legacy={$legacyFields} new={$newFields}; values: legacy={$legacyValues} new={$newValues}; templates: legacy={$legacyTemplates} new={$newTemplates}" - ); - - return; - } - - $this->recordPass('Data migration counts', "fields {$legacyFields}→{$newFields}, values {$legacyValues}→{$newValues}, templates {$legacyTemplates}→{$newTemplates}"); - } - - // ---- 7. orphans ---- - private function checkOrphans(): void - { - $orphanPivotValue = DB::table('form_value_options') - ->leftJoin('form_values', 'form_value_options.form_value_id', '=', 'form_values.id') - ->whereNull('form_values.id') - ->count(); - - $orphanSectionStatus = DB::table('form_submission_section_statuses') - ->leftJoin('form_submissions', 'form_submission_section_statuses.form_submission_id', '=', 'form_submissions.id') - ->whereNull('form_submissions.id') - ->count(); - - if ($orphanPivotValue > 0 || $orphanSectionStatus > 0) { - $this->recordFailure('Orphan records', "{$orphanPivotValue} orphan form_value_options, {$orphanSectionStatus} orphan section_statuses"); - - return; - } - - $this->recordPass('Orphan records', 'none'); - } - - // ---- 8. relation consistency ---- - private function checkRelationConsistency(): void - { - $mismatchedSection = DB::table('form_fields as ff') - ->join('form_schema_sections as fss', 'ff.form_schema_section_id', '=', 'fss.id') - ->whereColumn('ff.form_schema_id', '!=', 'fss.form_schema_id') - ->count(); - - if ($mismatchedSection > 0) { - $this->recordFailure('Relation consistency', - "{$mismatchedSection} form_fields.form_schema_section_id points at a section in a different schema" - ); - - return; - } - - $this->recordPass('Relation consistency', 'all section/schema relations aligned'); - } - - // ---- 9. strict ---- - private function checkStrictReachability(): void - { - $unreachableValues = DB::table('form_values as fv') - ->join('form_submissions as fs', 'fv.form_submission_id', '=', 'fs.id') - ->join('form_fields as ff', 'fv.form_field_id', '=', 'ff.id') - ->whereColumn('ff.form_schema_id', '!=', 'fs.form_schema_id') - ->count(); - - if ($unreachableValues > 0) { - $this->recordFailure('Strict reachability', - "{$unreachableValues} form_values where field.schema != submission.schema" - ); - - return; - } - - $this->recordPass('Strict reachability', 'all values reachable via submission.schema→field'); - } -} diff --git a/api/database/migrations/2026_04_20_100000_drop_remaining_legacy_registration_tables.php b/api/database/migrations/2026_04_20_100000_drop_remaining_legacy_registration_tables.php index 3a35c2d8..8e564d12 100644 --- a/api/database/migrations/2026_04_20_100000_drop_remaining_legacy_registration_tables.php +++ b/api/database/migrations/2026_04_20_100000_drop_remaining_legacy_registration_tables.php @@ -8,10 +8,7 @@ use Illuminate\Support\Facades\Schema; /** * S2a — drops the three remaining legacy registration tables now that every * PHP caller (controllers / services / requests / resources / policies / - * models / tests) has been purged. The forms:migrate-legacy-data command - * self-skips when these tables are absent, so environments with real - * legacy data can still run the migration command BEFORE running this - * drop migration to move their data onto the new form_* tables. + * models / tests) has been purged. * * down() is a hard failure: restoring these tables and repopulating them * from the new form_* structure would be a lossy, non-trivial conversion diff --git a/api/database/seeders/FormBuilderDevSeeder.php b/api/database/seeders/FormBuilderDevSeeder.php index 03669cb9..0287829f 100644 --- a/api/database/seeders/FormBuilderDevSeeder.php +++ b/api/database/seeders/FormBuilderDevSeeder.php @@ -29,7 +29,7 @@ use Illuminate\Support\Str; * Seeds the form-builder layer for DevSeeder: 16 system form_templates per * org, one FormSchema per event, the canonical 16-field registration set, * and a realistic batch of FormSubmissions for approved/applied/no_show - * persons (same rule as the forms:migrate-legacy-data command). + * persons. * * Callers MUST wrap this in ActivityLog::suppressed() — every field create * would otherwise fire a logFieldChange("created") entry. @@ -174,7 +174,6 @@ final class FormBuilderDevSeeder /** * For each person with status ∈ applied/approved/no_show on this event, * create one FormSubmission with a realistic handful of FormValues. - * Status follows the same rule as MigrateLegacyFormsData. */ public static function seedSubmissionsForEvent(Event $event, FormSchema $schema): int {