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:
@@ -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();
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user