feat(forms): add core migrations (user_profiles, schemas, fields, submissions, values, webhooks)

14 fresh tables per ARCH §4 (v1.2): user_profiles + 13 form_* tables.
ULID PKs on domain rows, integer AI on heavy-join EAV tables (form_values,
form_value_options). All FKs indexed, every constraint named explicitly.
FULLTEXT on form_submissions.search_index is best-effort (wrapped try/catch
so SQLite test runs still apply).

Notes:
- Partial unique indexes on public_token/custom_purpose_slug traded for
  regular indexes + application-level uniqueness (MySQL limitation).
- (form_schema_id, slug) on form_fields is a regular index to avoid
  soft-delete + re-create collisions; uniqueness enforced in service.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-04-17 12:02:09 +02:00
parent 25de407e14
commit 6b26a90fa1
14 changed files with 658 additions and 0 deletions

View File

@@ -0,0 +1,33 @@
<?php
declare(strict_types=1);
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::create('user_profiles', function (Blueprint $table) {
$table->ulid('id')->primary();
$table->foreignUlid('user_id')->unique()->constrained()->cascadeOnDelete();
$table->text('bio')->nullable();
$table->string('photo_url')->nullable();
$table->string('emergency_contact_name')->nullable();
$table->string('emergency_contact_phone')->nullable();
$table->decimal('reliability_score', 3, 2)->default(0.00);
$table->boolean('is_ambassador')->default(false);
$table->json('settings')->nullable();
$table->timestamps();
$table->index('reliability_score', 'user_profiles_reliability_index');
});
}
public function down(): void
{
Schema::dropIfExists('user_profiles');
}
};

View File

@@ -0,0 +1,45 @@
<?php
declare(strict_types=1);
use Illuminate\Database\Migrations\Migration;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Str;
return new class extends Migration
{
public function up(): void
{
DB::table('users')->orderBy('id')->chunkById(100, function ($users): void {
$existing = DB::table('user_profiles')
->whereIn('user_id', $users->pluck('id'))
->pluck('user_id')
->all();
$rows = $users
->reject(fn ($u) => in_array($u->id, $existing, true))
->map(fn ($u) => [
'id' => (string) Str::ulid(),
'user_id' => $u->id,
'reliability_score' => 0.00,
'is_ambassador' => false,
'created_at' => now(),
'updated_at' => now(),
])
->values()
->all();
if (! empty($rows)) {
DB::table('user_profiles')->insert($rows);
}
});
}
public function down(): void
{
// Destructive: truncate all user_profiles rows created here.
// down() for this migration only makes sense together with the
// preceding create_user_profiles_table rollback.
DB::table('user_profiles')->delete();
}
};

View File

@@ -0,0 +1,74 @@
<?php
declare(strict_types=1);
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::create('form_schemas', function (Blueprint $table) {
$table->ulid('id')->primary();
$table->foreignUlid('organisation_id')->constrained()->cascadeOnDelete();
// Polymorphic owner (event / user_profile / artist / company / null)
$table->string('owner_type', 50)->nullable();
$table->ulid('owner_id')->nullable();
$table->string('name');
$table->string('slug');
$table->string('purpose', 50);
$table->string('custom_purpose_slug')->nullable();
$table->text('description')->nullable();
$table->boolean('is_published')->default(false);
$table->string('submission_mode', 20);
$table->ulid('public_token')->nullable();
$table->ulid('public_token_previous')->nullable();
$table->timestamp('public_token_rotated_at')->nullable();
$table->timestamp('submission_deadline')->nullable();
$table->string('locale', 10)->default('nl');
$table->json('settings')->nullable();
$table->unsignedInteger('version')->default(1);
$table->string('snapshot_mode', 20)->default('never');
$table->boolean('freeze_on_submit')->default(false);
$table->unsignedInteger('retention_days')->nullable();
$table->string('consent_version')->nullable();
$table->boolean('section_level_submit')->default(false);
$table->boolean('auto_save_enabled')->default(false);
$table->unsignedInteger('max_submissions')->nullable();
$table->foreignUlid('created_by_user_id')->nullable()
->constrained('users')->nullOnDelete();
$table->foreignUlid('last_updated_by_user_id')->nullable()
->constrained('users')->nullOnDelete();
$table->foreignUlid('edit_lock_user_id')->nullable()
->constrained('users')->nullOnDelete();
$table->timestamp('edit_lock_expires_at')->nullable();
$table->timestamps();
$table->softDeletes();
$table->index(['organisation_id', 'purpose'], 'fs_org_purpose_idx');
$table->index(['owner_type', 'owner_id'], 'fs_owner_idx');
$table->unique(['organisation_id', 'slug'], 'fs_org_slug_unique');
// MySQL cannot express a partial unique index cross-platform.
// We use regular indexes; application-level uniqueness enforced
// in FormSchemaService (NULLs bypass DB-level uniqueness anyway).
$table->index('public_token', 'fs_public_token_idx');
$table->index('public_token_previous', 'fs_public_token_prev_idx');
$table->index('custom_purpose_slug', 'fs_custom_purpose_slug_idx');
});
}
public function down(): void
{
Schema::dropIfExists('form_schemas');
}
};

View File

@@ -0,0 +1,40 @@
<?php
declare(strict_types=1);
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::create('form_schema_sections', function (Blueprint $table) {
$table->ulid('id')->primary();
$table->foreignUlid('form_schema_id')
->constrained('form_schemas')
->cascadeOnDelete();
$table->string('slug');
$table->string('name');
$table->text('description')->nullable();
$table->unsignedInteger('sort_order')->default(0);
$table->boolean('submit_independent')->default(true);
$table->foreignUlid('depends_on_section_id')
->nullable()
->constrained('form_schema_sections')
->nullOnDelete();
$table->boolean('required_for_schema_submit')->default(true);
$table->timestamps();
$table->softDeletes();
$table->index(['form_schema_id', 'sort_order'], 'fss_schema_order_idx');
$table->unique(['form_schema_id', 'slug'], 'fss_schema_slug_unique');
});
}
public function down(): void
{
Schema::dropIfExists('form_schema_sections');
}
};

View File

@@ -0,0 +1,43 @@
<?php
declare(strict_types=1);
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::create('form_field_library', function (Blueprint $table) {
$table->ulid('id')->primary();
$table->foreignUlid('organisation_id')->constrained()->cascadeOnDelete();
$table->string('name');
$table->string('slug');
$table->string('field_type', 50);
$table->string('label');
$table->text('help_text')->nullable();
$table->json('options')->nullable();
$table->json('validation_rules')->nullable();
$table->boolean('default_is_required')->default(false);
$table->boolean('default_is_filterable')->default(false);
$table->json('default_binding')->nullable();
$table->json('translations')->nullable();
$table->text('description')->nullable();
$table->unsignedInteger('usage_count')->default(0);
$table->boolean('is_system')->default(false);
$table->boolean('is_active')->default(true);
$table->timestamps();
$table->index(['organisation_id', 'field_type'], 'ffl_org_type_idx');
$table->index(['organisation_id', 'is_active'], 'ffl_org_active_idx');
$table->unique(['organisation_id', 'slug'], 'ffl_org_slug_unique');
});
}
public function down(): void
{
Schema::dropIfExists('form_field_library');
}
};

View File

@@ -0,0 +1,69 @@
<?php
declare(strict_types=1);
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::create('form_fields', function (Blueprint $table) {
$table->ulid('id')->primary();
$table->foreignUlid('form_schema_id')
->constrained('form_schemas')
->cascadeOnDelete();
$table->foreignUlid('form_schema_section_id')
->nullable()
->constrained('form_schema_sections')
->nullOnDelete();
$table->foreignUlid('library_field_id')
->nullable()
->constrained('form_field_library')
->nullOnDelete();
// field_type is stored as string (not DB enum) to allow
// runtime-registered custom types per ARCH §17.2.
$table->string('field_type', 50);
$table->string('slug', 100);
$table->string('label');
$table->text('help_text')->nullable();
$table->string('section', 100)->nullable();
$table->json('options')->nullable();
$table->json('validation_rules')->nullable();
$table->boolean('is_required')->default(false);
$table->boolean('is_filterable')->default(false);
$table->boolean('is_portal_visible')->default(true);
$table->boolean('is_admin_only')->default(false);
$table->boolean('is_unique')->default(false);
$table->boolean('is_pii')->default(false);
$table->string('display_width', 10)->default('full');
$table->json('binding')->nullable();
$table->json('conditional_logic')->nullable();
$table->json('role_restrictions')->nullable();
$table->json('translations')->nullable();
$table->string('value_storage_hint', 10)->default('json');
$table->boolean('review_required')->default(false);
$table->unsignedInteger('sort_order')->default(0);
$table->timestamps();
$table->softDeletes();
$table->index(['form_schema_id', 'sort_order'], 'ff_schema_order_idx');
$table->index(['form_schema_id', 'is_filterable'], 'ff_schema_filterable_idx');
$table->index('library_field_id', 'ff_library_idx');
// Uniqueness of slug-within-schema for non-deleted rows is
// enforced at application level (FormFieldService); a composite
// unique across (schema_id, slug) would collide on soft-delete
// + re-create. Adjust if MySQL generated column approach preferred.
$table->index(['form_schema_id', 'slug'], 'ff_schema_slug_idx');
});
}
public function down(): void
{
Schema::dropIfExists('form_fields');
}
};

View File

@@ -0,0 +1,82 @@
<?php
declare(strict_types=1);
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::create('form_submissions', function (Blueprint $table) {
$table->ulid('id')->primary();
$table->foreignUlid('form_schema_id')
->constrained('form_schemas')
->cascadeOnDelete();
// Polymorphic subject
$table->string('subject_type', 50)->nullable();
$table->ulid('subject_id')->nullable();
$table->foreignUlid('submitted_by_user_id')->nullable()
->constrained('users')->nullOnDelete();
$table->string('public_submitter_name')->nullable();
$table->string('public_submitter_email')->nullable();
$table->string('public_submitter_ip', 45)->nullable();
$table->timestamp('public_submitter_ip_anonymised_at')->nullable();
$table->string('status', 20);
$table->string('review_status', 30)->nullable();
$table->foreignUlid('reviewed_by_user_id')->nullable()
->constrained('users')->nullOnDelete();
$table->timestamp('reviewed_at')->nullable();
$table->text('review_notes')->nullable();
$table->timestamp('submitted_at')->nullable();
$table->unsignedInteger('schema_version_at_submit')->nullable();
$table->json('schema_snapshot')->nullable();
$table->boolean('is_test')->default(false);
$table->string('submitted_in_locale', 10)->nullable();
$table->timestamp('opened_at')->nullable();
$table->timestamp('first_interacted_at')->nullable();
$table->unsignedInteger('submission_duration_seconds')->nullable();
$table->unsignedInteger('auto_save_count')->default(0);
$table->ulid('idempotency_key')->nullable();
$table->timestamp('anonymised_at')->nullable();
// MEDIUMTEXT for long concatenated submission content; FULLTEXT
// added when engine supports it.
$table->mediumText('search_index')->nullable();
$table->timestamps();
$table->softDeletes();
$table->index(['form_schema_id', 'status'], 'fs_schema_status_idx');
$table->index(['subject_type', 'subject_id'], 'fs_subject_idx');
$table->index('submitted_by_user_id', 'fs_submitter_idx');
$table->index(['form_schema_id', 'review_status'], 'fs_schema_review_idx');
$table->index(['form_schema_id', 'idempotency_key'], 'fs_idempotency_idx');
});
// FULLTEXT index: best-effort. InnoDB (MySQL 5.7+) supports it.
// SQLite/older engines don't; we swallow the failure so tests that
// run on SQLite can still migrate. Production MySQL gets the index.
try {
Schema::table('form_submissions', function (Blueprint $table): void {
$table->fullText('search_index', 'fs_search_index_fulltext');
});
} catch (\Throwable) {
// Engine does not support FULLTEXT — regular index lookups via
// LIKE are the fallback. Documented in migration comment.
}
}
public function down(): void
{
Schema::dropIfExists('form_submissions');
}
};

View File

@@ -0,0 +1,40 @@
<?php
declare(strict_types=1);
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::create('form_submission_section_statuses', function (Blueprint $table) {
$table->id();
$table->foreignUlid('form_submission_id')
->constrained('form_submissions')
->cascadeOnDelete();
$table->foreignUlid('form_schema_section_id')
->constrained('form_schema_sections')
->cascadeOnDelete();
$table->string('status', 30);
$table->timestamp('submitted_at')->nullable();
$table->foreignUlid('reviewed_by_user_id')->nullable()
->constrained('users')->nullOnDelete();
$table->timestamp('reviewed_at')->nullable();
$table->text('review_notes')->nullable();
$table->timestamps();
$table->unique(
['form_submission_id', 'form_schema_section_id'],
'fsss_submission_section_unique'
);
});
}
public function down(): void
{
Schema::dropIfExists('form_submission_section_statuses');
}
};

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;
return new class extends Migration
{
public function up(): void
{
Schema::create('form_submission_delegations', function (Blueprint $table) {
$table->ulid('id')->primary();
$table->foreignUlid('form_submission_id')
->constrained('form_submissions')
->cascadeOnDelete();
$table->foreignUlid('delegated_to_user_id')
->constrained('users')
->cascadeOnDelete();
$table->foreignUlid('delegated_by_user_id')
->constrained('users')
->cascadeOnDelete();
$table->timestamp('granted_at');
$table->timestamp('revoked_at')->nullable();
$table->text('message')->nullable();
$table->timestamps();
$table->index(['delegated_to_user_id', 'revoked_at'], 'fsd_delegatee_active_idx');
$table->index('form_submission_id', 'fsd_submission_idx');
});
}
public function down(): void
{
Schema::dropIfExists('form_submission_delegations');
}
};

View File

@@ -0,0 +1,46 @@
<?php
declare(strict_types=1);
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::create('form_values', function (Blueprint $table) {
// Integer AI PK: EAV table, joined heavily — int joins faster
// than ULID joins (ARCH §4.4).
$table->id();
$table->foreignUlid('form_submission_id')
->constrained('form_submissions')
->cascadeOnDelete();
$table->foreignUlid('form_field_id')
->constrained('form_fields')
->cascadeOnDelete();
$table->json('value');
$table->string('value_indexed', 255)->nullable();
$table->decimal('value_number', 15, 4)->nullable();
$table->date('value_date')->nullable();
$table->boolean('value_bool')->nullable();
$table->boolean('value_anonymised')->default(false);
$table->timestamps();
$table->unique(
['form_submission_id', 'form_field_id'],
'fv_submission_field_unique'
);
$table->index(['form_field_id', 'value_indexed'], 'fv_field_indexed_idx');
$table->index(['form_field_id', 'value_number'], 'fv_field_number_idx');
$table->index(['form_field_id', 'value_date'], 'fv_field_date_idx');
});
}
public function down(): void
{
Schema::dropIfExists('form_values');
}
};

View File

@@ -0,0 +1,36 @@
<?php
declare(strict_types=1);
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::create('form_value_options', function (Blueprint $table) {
$table->id();
$table->foreignId('form_value_id')
->constrained('form_values')
->cascadeOnDelete();
$table->foreignUlid('form_field_id')
->constrained('form_fields')
->cascadeOnDelete();
$table->foreignUlid('form_submission_id')
->constrained('form_submissions')
->cascadeOnDelete();
$table->string('option_value', 255);
$table->index(['form_field_id', 'option_value'], 'fvo_field_option_idx');
$table->index('form_submission_id', 'fvo_submission_idx');
$table->index('form_value_id', 'fvo_value_idx');
});
}
public function down(): void
{
Schema::dropIfExists('form_value_options');
}
};

View File

@@ -0,0 +1,35 @@
<?php
declare(strict_types=1);
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::create('form_templates', function (Blueprint $table) {
$table->ulid('id')->primary();
$table->foreignUlid('organisation_id')->constrained()->cascadeOnDelete();
$table->string('name');
$table->string('slug');
$table->string('purpose', 50);
$table->text('description')->nullable();
// schema_snapshot structure per ARCH §4.6.1
$table->json('schema_snapshot');
$table->boolean('is_system')->default(false);
$table->boolean('is_active')->default(true);
$table->timestamps();
$table->index(['organisation_id', 'purpose', 'is_active'], 'ft_org_purpose_active_idx');
$table->unique(['organisation_id', 'slug'], 'ft_org_slug_unique');
});
}
public function down(): void
{
Schema::dropIfExists('form_templates');
}
};

View File

@@ -0,0 +1,35 @@
<?php
declare(strict_types=1);
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::create('form_schema_webhooks', function (Blueprint $table) {
$table->ulid('id')->primary();
$table->foreignUlid('form_schema_id')
->constrained('form_schemas')
->cascadeOnDelete();
$table->string('name');
$table->string('trigger_event', 40);
// Encrypted storage: enforced by Eloquent cast on the model.
// Column is TEXT to accommodate ciphertext length overhead.
$table->text('url');
$table->text('secret')->nullable();
$table->boolean('is_active')->default(true);
$table->timestamps();
$table->index(['form_schema_id', 'is_active'], 'fsw_schema_active_idx');
});
}
public function down(): void
{
Schema::dropIfExists('form_schema_webhooks');
}
};

View File

@@ -0,0 +1,42 @@
<?php
declare(strict_types=1);
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::create('form_webhook_deliveries', function (Blueprint $table) {
$table->ulid('id')->primary();
$table->foreignUlid('form_schema_webhook_id')
->constrained('form_schema_webhooks')
->cascadeOnDelete();
$table->foreignUlid('form_submission_id')
->constrained('form_submissions')
->cascadeOnDelete();
$table->string('trigger_event', 40);
$table->string('status', 20);
$table->unsignedInteger('attempts')->default(0);
$table->timestamp('last_attempt_at')->nullable();
$table->unsignedSmallInteger('response_status')->nullable();
$table->text('response_body_excerpt')->nullable();
$table->timestamp('next_retry_at')->nullable();
$table->timestamp('delivered_at')->nullable();
$table->timestamp('failed_permanently_at')->nullable();
$table->json('payload_snapshot');
$table->index(['status', 'next_retry_at'], 'fwd_status_retry_idx');
$table->index(['form_schema_webhook_id', 'status'], 'fwd_webhook_status_idx');
$table->index('form_submission_id', 'fwd_submission_idx');
});
}
public function down(): void
{
Schema::dropIfExists('form_webhook_deliveries');
}
};