diff --git a/api/app/Services/FormBuilder/FormSubmissionService.php b/api/app/Services/FormBuilder/FormSubmissionService.php index a56d6645..f0198b50 100644 --- a/api/app/Services/FormBuilder/FormSubmissionService.php +++ b/api/app/Services/FormBuilder/FormSubmissionService.php @@ -109,7 +109,9 @@ final class FormSubmissionService } if ($submission->opened_at !== null) { - $submission->submission_duration_seconds = now()->diffInSeconds($submission->opened_at); + // abs + int cast: Carbon's diffInSeconds returns signed fractional + // seconds, and the column is unsignedInteger. + $submission->submission_duration_seconds = (int) abs(now()->diffInSeconds($submission->opened_at)); } $submission->save(); diff --git a/api/database/seeders/DevSeeder.php b/api/database/seeders/DevSeeder.php index a07dc1db..8bda293a 100644 --- a/api/database/seeders/DevSeeder.php +++ b/api/database/seeders/DevSeeder.php @@ -926,6 +926,13 @@ class DevSeeder extends Seeder $submissions = FormBuilderDevSeeder::seedSubmissionsForEvent($festival, $formSchema); $this->command->info(" Form schema + 16 fields + {$submissions} submissions created"); + // Sprint 0.5 showcase: public-token-enabled event_registration + // schema + draft + submitted submission that exercises + // FORM-02 / §31.10 TAG_PICKER sync end-to-end. + if (app()->environment('local', 'testing', 'development')) { + FormBuilderDevSeeder::seedEventRegistrationShowcase($this->org, $festival, $this->command); + } + $this->command->info(' Echt Feesten 2026 complete'); }); } diff --git a/api/database/seeders/FormBuilderDevSeeder.php b/api/database/seeders/FormBuilderDevSeeder.php index b25393ed..14f9c683 100644 --- a/api/database/seeders/FormBuilderDevSeeder.php +++ b/api/database/seeders/FormBuilderDevSeeder.php @@ -18,6 +18,11 @@ use App\Models\FormBuilder\FormTemplate; use App\Models\FormBuilder\FormValue; use App\Models\Organisation; use App\Models\Person; +use App\Models\PersonTag; +use App\Models\UserOrganisationTag; +use App\Services\FormBuilder\FormSubmissionService; +use App\Services\FormBuilder\FormValueService; +use Illuminate\Console\Command; use Illuminate\Support\Str; /** @@ -227,4 +232,317 @@ final class FormBuilderDevSeeder 'value' => $value, ]); } + + // ========================================================================= + // Sprint 0.5 — event_registration showcase (FORM-02 / §31.10 demo) + // + // Creates ONE dedicated, public-token-enabled event_registration schema + // per dev org, with a curated 5-field set, a draft submission, and a + // fully-submitted submission whose TAG_PICKER values exercise the + // SyncTagPickerSelectionsOnSubmit listener so user_organisation_tags + // populate during `migrate:fresh --seed`. + // ========================================================================= + + /** + * Showcase field definitions. 5 fields per Sprint 0.5 spec. + * + * @return list> + */ + private static function showcaseFieldDefinitions(): array + { + return [ + [ + 'type' => FormFieldType::HEADING, + 'slug' => 'over-jou', + 'label' => 'Over jou', + 'is_required' => false, + 'is_filterable' => false, + 'display_width' => 'full', + 'value_storage_hint' => FormValueStorageHint::JSON, + ], + [ + 'type' => FormFieldType::SELECT, + 'slug' => 'shirtmaat', + 'label' => 'Shirtmaat', + 'options' => ['XS', 'S', 'M', 'L', 'XL', 'XXL'], + 'is_required' => true, + 'is_filterable' => true, + 'display_width' => 'half', + 'value_storage_hint' => FormValueStorageHint::STRING, + ], + [ + 'type' => FormFieldType::CHECKBOX_LIST, + 'slug' => 'dieetwensen', + 'label' => 'Dieetwensen', + 'options' => ['Vegetarisch', 'Veganistisch', 'Glutenvrij', 'Lactosevrij', 'Halal', 'Kosher'], + 'is_required' => false, + 'is_filterable' => true, + 'display_width' => 'half', + 'value_storage_hint' => FormValueStorageHint::JSON, + ], + [ + 'type' => FormFieldType::TAG_PICKER, + 'slug' => 'vaardigheden', + 'label' => 'Vaardigheden en certificaten', + // validation_rules.tag_categories = [] means "all active + // person_tags for this org" — the FormFieldResource picks + // up every active tag when no category filter is set. + 'validation_rules' => null, + 'is_required' => false, + 'is_filterable' => true, + 'display_width' => 'full', + 'value_storage_hint' => FormValueStorageHint::JSON, + ], + [ + 'type' => FormFieldType::TEXTAREA, + 'slug' => 'opmerkingen', + 'label' => 'Opmerkingen', + 'is_required' => false, + 'is_filterable' => false, + 'display_width' => 'full', + 'value_storage_hint' => FormValueStorageHint::STRING, + ], + ]; + } + + /** + * Create the public-token-enabled showcase schema on the given primary + * event, plus its 5 fields. Returns the schema for follow-up seeding. + */ + public static function seedEventRegistrationShowcaseSchema(Organisation $org, Event $event): FormSchema + { + $name = 'Vrijwilligersregistratie '.$event->name; + $slug = self::uniqueSchemaSlug($org, Str::slug($name)); + + /** @var FormSchema $schema */ + $schema = FormSchema::create([ + 'organisation_id' => $org->id, + 'owner_type' => 'event', + 'owner_id' => $event->id, + 'name' => $name, + 'slug' => $slug, + 'purpose' => FormPurpose::EVENT_REGISTRATION, + 'description' => "Demo-formulier voor het end-to-end doorlopen van de vrijwilligersregistratie voor {$event->name}.", + 'is_published' => true, + 'submission_mode' => FormSubmissionMode::DRAFT_SINGLE, + 'public_token' => (string) Str::ulid(), + 'locale' => 'nl', + 'snapshot_mode' => FormSchemaSnapshotMode::ON_SUBMIT, + 'freeze_on_submit' => false, + 'section_level_submit' => false, + 'auto_save_enabled' => false, + 'version' => 1, + ]); + + foreach (self::showcaseFieldDefinitions() as $sortOrder => $def) { + FormField::create([ + 'form_schema_id' => $schema->id, + 'field_type' => $def['type']->value, + 'slug' => $def['slug'], + 'label' => $def['label'], + 'options' => $def['options'] ?? null, + 'validation_rules' => $def['validation_rules'] ?? null, + 'is_required' => $def['is_required'] ?? false, + 'is_filterable' => $def['is_filterable'] ?? false, + 'is_portal_visible' => true, + 'is_admin_only' => false, + 'is_pii' => false, + 'display_width' => $def['display_width'] ?? 'full', + 'binding' => null, + 'role_restrictions' => null, + 'value_storage_hint' => $def['value_storage_hint'] ?? FormValueStorageHint::JSON, + 'sort_order' => $sortOrder + 1, + ]); + } + + return $schema->refresh(); + } + + /** + * Seed one draft + one fully-submitted submission on the showcase + * schema. The submitted one triggers FormSubmissionSubmitted so the + * §31.10 listener runs and `user_organisation_tags` picks up + * `source=self_reported` rows for the submitter's user. + * + * Returns summary stats for caller-side logging. + * + * @return array{draft_person: ?Person, submitted_person: ?Person, synced_tag_count: int} + */ + public static function seedEventRegistrationShowcaseSubmissions(FormSchema $schema, Organisation $org): array + { + $event = Event::withoutGlobalScopes()->find($schema->owner_id); + if ($event === null) { + return ['draft_person' => null, 'submitted_person' => null, 'synced_tag_count' => 0]; + } + + // Deterministic pick: first two approved persons with a user_id + // on this event, ordered by ULID (chronological insertion order). + $candidates = Person::withoutGlobalScopes() + ->where('event_id', $event->id) + ->whereNotNull('user_id') + ->orderBy('id') + ->take(2) + ->get(); + + $draftPerson = $candidates->first(); + $submittedPerson = $candidates->skip(1)->first(); + + if ($draftPerson !== null) { + self::buildDraftSubmission($schema, $draftPerson); + } + + $syncedTagCount = 0; + if ($submittedPerson !== null) { + $syncedTagCount = self::buildSubmittedSubmission($schema, $org, $submittedPerson); + } + + return [ + 'draft_person' => $draftPerson, + 'submitted_person' => $submittedPerson, + 'synced_tag_count' => $syncedTagCount, + ]; + } + + /** + * Orchestration helper: schema + submissions + console output, matching + * the exact format Bert asked for in Sprint 0.5. + */ + public static function seedEventRegistrationShowcase( + Organisation $org, + Event $event, + ?Command $command = null, + ): FormSchema { + $schema = self::seedEventRegistrationShowcaseSchema($org, $event); + + $appUrl = rtrim((string) config('app.url'), '/'); + $publicUrl = "{$appUrl}/api/v1/public/forms/{$schema->public_token}"; + + if ($command !== null) { + $command->info("[FormBuilderDevSeeder] Seeded event_registration schema for {$org->name}:"); + $command->info(" public URL: {$publicUrl}"); + } + + $stats = self::seedEventRegistrationShowcaseSubmissions($schema, $org); + + if ($command !== null) { + $draft = $stats['draft_person']; + if ($draft === null) { + $command->warn('[FormBuilderDevSeeder] No person with user_id found — draft submission skipped.'); + } else { + $command->info("[FormBuilderDevSeeder] Draft registration stored for {$draft->full_name} (person {$draft->id})"); + } + + $submitted = $stats['submitted_person']; + if ($submitted === null) { + $command->warn('[FormBuilderDevSeeder] Not enough persons with user_id for submitted demo — §31.10 sync not exercised.'); + } else { + $command->info("[FormBuilderDevSeeder] Submitted registration for {$submitted->full_name}"); + $command->info(" TAG_PICKER sync result: {$stats['synced_tag_count']} self_reported tags now on user {$submitted->user_id}"); + } + } + + return $schema; + } + + private static function buildDraftSubmission(FormSchema $schema, Person $person): FormSubmission + { + $fields = $schema->fields()->get()->keyBy('slug'); + + /** @var FormSubmission $submission */ + $submission = FormSubmission::create([ + 'form_schema_id' => $schema->id, + 'subject_type' => 'person', + 'subject_id' => $person->id, + 'submitted_by_user_id' => $person->user_id, + 'status' => FormSubmissionStatus::DRAFT->value, + 'is_test' => false, + 'submitted_in_locale' => 'nl', + 'opened_at' => now()->subDay(), + ]); + + // Realistic partial fill — deliberately skip TAG_PICKER (that path + // is exercised by the submitted demo below). + self::createValueIfField($fields, 'shirtmaat', 'M', $submission); + self::createValueIfField($fields, 'dieetwensen', ['Vegetarisch'], $submission); + + return $submission->refresh(); + } + + /** + * Full-submit path: create draft, fill values (incl. TAG_PICKER), run + * the service so FormSubmissionSubmitted fires and §31.10 runs. Queue + * connection is flipped to sync for the duration so the listener + * executes inline rather than being deferred to redis during seeding. + */ + private static function buildSubmittedSubmission(FormSchema $schema, Organisation $org, Person $person): int + { + $tagIds = PersonTag::withoutGlobalScopes() + ->where('organisation_id', $org->id) + ->where('is_active', true) + ->orderBy('sort_order') + ->orderBy('name') + ->take(3) + ->pluck('id') + ->all(); + + /** @var FormSubmissionService $submissionService */ + $submissionService = app(FormSubmissionService::class); + + $submission = $submissionService->createDraft( + $schema, + $person, + $person->user, + [ + 'opened_at' => now()->subHours(2), + 'is_test' => false, + ], + ); + + /** @var FormValueService $valueService */ + $valueService = app(FormValueService::class); + $valueService->upsertMany( + $submission, + [ + 'shirtmaat' => 'L', + 'dieetwensen' => ['Glutenvrij', 'Lactosevrij'], + 'vaardigheden' => $tagIds, + 'opmerkingen' => 'Kan eerder als nodig.', + ], + $person->user, + ); + + // Force synchronous listener execution while we're in the seeder + // so user_organisation_tags is populated by the time the migrate + // command returns (see `QUEUE_CONNECTION=redis` in local .env). + $previousConnection = config('queue.default'); + config(['queue.default' => 'sync']); + + try { + $submissionService->submit($submission->refresh(), $person->user); + } finally { + config(['queue.default' => $previousConnection]); + } + + return UserOrganisationTag::query() + ->where('user_id', $person->user_id) + ->where('organisation_id', $org->id) + ->where('source', 'self_reported') + ->count(); + } + + private static function uniqueSchemaSlug(Organisation $org, string $base): string + { + $candidate = $base; + $i = 2; + while (FormSchema::withoutGlobalScopes() + ->where('organisation_id', $org->id) + ->where('slug', $candidate) + ->exists() + ) { + $candidate = $base.'-'.$i; + $i++; + } + + return $candidate; + } }