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>
393 lines
15 KiB
PHP
393 lines
15 KiB
PHP
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
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;
|
|
|
|
/**
|
|
* Exit codes:
|
|
* 0 — all checks passed
|
|
* 1 — at least one check failed (strict or non-strict)
|
|
*
|
|
* Checks always run to completion; the final exit code reflects the worst
|
|
* result across all checks.
|
|
*/
|
|
final class VerifyFormsDataIntegrity extends Command
|
|
{
|
|
protected $signature = 'forms:verify-data-integrity {--strict : Also run strict reachability and activity-log spot-checks}';
|
|
|
|
protected $description = 'Verify integrity of form-builder data (schemas, fields, submissions, values, user_profiles)';
|
|
|
|
private bool $hasFailure = false;
|
|
|
|
public function handle(): int
|
|
{
|
|
$this->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
|
|
$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 = 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');
|
|
}
|
|
}
|