feat(timetable): ArtistAdvanceDefault seeder + bootstrap

Seeds 5 default sections per RFC v0.2 D15 (General Info, Contacts,
Production, Technical Rider, Hospitality) on a per-organisation
artist_advance FormSchema with section_level_submit=true. Each
section ships with 3-4 illustrative form_fields; organisations
customise via the FormBuilder UI later.

Wired into org-creation via the new OrganisationObserver so new
tenants receive the schema automatically. Existing orgs get
coverage via the new artist:seed-advance-default artisan command
(idempotent — orgs that already own a schema are skipped).

Note: introduces a new production-grade default-seeder convention.
Prior FormBuilder defaults were dev-only via FormBuilderDevSeeder
called from DevSeeder::run(). This is the first non-dev path.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-08 22:16:25 +02:00
parent cc48011da6
commit 895a1690e7
4 changed files with 260 additions and 0 deletions

View File

@@ -0,0 +1,51 @@
<?php
declare(strict_types=1);
namespace App\Console\Commands;
use App\FormBuilder\Defaults\ArtistAdvanceDefault;
use App\Models\Organisation;
use Illuminate\Console\Command;
/**
* Seed the default artist_advance FormSchema for one organisation
* (by id) or for every organisation.
*
* The OrganisationObserver wires this for new tenants automatically;
* this command exists to backfill organisations that pre-date the
* RFC-TIMETABLE v0.2 D15 default. Idempotent orgs that already own
* an artist_advance schema are skipped.
*/
final class SeedArtistAdvanceDefaultCommand extends Command
{
protected $signature = 'artist:seed-advance-default {organisation? : Organisation ID; omit to seed every organisation}';
protected $description = 'Seed the default artist_advance FormSchema for one or every organisation.';
public function handle(): int
{
$organisationId = $this->argument('organisation');
$query = Organisation::query();
if (is_string($organisationId) && $organisationId !== '') {
$query->whereKey($organisationId);
}
$organisations = $query->get();
if ($organisations->isEmpty()) {
$this->error('No organisations matched the supplied filter.');
return self::FAILURE;
}
foreach ($organisations as $organisation) {
ArtistAdvanceDefault::seedFor($organisation);
$this->line(sprintf(' ✓ %s (%s)', $organisation->name, $organisation->id));
}
$this->info(sprintf('Seeded artist_advance defaults for %d organisation(s).', $organisations->count()));
return self::SUCCESS;
}
}

View File

@@ -0,0 +1,181 @@
<?php
declare(strict_types=1);
namespace App\FormBuilder\Defaults;
use App\Enums\Artist\AdvanceSectionType;
use App\Enums\FormBuilder\FormFieldType;
use App\Enums\FormBuilder\FormPurpose;
use App\Enums\FormBuilder\FormSchemaSnapshotMode;
use App\Enums\FormBuilder\FormSubmissionMode;
use App\Models\FormBuilder\FormField;
use App\Models\FormBuilder\FormSchema;
use App\Models\FormBuilder\FormSchemaSection;
use App\Models\Organisation;
use App\Models\Scopes\OrganisationScope;
use Illuminate\Support\Facades\DB;
/**
* Default `artist_advance` FormSchema bootstrap per RFC-TIMETABLE
* v0.2 D15. One schema per organisation, with five sections mapped
* to AdvanceSectionType:
*
* - General Info Custom
* - Contacts Contacts
* - Production Production
* - Technical Rider Production
* - Hospitality Custom
*
* Each section carries 3-4 illustrative fields. Organisations
* customise via the FormBuilder UI later. The schema is published
* and uses section_level_submit per ARCH-FORM-BUILDER §3.2.5.
*
* Idempotent: if an organisation already owns an artist_advance
* schema (any one), the seeder no-ops and returns the existing row.
*
* Bridge to per-engagement AdvanceSection rows: FormSchemaSection
* slug matches AdvanceSectionType::value (where applicable). Sections
* carrying type=Custom use a stable slug per row name.
*/
final class ArtistAdvanceDefault
{
public static function seedFor(Organisation $organisation): FormSchema
{
$existing = FormSchema::query()
->withoutGlobalScope(OrganisationScope::class)
->where('organisation_id', $organisation->id)
->where('purpose', FormPurpose::ARTIST_ADVANCE->value)
->first();
if ($existing instanceof FormSchema) {
return $existing;
}
return DB::transaction(static function () use ($organisation): FormSchema {
$schema = FormSchema::create([
'organisation_id' => $organisation->id,
'owner_type' => 'organisation',
'owner_id' => $organisation->id,
'name' => 'Artiest advance',
'slug' => 'artiest-advance',
'purpose' => FormPurpose::ARTIST_ADVANCE->value,
'description' => 'Standaard advance-formulier voor artiesten. Pas de secties en velden aan via de FormBuilder.',
'is_published' => true,
'submission_mode' => FormSubmissionMode::DRAFT_SINGLE->value,
'locale' => 'nl',
'snapshot_mode' => FormSchemaSnapshotMode::ON_SUBMIT->value,
'freeze_on_submit' => false,
'section_level_submit' => true,
'auto_save_enabled' => true,
'version' => 1,
]);
foreach (self::sectionDefinitions() as $sortOrder => $def) {
$section = FormSchemaSection::create([
'form_schema_id' => $schema->id,
'slug' => $def['slug'],
'name' => $def['name'],
'sort_order' => $sortOrder + 1,
'submit_independent' => true,
'required_for_schema_submit' => true,
]);
foreach ($def['fields'] as $fieldOrder => $field) {
FormField::create([
'form_schema_id' => $schema->id,
'form_schema_section_id' => $section->id,
'field_type' => $field['type']->value,
'slug' => $field['slug'],
'label' => $field['label'],
'help_text' => $field['help_text'] ?? null,
'is_required' => $field['is_required'] ?? false,
'is_filterable' => false,
'is_portal_visible' => true,
'is_admin_only' => false,
'is_pii' => $field['is_pii'] ?? false,
'display_width' => $field['display_width'] ?? 'full',
'value_storage_hint' => ($field['type']->recommendedValueStorageHint())->value,
'sort_order' => $fieldOrder + 1,
]);
}
}
return $schema->refresh();
});
}
/**
* @return array<int, array{
* slug: string,
* name: string,
* advance_type: AdvanceSectionType,
* fields: array<int, array{
* type: FormFieldType,
* slug: string,
* label: string,
* help_text?: string,
* is_required?: bool,
* is_pii?: bool,
* display_width?: string,
* }>
* }>
*/
private static function sectionDefinitions(): array
{
return [
[
'slug' => 'general-info',
'name' => 'Algemeen',
'advance_type' => AdvanceSectionType::Custom,
'fields' => [
['type' => FormFieldType::DATETIME, 'slug' => 'arrival-datetime', 'label' => 'Aankomsttijd', 'is_required' => true, 'display_width' => 'half'],
['type' => FormFieldType::DATETIME, 'slug' => 'departure-datetime', 'label' => 'Vertrektijd', 'is_required' => true, 'display_width' => 'half'],
['type' => FormFieldType::TEXTAREA, 'slug' => 'general-notes', 'label' => 'Opmerkingen'],
],
],
[
'slug' => 'contacts',
'name' => 'Contactpersonen',
'advance_type' => AdvanceSectionType::Contacts,
'fields' => [
['type' => FormFieldType::TEXT, 'slug' => 'tour-manager-name', 'label' => 'Tour manager', 'is_required' => true, 'is_pii' => true, 'display_width' => 'full'],
['type' => FormFieldType::EMAIL, 'slug' => 'tour-manager-email', 'label' => 'E-mail tour manager', 'is_required' => true, 'is_pii' => true, 'display_width' => 'half'],
['type' => FormFieldType::PHONE, 'slug' => 'tour-manager-phone', 'label' => 'Telefoon tour manager', 'is_pii' => true, 'display_width' => 'half'],
['type' => FormFieldType::TABLE_ROWS, 'slug' => 'additional-contacts', 'label' => 'Aanvullende contactpersonen', 'is_pii' => true],
],
],
[
'slug' => 'production',
'name' => 'Productie',
'advance_type' => AdvanceSectionType::Production,
'fields' => [
['type' => FormFieldType::FILE_UPLOAD, 'slug' => 'stage-plot', 'label' => 'Stage plot'],
['type' => FormFieldType::TEXTAREA, 'slug' => 'monitor-needs', 'label' => 'Monitorwensen'],
['type' => FormFieldType::TEXTAREA, 'slug' => 'special-equipment', 'label' => 'Specifieke apparatuur'],
],
],
[
'slug' => 'technical-rider',
'name' => 'Technische rider',
'advance_type' => AdvanceSectionType::Production,
'fields' => [
['type' => FormFieldType::FILE_UPLOAD, 'slug' => 'input-list', 'label' => 'Input list'],
['type' => FormFieldType::TEXTAREA, 'slug' => 'microphone-preferences', 'label' => 'Microfoonvoorkeuren'],
['type' => FormFieldType::TEXTAREA, 'slug' => 'backline-requirements', 'label' => 'Backline'],
],
],
[
'slug' => 'hospitality',
'name' => 'Hospitality',
'advance_type' => AdvanceSectionType::Custom,
'fields' => [
['type' => FormFieldType::TEXTAREA, 'slug' => 'dressing-room-requirements', 'label' => 'Kleedkamer'],
['type' => FormFieldType::TEXTAREA, 'slug' => 'food-preferences', 'label' => 'Cateringvoorkeuren'],
['type' => FormFieldType::TEXTAREA, 'slug' => 'drinks', 'label' => 'Drankvoorkeuren'],
['type' => FormFieldType::TEXT, 'slug' => 'allergies', 'label' => 'Allergieën', 'is_pii' => true],
],
],
];
}
}

View File

@@ -0,0 +1,27 @@
<?php
declare(strict_types=1);
namespace App\Observers;
use App\FormBuilder\Defaults\ArtistAdvanceDefault;
use App\Models\Organisation;
/**
* Bootstrap an organisation's domain defaults on creation.
*
* Currently provisions the artist_advance FormSchema (RFC-TIMETABLE
* v0.2 D15) so new tenants can use the artist portal flow without a
* separate manual step. Existing organisations get the same coverage
* via the `artist:seed-advance-default` artisan command.
*
* The default seeder is idempotent if the org already owns an
* artist_advance schema, the call is a no-op. Safe to re-run.
*/
final class OrganisationObserver
{
public function created(Organisation $organisation): void
{
ArtistAdvanceDefault::seedFor($organisation);
}
}

View File

@@ -153,6 +153,7 @@ class AppServiceProvider extends ServiceProvider
Person::observe(PersonObserver::class);
User::observe(UserObserver::class);
Organisation::observe(\App\Observers\OrganisationObserver::class);
FormValue::observe(FormValueObserver::class);
\App\Models\FormBuilder\FormSubmission::observe(FormSubmissionObserver::class);