From 72892d38f4e2d41f654fb79e4426d2fcae4837ec Mon Sep 17 00:00:00 2001 From: "bert.hausmans" Date: Fri, 17 Apr 2026 13:18:42 +0200 Subject: [PATCH] feat(forms): add data migration and verification commands MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit forms:migrate-legacy-data {--dry-run} {--verify-only} Per-org transaction (outer loop); inside each org, one form_schema per distinct event_id in registration_form_fields, one form_field per legacy field (with lowercase→uppercase field_type mapping and PII heuristic), one form_submission per distinct person_field_values author, one form_value per legacy row. form_templates derive schema_snapshot in ARCH §4.6.1 shape. Idempotent via existence checks; skips if registration_form_fields absent. Wrapped in App\Support\ActivityLog::suppressed() so --dry-run and re-runs don't storm the activity log. forms:verify-data-integrity {--strict} Nine coherence checks: schemas/fields/submissions/values/user_profiles structure, data migration counts (skipped when legacy tables absent), orphans, section/schema relation consistency, and strict reachability (opt-in). Runs all checks to completion; exit 1 on any failure. Validates binding JSON against config/form_binding.php registry and field_type against FormFieldType::values() ∪ custom_field_types config. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../Commands/MigrateLegacyFormsData.php | 472 ++++++++++++++++++ .../Commands/VerifyFormsDataIntegrity.php | 407 +++++++++++++++ 2 files changed, 879 insertions(+) create mode 100644 api/app/Console/Commands/MigrateLegacyFormsData.php create mode 100644 api/app/Console/Commands/VerifyFormsDataIntegrity.php diff --git a/api/app/Console/Commands/MigrateLegacyFormsData.php b/api/app/Console/Commands/MigrateLegacyFormsData.php new file mode 100644 index 00000000..cab7048e --- /dev/null +++ b/api/app/Console/Commands/MigrateLegacyFormsData.php @@ -0,0 +1,472 @@ + '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 new file mode 100644 index 00000000..6eddb4f1 --- /dev/null +++ b/api/app/Console/Commands/VerifyFormsDataIntegrity.php @@ -0,0 +1,407 @@ +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(); + + $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') + ->groupBy('public_token') + ->havingRaw('COUNT(*) > 1') + ->count(); + + if ($invalidPurpose > 0 || $customMismatch > 0 || $publicTokenDupes > 0) { + $this->recordFailure('Schema coherence', + "{$invalidPurpose} invalid purpose, {$customMismatch} custom_purpose_slug mismatches, {$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 + $binding = (array) config('form_binding', []); + $badBindings = 0; + $invalidBindings = DB::table('form_fields')->whereNotNull('binding')->select('binding')->get(); + foreach ($invalidBindings as $row) { + $b = is_string($row->binding) ? json_decode($row->binding, true) : null; + if (! is_array($b) || ! isset($b['mode'], $b['entity'], $b['column'])) { + $badBindings++; + + continue; + } + if (! in_array($b['mode'], ['entity_owned', 'mirrored'], true)) { + $badBindings++; + + continue; + } + if (! isset($binding[$b['entity']][$b['column']])) { + $badBindings++; + + continue; + } + if (($binding[$b['entity']][$b['column']]['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 = array_keys((array) config('form_subjects', [])); + $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) { + $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" + ); + + return; + } + + // Warning only — doesn't fail the check. + if ($nonFilterableIndexed > 0) { + $this->warn(" [WARN] {$nonFilterableIndexed} form_values have value_indexed set for a non-filterable field"); + } + + $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'); + } +}