diff --git a/api/database/migrations/2026_04_19_100000_create_user_profiles_table.php b/api/database/migrations/2026_04_19_100000_create_user_profiles_table.php new file mode 100644 index 00000000..7001706d --- /dev/null +++ b/api/database/migrations/2026_04_19_100000_create_user_profiles_table.php @@ -0,0 +1,33 @@ +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'); + } +}; diff --git a/api/database/migrations/2026_04_19_100001_populate_user_profiles_from_existing_users.php b/api/database/migrations/2026_04_19_100001_populate_user_profiles_from_existing_users.php new file mode 100644 index 00000000..6fd5bc43 --- /dev/null +++ b/api/database/migrations/2026_04_19_100001_populate_user_profiles_from_existing_users.php @@ -0,0 +1,45 @@ +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(); + } +}; diff --git a/api/database/migrations/2026_04_19_100002_create_form_schemas_table.php b/api/database/migrations/2026_04_19_100002_create_form_schemas_table.php new file mode 100644 index 00000000..8f9b6925 --- /dev/null +++ b/api/database/migrations/2026_04_19_100002_create_form_schemas_table.php @@ -0,0 +1,74 @@ +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'); + } +}; diff --git a/api/database/migrations/2026_04_19_100003_create_form_schema_sections_table.php b/api/database/migrations/2026_04_19_100003_create_form_schema_sections_table.php new file mode 100644 index 00000000..c7ab3c77 --- /dev/null +++ b/api/database/migrations/2026_04_19_100003_create_form_schema_sections_table.php @@ -0,0 +1,40 @@ +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'); + } +}; diff --git a/api/database/migrations/2026_04_19_100004_create_form_field_library_table.php b/api/database/migrations/2026_04_19_100004_create_form_field_library_table.php new file mode 100644 index 00000000..0385d86b --- /dev/null +++ b/api/database/migrations/2026_04_19_100004_create_form_field_library_table.php @@ -0,0 +1,43 @@ +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'); + } +}; diff --git a/api/database/migrations/2026_04_19_100005_create_form_fields_table.php b/api/database/migrations/2026_04_19_100005_create_form_fields_table.php new file mode 100644 index 00000000..498ceee7 --- /dev/null +++ b/api/database/migrations/2026_04_19_100005_create_form_fields_table.php @@ -0,0 +1,69 @@ +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'); + } +}; diff --git a/api/database/migrations/2026_04_19_100006_create_form_submissions_table.php b/api/database/migrations/2026_04_19_100006_create_form_submissions_table.php new file mode 100644 index 00000000..37bbb020 --- /dev/null +++ b/api/database/migrations/2026_04_19_100006_create_form_submissions_table.php @@ -0,0 +1,82 @@ +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'); + } +}; diff --git a/api/database/migrations/2026_04_19_100007_create_form_submission_section_statuses_table.php b/api/database/migrations/2026_04_19_100007_create_form_submission_section_statuses_table.php new file mode 100644 index 00000000..61d25030 --- /dev/null +++ b/api/database/migrations/2026_04_19_100007_create_form_submission_section_statuses_table.php @@ -0,0 +1,40 @@ +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'); + } +}; diff --git a/api/database/migrations/2026_04_19_100008_create_form_submission_delegations_table.php b/api/database/migrations/2026_04_19_100008_create_form_submission_delegations_table.php new file mode 100644 index 00000000..85d28e09 --- /dev/null +++ b/api/database/migrations/2026_04_19_100008_create_form_submission_delegations_table.php @@ -0,0 +1,38 @@ +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'); + } +}; diff --git a/api/database/migrations/2026_04_19_100009_create_form_values_table.php b/api/database/migrations/2026_04_19_100009_create_form_values_table.php new file mode 100644 index 00000000..7fb4d757 --- /dev/null +++ b/api/database/migrations/2026_04_19_100009_create_form_values_table.php @@ -0,0 +1,46 @@ +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'); + } +}; diff --git a/api/database/migrations/2026_04_19_100010_create_form_value_options_table.php b/api/database/migrations/2026_04_19_100010_create_form_value_options_table.php new file mode 100644 index 00000000..9d797369 --- /dev/null +++ b/api/database/migrations/2026_04_19_100010_create_form_value_options_table.php @@ -0,0 +1,36 @@ +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'); + } +}; diff --git a/api/database/migrations/2026_04_19_100011_create_form_templates_table.php b/api/database/migrations/2026_04_19_100011_create_form_templates_table.php new file mode 100644 index 00000000..7c5be1e3 --- /dev/null +++ b/api/database/migrations/2026_04_19_100011_create_form_templates_table.php @@ -0,0 +1,35 @@ +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'); + } +}; diff --git a/api/database/migrations/2026_04_19_100012_create_form_schema_webhooks_table.php b/api/database/migrations/2026_04_19_100012_create_form_schema_webhooks_table.php new file mode 100644 index 00000000..6b275cbd --- /dev/null +++ b/api/database/migrations/2026_04_19_100012_create_form_schema_webhooks_table.php @@ -0,0 +1,35 @@ +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'); + } +}; diff --git a/api/database/migrations/2026_04_19_100013_create_form_webhook_deliveries_table.php b/api/database/migrations/2026_04_19_100013_create_form_webhook_deliveries_table.php new file mode 100644 index 00000000..e7836a71 --- /dev/null +++ b/api/database/migrations/2026_04_19_100013_create_form_webhook_deliveries_table.php @@ -0,0 +1,42 @@ +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'); + } +};