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:
51
api/app/Console/Commands/SeedArtistAdvanceDefaultCommand.php
Normal file
51
api/app/Console/Commands/SeedArtistAdvanceDefaultCommand.php
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
181
api/app/FormBuilder/Defaults/ArtistAdvanceDefault.php
Normal file
181
api/app/FormBuilder/Defaults/ArtistAdvanceDefault.php
Normal 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],
|
||||||
|
],
|
||||||
|
],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
27
api/app/Observers/OrganisationObserver.php
Normal file
27
api/app/Observers/OrganisationObserver.php
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -153,6 +153,7 @@ class AppServiceProvider extends ServiceProvider
|
|||||||
|
|
||||||
Person::observe(PersonObserver::class);
|
Person::observe(PersonObserver::class);
|
||||||
User::observe(UserObserver::class);
|
User::observe(UserObserver::class);
|
||||||
|
Organisation::observe(\App\Observers\OrganisationObserver::class);
|
||||||
FormValue::observe(FormValueObserver::class);
|
FormValue::observe(FormValueObserver::class);
|
||||||
\App\Models\FormBuilder\FormSubmission::observe(FormSubmissionObserver::class);
|
\App\Models\FormBuilder\FormSubmission::observe(FormSubmissionObserver::class);
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user