feat(seeder): dev event_registration schema with draft + submitted submissions exercising FORM-02 (§31.10)

Sprint 0.5. Extends FormBuilderDevSeeder (additive) so that after
`migrate:fresh --seed` the dev org has:

- one published public-token-enabled event_registration schema anchored
  to the primary festival (Echt Feesten 2026) with a curated 5-field
  set (HEADING / SELECT / CHECKBOX_LIST / TAG_PICKER / TEXTAREA) —
  mirrors the subset Bert needs to eyeball via the portal and verify
  §31.10 sync with;
- one draft submission (partial fill: shirtmaat + dieetwensen) for the
  first approved person with user_id — the TAG_PICKER is deliberately
  absent so this submission does NOT fire the listener;
- one submitted submission for the next approved person, with
  TAG_PICKER values = the first 3 active person_tags by sort_order.
  The submission is pushed through FormSubmissionService::submit so
  FormSubmissionSubmitted fires, SyncTagPickerSelectionsOnSubmit runs,
  and user_organisation_tags receives 3 self_reported rows.

Queue-connection contract: production runs QUEUE_CONNECTION=redis, so
the listener would queue and not execute before the seeder returns.
The seeder temporarily flips queue.default to sync for the submit()
call so Bert sees the synced tags immediately after `--seed`.

Console output matches the Sprint 0.5 spec: public URL for GET-testing
+ a line naming the submitter and the sync result count.

Wired from DevSeeder::seedEchtFeesten() behind an
app()->environment('local', 'testing', 'development') guard (belt-and-
suspenders on top of DatabaseSeeder's existing local gate).

Collateral fix: FormSubmissionService::submit() stored signed fractional
seconds into the unsigned `submission_duration_seconds` column. Carbon
3's diffInSeconds returns signed floats when `opened_at` is earlier than
now, which MySQL rejects. Wrapped with abs() + int cast. No test
expectations relied on the sign so 857 tests remain green.

Verified via tinker after `migrate:fresh --seed`:
  fields_count = 5, submissions_count = 2 (1 draft + 1 submitted),
  values on submitted = 4, self_reported tags for submitter = 3,
  PublicFormSchemaResource returns all 5 fields on the public token.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-04-17 21:58:42 +02:00
parent a51f3d3a47
commit 79d834cb1d
3 changed files with 328 additions and 1 deletions

View File

@@ -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();

View File

@@ -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');
});
}

View File

@@ -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<array<string, mixed>>
*/
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;
}
}