From d30bde97d2b8c325cd11ee4172e8f46113d68f4d Mon Sep 17 00:00:00 2001 From: numen-bot Date: Sun, 15 Mar 2026 19:19:29 +0000 Subject: [PATCH 01/11] feat(templates): database migrations + models (4 tables, ULID PKs) --- app/Models/PipelineTemplate.php | 91 ++++++++ app/Models/PipelineTemplateInstall.php | 56 +++++ app/Models/PipelineTemplateRating.php | 45 ++++ app/Models/PipelineTemplateVersion.php | 52 +++++ .../factories/PipelineTemplateFactory.php | 47 ++++ .../PipelineTemplateInstallFactory.php | 31 +++ .../PipelineTemplateRatingFactory.php | 28 +++ .../PipelineTemplateVersionFactory.php | 44 ++++ ...500001_create_pipeline_templates_table.php | 35 +++ ...reate_pipeline_template_versions_table.php | 29 +++ ...reate_pipeline_template_installs_table.php | 29 +++ ...create_pipeline_template_ratings_table.php | 27 +++ tests/Feature/PipelineTemplateTest.php | 206 ++++++++++++++++++ 13 files changed, 720 insertions(+) create mode 100644 app/Models/PipelineTemplate.php create mode 100644 app/Models/PipelineTemplateInstall.php create mode 100644 app/Models/PipelineTemplateRating.php create mode 100644 app/Models/PipelineTemplateVersion.php create mode 100644 database/factories/PipelineTemplateFactory.php create mode 100644 database/factories/PipelineTemplateInstallFactory.php create mode 100644 database/factories/PipelineTemplateRatingFactory.php create mode 100644 database/factories/PipelineTemplateVersionFactory.php create mode 100644 database/migrations/2026_03_15_500001_create_pipeline_templates_table.php create mode 100644 database/migrations/2026_03_15_500002_create_pipeline_template_versions_table.php create mode 100644 database/migrations/2026_03_15_500003_create_pipeline_template_installs_table.php create mode 100644 database/migrations/2026_03_15_500004_create_pipeline_template_ratings_table.php create mode 100644 tests/Feature/PipelineTemplateTest.php diff --git a/app/Models/PipelineTemplate.php b/app/Models/PipelineTemplate.php new file mode 100644 index 0000000..3878d44 --- /dev/null +++ b/app/Models/PipelineTemplate.php @@ -0,0 +1,91 @@ + $versions + * @property-read PipelineTemplateVersion|null $latestVersion + * @property-read \Illuminate\Database\Eloquent\Collection $installs + * @property-read \Illuminate\Database\Eloquent\Collection $ratings + */ +class PipelineTemplate extends Model +{ + use HasFactory, HasUlids, SoftDeletes; + + protected $fillable = [ + 'space_id', + 'name', + 'slug', + 'description', + 'category', + 'icon', + 'schema_version', + 'is_published', + 'author_name', + 'author_url', + 'downloads_count', + ]; + + protected $casts = [ + 'is_published' => 'boolean', + 'downloads_count' => 'integer', + ]; + + public function space(): BelongsTo + { + return $this->belongsTo(Space::class); + } + + public function versions(): HasMany + { + return $this->hasMany(PipelineTemplateVersion::class, 'template_id'); + } + + public function latestVersion(): HasMany + { + return $this->hasMany(PipelineTemplateVersion::class, 'template_id')->where('is_latest', true); + } + + public function installs(): HasMany + { + return $this->hasMany(PipelineTemplateInstall::class, 'template_id'); + } + + public function ratings(): HasMany + { + return $this->hasMany(PipelineTemplateRating::class, 'template_id'); + } + + public function isGlobal(): bool + { + return $this->space_id === null; + } + + public function averageRating(): float + { + return (float) $this->ratings()->avg('rating'); + } +} diff --git a/app/Models/PipelineTemplateInstall.php b/app/Models/PipelineTemplateInstall.php new file mode 100644 index 0000000..9466fb8 --- /dev/null +++ b/app/Models/PipelineTemplateInstall.php @@ -0,0 +1,56 @@ + 'datetime', + 'config_overrides' => 'array', + ]; + + public function template(): BelongsTo + { + return $this->belongsTo(PipelineTemplate::class, 'template_id'); + } + + public function templateVersion(): BelongsTo + { + return $this->belongsTo(PipelineTemplateVersion::class, 'version_id'); + } + + public function space(): BelongsTo + { + return $this->belongsTo(Space::class, 'space_id'); + } +} diff --git a/app/Models/PipelineTemplateRating.php b/app/Models/PipelineTemplateRating.php new file mode 100644 index 0000000..cba7fcf --- /dev/null +++ b/app/Models/PipelineTemplateRating.php @@ -0,0 +1,45 @@ + 'integer', + ]; + + public function template(): BelongsTo + { + return $this->belongsTo(PipelineTemplate::class, 'template_id'); + } + + public function user(): BelongsTo + { + return $this->belongsTo(User::class, 'user_id'); + } +} diff --git a/app/Models/PipelineTemplateVersion.php b/app/Models/PipelineTemplateVersion.php new file mode 100644 index 0000000..f4a1150 --- /dev/null +++ b/app/Models/PipelineTemplateVersion.php @@ -0,0 +1,52 @@ + $installs + */ +class PipelineTemplateVersion extends Model +{ + use HasFactory, HasUlids; + + protected $fillable = [ + 'template_id', + 'version', + 'definition', + 'changelog', + 'is_latest', + 'published_at', + ]; + + protected $casts = [ + 'definition' => 'array', + 'is_latest' => 'boolean', + 'published_at' => 'datetime', + ]; + + public function template(): BelongsTo + { + return $this->belongsTo(PipelineTemplate::class, 'template_id'); + } + + public function installs(): HasMany + { + return $this->hasMany(PipelineTemplateInstall::class, 'version_id'); + } +} diff --git a/database/factories/PipelineTemplateFactory.php b/database/factories/PipelineTemplateFactory.php new file mode 100644 index 0000000..b7ba347 --- /dev/null +++ b/database/factories/PipelineTemplateFactory.php @@ -0,0 +1,47 @@ +faker->words(3, true).' Template'; + + return [ + 'space_id' => null, + 'name' => $name, + 'slug' => Str::slug($name).'-'.Str::random(6), + 'description' => $this->faker->sentence(), + 'category' => $this->faker->randomElement(['content', 'seo', 'social', 'email', 'ecommerce']), + 'icon' => $this->faker->randomElement(['document', 'sparkles', 'megaphone', 'mail', 'shopping-cart']), + 'schema_version' => '1.0', + 'is_published' => false, + 'author_name' => $this->faker->name(), + 'author_url' => $this->faker->url(), + 'downloads_count' => 0, + ]; + } + + public function published(): static + { + return $this->state(['is_published' => true]); + } + + public function global(): static + { + return $this->state(['space_id' => null]); + } + + public function forSpace(Space $space): static + { + return $this->state(['space_id' => $space->id]); + } +} diff --git a/database/factories/PipelineTemplateInstallFactory.php b/database/factories/PipelineTemplateInstallFactory.php new file mode 100644 index 0000000..5f1f646 --- /dev/null +++ b/database/factories/PipelineTemplateInstallFactory.php @@ -0,0 +1,31 @@ + PipelineTemplate::factory(), + 'version_id' => PipelineTemplateVersion::factory(), + 'space_id' => Space::factory(), + 'pipeline_id' => null, + 'installed_at' => now(), + 'config_overrides' => null, + ]; + } + + public function withConfigOverrides(array $overrides = []): static + { + return $this->state(['config_overrides' => $overrides ?: ['persona_id' => 'custom']]); + } +} diff --git a/database/factories/PipelineTemplateRatingFactory.php b/database/factories/PipelineTemplateRatingFactory.php new file mode 100644 index 0000000..58ad4d8 --- /dev/null +++ b/database/factories/PipelineTemplateRatingFactory.php @@ -0,0 +1,28 @@ + PipelineTemplate::factory(), + 'user_id' => User::factory(), + 'rating' => $this->faker->numberBetween(1, 5), + 'review' => $this->faker->optional()->sentence(), + ]; + } + + public function withRating(int $rating): static + { + return $this->state(['rating' => $rating]); + } +} diff --git a/database/factories/PipelineTemplateVersionFactory.php b/database/factories/PipelineTemplateVersionFactory.php new file mode 100644 index 0000000..44d53ee --- /dev/null +++ b/database/factories/PipelineTemplateVersionFactory.php @@ -0,0 +1,44 @@ + PipelineTemplate::factory(), + 'version' => $this->faker->semver(), + 'definition' => [ + 'stages' => [ + ['name' => 'generate', 'type' => 'ai_generate', 'persona_role' => 'creator'], + ['name' => 'review', 'type' => 'human_gate'], + ['name' => 'publish', 'type' => 'auto_publish'], + ], + 'trigger' => 'manual', + ], + 'changelog' => $this->faker->sentence(), + 'is_latest' => false, + 'published_at' => null, + ]; + } + + public function latest(): static + { + return $this->state([ + 'is_latest' => true, + 'published_at' => now(), + ]); + } + + public function published(): static + { + return $this->state(['published_at' => now()]); + } +} diff --git a/database/migrations/2026_03_15_500001_create_pipeline_templates_table.php b/database/migrations/2026_03_15_500001_create_pipeline_templates_table.php new file mode 100644 index 0000000..bb72dbb --- /dev/null +++ b/database/migrations/2026_03_15_500001_create_pipeline_templates_table.php @@ -0,0 +1,35 @@ +ulid('id')->primary(); + $table->string('space_id', 26)->nullable()->index(); + $table->string('name'); + $table->string('slug')->unique(); + $table->text('description')->nullable(); + $table->string('category')->nullable(); + $table->string('icon')->nullable(); + $table->string('schema_version')->default('1.0'); + $table->boolean('is_published')->default(false); + $table->string('author_name')->nullable(); + $table->string('author_url')->nullable(); + $table->unsignedBigInteger('downloads_count')->default(0); + $table->timestamps(); + $table->softDeletes(); + }); + } + } + + public function down(): void + { + Schema::dropIfExists('pipeline_templates'); + } +}; diff --git a/database/migrations/2026_03_15_500002_create_pipeline_template_versions_table.php b/database/migrations/2026_03_15_500002_create_pipeline_template_versions_table.php new file mode 100644 index 0000000..39c96c3 --- /dev/null +++ b/database/migrations/2026_03_15_500002_create_pipeline_template_versions_table.php @@ -0,0 +1,29 @@ +ulid('id')->primary(); + $table->string('template_id', 26)->index(); + $table->string('version'); + $table->json('definition'); + $table->text('changelog')->nullable(); + $table->boolean('is_latest')->default(false); + $table->timestamp('published_at')->nullable(); + $table->timestamps(); + }); + } + } + + public function down(): void + { + Schema::dropIfExists('pipeline_template_versions'); + } +}; diff --git a/database/migrations/2026_03_15_500003_create_pipeline_template_installs_table.php b/database/migrations/2026_03_15_500003_create_pipeline_template_installs_table.php new file mode 100644 index 0000000..83dd59d --- /dev/null +++ b/database/migrations/2026_03_15_500003_create_pipeline_template_installs_table.php @@ -0,0 +1,29 @@ +ulid('id')->primary(); + $table->string('template_id', 26)->index(); + $table->string('version_id', 26)->index(); + $table->string('space_id', 26)->index(); + $table->string('pipeline_id', 26)->nullable()->index(); + $table->timestamp('installed_at')->useCurrent(); + $table->json('config_overrides')->nullable(); + $table->timestamps(); + }); + } + } + + public function down(): void + { + Schema::dropIfExists('pipeline_template_installs'); + } +}; diff --git a/database/migrations/2026_03_15_500004_create_pipeline_template_ratings_table.php b/database/migrations/2026_03_15_500004_create_pipeline_template_ratings_table.php new file mode 100644 index 0000000..b5ccd28 --- /dev/null +++ b/database/migrations/2026_03_15_500004_create_pipeline_template_ratings_table.php @@ -0,0 +1,27 @@ +ulid('id')->primary(); + $table->string('template_id', 26)->index(); + $table->string('user_id', 26)->index(); + $table->tinyInteger('rating')->unsigned(); + $table->text('review')->nullable(); + $table->timestamps(); + }); + } + } + + public function down(): void + { + Schema::dropIfExists('pipeline_template_ratings'); + } +}; diff --git a/tests/Feature/PipelineTemplateTest.php b/tests/Feature/PipelineTemplateTest.php new file mode 100644 index 0000000..05caf03 --- /dev/null +++ b/tests/Feature/PipelineTemplateTest.php @@ -0,0 +1,206 @@ +create(); + + $this->assertDatabaseHas('pipeline_templates', ['id' => $template->id]); + $this->assertNotEmpty($template->id); + $this->assertIsString($template->id); + $this->assertEquals(26, strlen($template->id)); + } + + public function test_pipeline_template_global_has_null_space_id(): void + { + $template = PipelineTemplate::factory()->global()->create(); + + $this->assertNull($template->space_id); + $this->assertTrue($template->isGlobal()); + } + + public function test_pipeline_template_for_space_has_space_id(): void + { + $space = Space::factory()->create(); + $template = PipelineTemplate::factory()->forSpace($space)->create(); + + $this->assertEquals($space->id, $template->space_id); + $this->assertFalse($template->isGlobal()); + } + + public function test_pipeline_template_published_state(): void + { + $template = PipelineTemplate::factory()->published()->create(); + + $this->assertTrue($template->is_published); + } + + public function test_pipeline_template_soft_deletes(): void + { + $template = PipelineTemplate::factory()->create(); + $id = $template->id; + + $template->delete(); + + $this->assertSoftDeleted('pipeline_templates', ['id' => $id]); + $this->assertNotNull(PipelineTemplate::withTrashed()->find($id)); + } + + public function test_pipeline_template_has_versions_relationship(): void + { + $template = PipelineTemplate::factory()->create(); + PipelineTemplateVersion::factory()->count(2)->create(['template_id' => $template->id]); + + $this->assertCount(2, $template->versions); + } + + public function test_pipeline_template_has_ratings_relationship(): void + { + $template = PipelineTemplate::factory()->create(); + PipelineTemplateRating::factory()->count(3)->create(['template_id' => $template->id]); + + $this->assertCount(3, $template->ratings); + } + + public function test_pipeline_template_average_rating(): void + { + $template = PipelineTemplate::factory()->create(); + PipelineTemplateRating::factory()->withRating(4)->create(['template_id' => $template->id]); + PipelineTemplateRating::factory()->withRating(2)->create(['template_id' => $template->id]); + + $this->assertEquals(3.0, $template->averageRating()); + } + + // ------------------------------------------------------------------------- + // PipelineTemplateVersion + // ------------------------------------------------------------------------- + + public function test_pipeline_template_version_factory_creates_record(): void + { + $version = PipelineTemplateVersion::factory()->create(); + + $this->assertDatabaseHas('pipeline_template_versions', ['id' => $version->id]); + $this->assertEquals(26, strlen($version->id)); + $this->assertIsArray($version->definition); + } + + public function test_pipeline_template_version_latest_state(): void + { + $version = PipelineTemplateVersion::factory()->latest()->create(); + + $this->assertTrue($version->is_latest); + $this->assertNotNull($version->published_at); + } + + public function test_pipeline_template_version_belongs_to_template(): void + { + $template = PipelineTemplate::factory()->create(); + $version = PipelineTemplateVersion::factory()->create(['template_id' => $template->id]); + + $this->assertEquals($template->id, $version->template->id); + } + + // ------------------------------------------------------------------------- + // PipelineTemplateInstall + // ------------------------------------------------------------------------- + + public function test_pipeline_template_install_factory_creates_record(): void + { + $install = PipelineTemplateInstall::factory()->create(); + + $this->assertDatabaseHas('pipeline_template_installs', ['id' => $install->id]); + $this->assertEquals(26, strlen($install->id)); + } + + public function test_pipeline_template_install_with_config_overrides(): void + { + $overrides = ['persona_id' => 'custom-persona-id']; + $install = PipelineTemplateInstall::factory()->withConfigOverrides($overrides)->create(); + + $this->assertEquals($overrides, $install->config_overrides); + } + + public function test_pipeline_template_install_nullable_pipeline_id(): void + { + $install = PipelineTemplateInstall::factory()->create(['pipeline_id' => null]); + + $this->assertNull($install->pipeline_id); + } + + public function test_pipeline_template_install_belongs_to_template(): void + { + $template = PipelineTemplate::factory()->create(); + $version = PipelineTemplateVersion::factory()->create(['template_id' => $template->id]); + $install = PipelineTemplateInstall::factory()->create([ + 'template_id' => $template->id, + 'version_id' => $version->id, + ]); + + $this->assertEquals($template->id, $install->template->id); + $this->assertEquals($version->id, $install->templateVersion->id); + } + + // ------------------------------------------------------------------------- + // PipelineTemplateRating + // ------------------------------------------------------------------------- + + public function test_pipeline_template_rating_factory_creates_record(): void + { + $rating = PipelineTemplateRating::factory()->create(); + + $this->assertDatabaseHas('pipeline_template_ratings', ['id' => $rating->id]); + $this->assertEquals(26, strlen($rating->id)); + $this->assertGreaterThanOrEqual(1, $rating->rating); + $this->assertLessThanOrEqual(5, $rating->rating); + } + + public function test_pipeline_template_rating_belongs_to_template(): void + { + $template = PipelineTemplate::factory()->create(); + $rating = PipelineTemplateRating::factory()->create(['template_id' => $template->id]); + + $this->assertEquals($template->id, $rating->template->id); + } + + public function test_pipeline_template_rating_belongs_to_user(): void + { + $user = User::factory()->create(); + $rating = PipelineTemplateRating::factory()->create(['user_id' => $user->id]); + + $this->assertEquals($user->id, $rating->user->id); + } + + public function test_pipeline_template_rating_with_specific_rating(): void + { + $rating = PipelineTemplateRating::factory()->withRating(5)->create(); + + $this->assertEquals(5, $rating->rating); + } +} From f5cf817d4eac7108ea0635297aa2875510d4bd97 Mon Sep 17 00:00:00 2001 From: numen-bot Date: Sun, 15 Mar 2026 19:32:36 +0000 Subject: [PATCH 02/11] feat(templates): TemplateSchemaValidator + JSON schema v1 + builder --- .../TemplateDefinitionBuilder.php | 153 +++++++++ .../TemplateSchemaValidator.php | 269 +++++++++++++++ .../PipelineTemplates/ValidationResult.php | 47 +++ .../TemplateSchemaValidatorTest.php | 312 ++++++++++++++++++ 4 files changed, 781 insertions(+) create mode 100644 app/Services/PipelineTemplates/TemplateDefinitionBuilder.php create mode 100644 app/Services/PipelineTemplates/TemplateSchemaValidator.php create mode 100644 app/Services/PipelineTemplates/ValidationResult.php create mode 100644 tests/Unit/PipelineTemplates/TemplateSchemaValidatorTest.php diff --git a/app/Services/PipelineTemplates/TemplateDefinitionBuilder.php b/app/Services/PipelineTemplates/TemplateDefinitionBuilder.php new file mode 100644 index 0000000..708ff4d --- /dev/null +++ b/app/Services/PipelineTemplates/TemplateDefinitionBuilder.php @@ -0,0 +1,153 @@ +> */ + private array $stages = []; + + /** @var array> */ + private array $personas = []; + + /** @var array */ + private array $settings = []; + + /** @var array> */ + private array $variables = []; + + public function __construct(private readonly TemplateSchemaValidator $validator) {} + + public function version(string $version): static + { + $this->version = $version; + + return $this; + } + + /** @param array $config */ + public function addStage( + string $type, + string $name, + array $config = [], + ?string $personaRef = null, + ?string $provider = null, + bool $enabled = true, + ): static { + $stage = ['type' => $type, 'name' => $name, 'config' => $config, 'enabled' => $enabled]; + if ($personaRef !== null) { + $stage['persona_ref'] = $personaRef; + } + if ($provider !== null) { + $stage['provider'] = $provider; + } + $this->stages[] = $stage; + + return $this; + } + + public function addPersona( + string $ref, + string $name, + string $systemPrompt, + string $llmProvider, + string $llmModel, + string $voiceGuidelines = '', + ): static { + $persona = [ + 'ref' => $ref, + 'name' => $name, + 'system_prompt' => $systemPrompt, + 'llm_provider' => $llmProvider, + 'llm_model' => $llmModel, + ]; + if ($voiceGuidelines !== '') { + $persona['voice_guidelines'] = $voiceGuidelines; + } + $this->personas[] = $persona; + + return $this; + } + + /** + * @param array $options Required for select/multiselect types. + */ + public function addVariable( + string $key, + string $type, + string $label, + mixed $default = null, + bool $required = false, + array $options = [], + ): static { + $variable = ['key' => $key, 'type' => $type, 'label' => $label, 'required' => $required]; + if ($default !== null) { + $variable['default'] = $default; + } + if ($options !== []) { + $variable['options'] = $options; + } + $this->variables[] = $variable; + + return $this; + } + + /** @param array $settings */ + public function setSettings(array $settings): static + { + $this->settings = array_merge($this->settings, $settings); + + return $this; + } + + /** + * Build and validate the definition. Throws on invalid schema. + * + * @return array + * + * @throws InvalidArgumentException + */ + public function build(): array + { + $definition = [ + 'version' => $this->version, + 'stages' => $this->stages, + 'personas' => $this->personas, + 'settings' => $this->settings, + ]; + if ($this->variables !== []) { + $definition['variables'] = $this->variables; + } + $result = $this->validator->validate($definition); + if (! $result->isValid()) { + throw new InvalidArgumentException('Invalid template definition: '.implode('; ', $result->errors())); + } + + return $definition; + } + + /** + * Build without throwing; returns definition + ValidationResult. + * + * @return array{definition: array, result: ValidationResult} + */ + public function buildWithValidation(): array + { + $definition = [ + 'version' => $this->version, + 'stages' => $this->stages, + 'personas' => $this->personas, + 'settings' => $this->settings, + ]; + if ($this->variables !== []) { + $definition['variables'] = $this->variables; + } + + return ['definition' => $definition, 'result' => $this->validator->validate($definition)]; + } +} diff --git a/app/Services/PipelineTemplates/TemplateSchemaValidator.php b/app/Services/PipelineTemplates/TemplateSchemaValidator.php new file mode 100644 index 0000000..d1d60eb --- /dev/null +++ b/app/Services/PipelineTemplates/TemplateSchemaValidator.php @@ -0,0 +1,269 @@ + $definition */ + public function validate(array $definition): ValidationResult + { + $errors = []; + $warnings = []; + if (! isset($definition['version'])) { + $errors[] = 'Missing required field: version'; + } elseif (! in_array($definition['version'], self::SUPPORTED_VERSIONS, true)) { + $errors[] = "Unsupported schema version: \"{$definition['version']}\""; + } + if (! isset($definition['stages'])) { + $errors[] = 'Missing required field: stages'; + } elseif (! is_array($definition['stages'])) { + $errors[] = 'Field "stages" must be an array'; + } elseif (empty($definition['stages'])) { + $errors[] = 'Field "stages" must contain at least one stage'; + } else { + $errors = array_merge($errors, $this->validateStages($definition['stages'])); + } + if (! isset($definition['personas'])) { + $errors[] = 'Missing required field: personas'; + } elseif (! is_array($definition['personas'])) { + $errors[] = 'Field "personas" must be an array'; + } else { + [$personaErrors, $personaWarnings] = $this->validatePersonas($definition['personas']); + $errors = array_merge($errors, $personaErrors); + $warnings = array_merge($warnings, $personaWarnings); + } + if (! isset($definition['settings'])) { + $errors[] = 'Missing required field: settings'; + } elseif (! is_array($definition['settings'])) { + $errors[] = 'Field "settings" must be an object/array'; + } else { + $errors = array_merge($errors, $this->validateSettings($definition['settings'])); + } + if (isset($definition['variables'])) { + if (! is_array($definition['variables'])) { + $errors[] = 'Field "variables" must be an array'; + } else { + $errors = array_merge($errors, $this->validateVariables($definition['variables'])); + } + } + if (empty($errors)) { + $refs = $this->collectPersonaRefs($definition['personas'] ?? []); + $errors = array_merge($errors, $this->validatePersonaRefs($definition['stages'] ?? [], $refs)); + } + + return empty($errors) ? ValidationResult::valid($warnings) : ValidationResult::invalid($errors, $warnings); + } + + /** + * @param array $stages + * @return array + */ + private function validateStages(array $stages): array + { + $errors = []; + $allowed = $this->getAllowedStageTypes(); + foreach ($stages as $i => $stage) { + $p = "stages[$i]"; + if (! is_array($stage)) { + $errors[] = "{$p}: Each stage must be an object"; + + continue; + } + if (! isset($stage['type']) || ! is_string($stage['type']) || $stage['type'] === '') { + $errors[] = "{$p}: Missing required field \"type\""; + } elseif (! in_array($stage['type'], $allowed, true)) { + $errors[] = "{$p}: Unknown stage type \"{$stage['type']}\". Allowed: ".implode(', ', $allowed); + } + if (! isset($stage['name']) || ! is_string($stage['name']) || $stage['name'] === '') { + $errors[] = "{$p}: Missing required field \"name\""; + } + if (! isset($stage['config'])) { + $errors[] = "{$p}: Missing required field \"config\""; + } elseif (! is_array($stage['config'])) { + $errors[] = "{$p}: Field \"config\" must be an object/array"; + } + if (isset($stage['persona_ref']) && ! is_string($stage['persona_ref'])) { + $errors[] = "{$p}: Field \"persona_ref\" must be a string"; + } + if (isset($stage['provider']) && ! is_string($stage['provider'])) { + $errors[] = "{$p}: Field \"provider\" must be a string"; + } + if (isset($stage['enabled']) && ! is_bool($stage['enabled'])) { + $errors[] = "{$p}: Field \"enabled\" must be a boolean"; + } + } + + return $errors; + } + + /** + * @param array $personas + * @return array{0: array, 1: array} + */ + private function validatePersonas(array $personas): array + { + $errors = []; + $warnings = []; + $refs = []; + foreach ($personas as $i => $persona) { + $p = "personas[$i]"; + if (! is_array($persona)) { + $errors[] = "{$p}: Each persona must be an object"; + + continue; + } + foreach (['ref', 'name', 'system_prompt', 'llm_provider', 'llm_model'] as $field) { + if (! isset($persona[$field]) || ! is_string($persona[$field]) || $persona[$field] === '') { + $errors[] = "{$p}: Missing required field \"{$field}\""; + } + } + if (isset($persona['voice_guidelines']) && ! is_string($persona['voice_guidelines'])) { + $errors[] = "{$p}: Field \"voice_guidelines\" must be a string"; + } + if (isset($persona['ref'])) { + if (in_array($persona['ref'], $refs, true)) { + $errors[] = "{$p}: Duplicate persona ref \"{$persona['ref']}\""; + } else { + $refs[] = $persona['ref']; + } + } + } + + return [$errors, $warnings]; + } + + /** + * @param array $settings + * @return array + */ + private function validateSettings(array $settings): array + { + $errors = []; + if (isset($settings['auto_publish']) && ! is_bool($settings['auto_publish'])) { + $errors[] = 'settings.auto_publish must be a boolean'; + } + if (isset($settings['review_required']) && ! is_bool($settings['review_required'])) { + $errors[] = 'settings.review_required must be a boolean'; + } + if (isset($settings['max_retries']) && ! is_int($settings['max_retries'])) { + $errors[] = 'settings.max_retries must be an integer'; + } + if (isset($settings['timeout_seconds']) && ! is_int($settings['timeout_seconds'])) { + $errors[] = 'settings.timeout_seconds must be an integer'; + } + + return $errors; + } + + /** + * @param array $variables + * @return array + */ + private function validateVariables(array $variables): array + { + $errors = []; + $keys = []; + foreach ($variables as $i => $variable) { + $p = "variables[$i]"; + if (! is_array($variable)) { + $errors[] = "{$p}: Each variable must be an object"; + + continue; + } + if (! isset($variable['key']) || ! is_string($variable['key']) || $variable['key'] === '') { + $errors[] = "{$p}: Missing required field \"key\""; + } else { + if (! preg_match('/^[a-zA-Z_][a-zA-Z0-9_]*$/', $variable['key'])) { + $errors[] = "{$p}: Field \"key\" must be a valid identifier"; + } + if (in_array($variable['key'], $keys, true)) { + $errors[] = "{$p}: Duplicate variable key \"{$variable['key']}\""; + } else { + $keys[] = $variable['key']; + } + } + if (! isset($variable['type']) || ! is_string($variable['type'])) { + $errors[] = "{$p}: Missing required field \"type\""; + } elseif (! in_array($variable['type'], self::VARIABLE_TYPES, true)) { + $errors[] = "{$p}: Invalid variable type \"{$variable['type']}\""; + } + if (! isset($variable['label']) || ! is_string($variable['label']) || $variable['label'] === '') { + $errors[] = "{$p}: Missing required field \"label\""; + } + if (isset($variable['required']) && ! is_bool($variable['required'])) { + $errors[] = "{$p}: Field \"required\" must be a boolean"; + } + if (isset($variable['type']) && in_array($variable['type'], ['select', 'multiselect'], true)) { + if (! isset($variable['options']) || ! is_array($variable['options']) || empty($variable['options'])) { + $errors[] = "{$p}: Variable of type \"{$variable['type']}\" must include a non-empty \"options\" array"; + } + } + } + + return $errors; + } + + /** + * @param array $stages + * @param array $personaRefs + * @return array + */ + private function validatePersonaRefs(array $stages, array $personaRefs): array + { + $errors = []; + foreach ($stages as $i => $stage) { + if (! is_array($stage)) { + continue; + } + if (isset($stage['persona_ref']) && is_string($stage['persona_ref'])) { + if (! in_array($stage['persona_ref'], $personaRefs, true)) { + $errors[] = "stages[$i]: persona_ref \"{$stage['persona_ref']}\" does not reference any defined persona"; + } + } + } + + return $errors; + } + + /** + * @param array $personas + * @return array + */ + private function collectPersonaRefs(array $personas): array + { + $refs = []; + foreach ($personas as $persona) { + if (is_array($persona) && isset($persona['ref']) && is_string($persona['ref'])) { + $refs[] = $persona['ref']; + } + } + + return $refs; + } + + /** @return array */ + private function getAllowedStageTypes(): array + { + return array_unique(array_merge( + self::CORE_STAGE_TYPES, + $this->hookRegistry->getRegisteredPipelineStageTypes(), + )); + } +} diff --git a/app/Services/PipelineTemplates/ValidationResult.php b/app/Services/PipelineTemplates/ValidationResult.php new file mode 100644 index 0000000..10cce2c --- /dev/null +++ b/app/Services/PipelineTemplates/ValidationResult.php @@ -0,0 +1,47 @@ + $errors + * @param array $warnings + */ + public function __construct( + private readonly array $errors = [], + private readonly array $warnings = [], + ) {} + + /** @param array $warnings */ + public static function valid(array $warnings = []): self + { + return new self([], $warnings); + } + + /** + * @param array $errors + * @param array $warnings + */ + public static function invalid(array $errors, array $warnings = []): self + { + return new self($errors, $warnings); + } + + public function isValid(): bool + { + return empty($this->errors); + } + + /** @return array */ + public function errors(): array + { + return $this->errors; + } + + /** @return array */ + public function warnings(): array + { + return $this->warnings; + } +} diff --git a/tests/Unit/PipelineTemplates/TemplateSchemaValidatorTest.php b/tests/Unit/PipelineTemplates/TemplateSchemaValidatorTest.php new file mode 100644 index 0000000..3bb3201 --- /dev/null +++ b/tests/Unit/PipelineTemplates/TemplateSchemaValidatorTest.php @@ -0,0 +1,312 @@ +registry = new HookRegistry; + $this->validator = new TemplateSchemaValidator($this->registry); + } + + /** @return array */ + private function validDefinition(): array + { + return [ + 'version' => '1.0', + 'stages' => [[ + 'type' => 'ai_generate', + 'name' => 'Generate Article', + 'config' => ['prompt' => 'Write about {brand_name}'], + 'persona_ref' => 'writer', + 'enabled' => true, + ]], + 'personas' => [[ + 'ref' => 'writer', + 'name' => 'Writer Persona', + 'system_prompt' => 'You are a skilled writer.', + 'voice_guidelines' => 'Professional and clear.', + 'llm_provider' => 'anthropic', + 'llm_model' => 'claude-3-5-sonnet-20241022', + ]], + 'settings' => ['auto_publish' => false, 'review_required' => true], + 'variables' => [[ + 'key' => 'brand_name', + 'type' => 'string', + 'label' => 'Brand Name', + 'required' => true, + ]], + ]; + } + + public function test_valid_schema_passes(): void + { + $result = $this->validator->validate($this->validDefinition()); + $this->assertTrue($result->isValid()); + $this->assertEmpty($result->errors()); + } + + public function test_valid_schema_without_variables_passes(): void + { + $def = $this->validDefinition(); + unset($def['variables']); + $result = $this->validator->validate($def); + $this->assertTrue($result->isValid()); + } + + public function test_missing_version_fails(): void + { + $def = $this->validDefinition(); + unset($def['version']); + $result = $this->validator->validate($def); + $this->assertFalse($result->isValid()); + $this->assertStringContainsString('version', $result->errors()[0]); + } + + public function test_unsupported_version_fails(): void + { + $def = $this->validDefinition(); + $def['version'] = '99.0'; + $result = $this->validator->validate($def); + $this->assertFalse($result->isValid()); + $this->assertStringContainsString('Unsupported schema version', $result->errors()[0]); + } + + public function test_missing_stages_fails(): void + { + $def = $this->validDefinition(); + unset($def['stages']); + $result = $this->validator->validate($def); + $this->assertFalse($result->isValid()); + $this->assertContains('Missing required field: stages', $result->errors()); + } + + public function test_empty_stages_fails(): void + { + $def = $this->validDefinition(); + $def['stages'] = []; + $result = $this->validator->validate($def); + $this->assertFalse($result->isValid()); + } + + public function test_missing_personas_fails(): void + { + $def = $this->validDefinition(); + unset($def['personas']); + $result = $this->validator->validate($def); + $this->assertFalse($result->isValid()); + $this->assertContains('Missing required field: personas', $result->errors()); + } + + public function test_missing_settings_fails(): void + { + $def = $this->validDefinition(); + unset($def['settings']); + $result = $this->validator->validate($def); + $this->assertFalse($result->isValid()); + $this->assertContains('Missing required field: settings', $result->errors()); + } + + public function test_invalid_stage_type_fails(): void + { + $def = $this->validDefinition(); + $def['stages'][0]['type'] = 'does_not_exist'; + $result = $this->validator->validate($def); + $this->assertFalse($result->isValid()); + $this->assertStringContainsString('Unknown stage type', $result->errors()[0]); + } + + public function test_missing_stage_name_fails(): void + { + $def = $this->validDefinition(); + unset($def['stages'][0]['name']); + $result = $this->validator->validate($def); + $this->assertFalse($result->isValid()); + $this->assertStringContainsString('"name"', $result->errors()[0]); + } + + public function test_missing_stage_config_fails(): void + { + $def = $this->validDefinition(); + unset($def['stages'][0]['config']); + $result = $this->validator->validate($def); + $this->assertFalse($result->isValid()); + $this->assertStringContainsString('"config"', $result->errors()[0]); + } + + public function test_custom_stage_type_via_hook_passes(): void + { + $mockHandler = new class implements PipelineStageContract + { + public static function type(): string + { + return 'custom_plugin_stage'; + } + + public static function label(): string + { + return 'Custom Stage'; + } + + public static function configSchema(): array + { + return []; + } + + public function handle(PipelineRun $run, array $stageConfig): array + { + return ['success' => true]; + } + }; + $this->registry->registerPipelineStageClass('custom_plugin_stage', $mockHandler::class); + $def = $this->validDefinition(); + $def['stages'][0]['type'] = 'custom_plugin_stage'; + $result = $this->validator->validate($def); + $this->assertTrue($result->isValid(), implode('; ', $result->errors())); + } + + public function test_missing_persona_field_fails(): void + { + $def = $this->validDefinition(); + unset($def['personas'][0]['llm_model']); + $result = $this->validator->validate($def); + $this->assertFalse($result->isValid()); + $this->assertStringContainsString('llm_model', $result->errors()[0]); + } + + public function test_duplicate_persona_ref_fails(): void + { + $def = $this->validDefinition(); + $def['personas'][] = $def['personas'][0]; + $result = $this->validator->validate($def); + $this->assertFalse($result->isValid()); + $this->assertStringContainsString('Duplicate persona ref', $result->errors()[0]); + } + + public function test_stage_with_unknown_persona_ref_fails(): void + { + $def = $this->validDefinition(); + $def['stages'][0]['persona_ref'] = 'nonexistent_persona'; + $result = $this->validator->validate($def); + $this->assertFalse($result->isValid()); + $this->assertStringContainsString('does not reference any defined persona', $result->errors()[0]); + } + + public function test_invalid_variable_type_fails(): void + { + $def = $this->validDefinition(); + $def['variables'][0]['type'] = 'blob'; + $result = $this->validator->validate($def); + $this->assertFalse($result->isValid()); + $this->assertStringContainsString('Invalid variable type', $result->errors()[0]); + } + + public function test_select_variable_without_options_fails(): void + { + $def = $this->validDefinition(); + $def['variables'][] = [ + 'key' => 'my_select', 'type' => 'select', + 'label' => 'Pick one', 'required' => false, + ]; + $result = $this->validator->validate($def); + $this->assertFalse($result->isValid()); + $this->assertStringContainsString('"options"', $result->errors()[0]); + } + + public function test_select_variable_with_options_passes(): void + { + $def = $this->validDefinition(); + $def['variables'][] = [ + 'key' => 'my_select', 'type' => 'select', 'label' => 'Pick one', + 'required' => false, 'options' => ['a', 'b', 'c'], + ]; + $result = $this->validator->validate($def); + $this->assertTrue($result->isValid(), implode('; ', $result->errors())); + } + + public function test_duplicate_variable_key_fails(): void + { + $def = $this->validDefinition(); + $def['variables'][] = $def['variables'][0]; + $result = $this->validator->validate($def); + $this->assertFalse($result->isValid()); + $this->assertStringContainsString('Duplicate variable key', $result->errors()[0]); + } + + public function test_invalid_variable_key_identifier_fails(): void + { + $def = $this->validDefinition(); + $def['variables'][0]['key'] = '1invalid'; + $result = $this->validator->validate($def); + $this->assertFalse($result->isValid()); + $this->assertStringContainsString('valid identifier', $result->errors()[0]); + } + + public function test_non_bool_auto_publish_fails(): void + { + $def = $this->validDefinition(); + $def['settings']['auto_publish'] = 'yes'; + $result = $this->validator->validate($def); + $this->assertFalse($result->isValid()); + $this->assertStringContainsString('auto_publish', $result->errors()[0]); + } + + public function test_validation_result_valid_factory(): void + { + $result = ValidationResult::valid(['a warning']); + $this->assertTrue($result->isValid()); + $this->assertEmpty($result->errors()); + $this->assertEquals(['a warning'], $result->warnings()); + } + + public function test_validation_result_invalid_factory(): void + { + $result = ValidationResult::invalid(['error one', 'error two']); + $this->assertFalse($result->isValid()); + $this->assertCount(2, $result->errors()); + } + + public function test_builder_produces_valid_output(): void + { + $builder = new TemplateDefinitionBuilder($this->validator); + $def = $builder + ->addPersona('writer', 'Writer', 'You write content.', 'anthropic', 'claude-3-5-sonnet-20241022') + ->addStage('ai_generate', 'Generate', ['prompt' => 'Write about {brand}'], 'writer') + ->addVariable('brand', 'string', 'Brand Name', null, true) + ->setSettings(['auto_publish' => false, 'review_required' => true]) + ->build(); + $this->assertEquals('1.0', $def['version']); + $this->assertCount(1, $def['stages']); + $this->assertCount(1, $def['personas']); + $this->assertArrayHasKey('brand', array_column($def['variables'], 'key', 'key')); + } + + public function test_builder_throws_on_invalid_definition(): void + { + $this->expectException(\InvalidArgumentException::class); + $builder = new TemplateDefinitionBuilder($this->validator); + $builder->build(); + } + + public function test_builder_with_validation_returns_result(): void + { + $builder = new TemplateDefinitionBuilder($this->validator); + ['definition' => $def, 'result' => $result] = $builder->buildWithValidation(); + $this->assertInstanceOf(ValidationResult::class, $result); + $this->assertArrayHasKey('version', $def); + } +} From aacec0682793cfa1a6fe08f5787c780cb7982178 Mon Sep 17 00:00:00 2001 From: numen-bot Date: Sun, 15 Mar 2026 19:41:46 +0000 Subject: [PATCH 03/11] feat(templates): PipelineTemplateService (CRUD, versioning, import/export, 8 built-in templates) --- .../PipelineTemplateService.php | 217 +++++++++++++ database/seeders/BuiltInTemplateSeeder.php | 304 ++++++++++++++++++ tests/Feature/PipelineTemplateServiceTest.php | 249 ++++++++++++++ 3 files changed, 770 insertions(+) create mode 100644 app/Services/PipelineTemplates/PipelineTemplateService.php create mode 100644 database/seeders/BuiltInTemplateSeeder.php create mode 100644 tests/Feature/PipelineTemplateServiceTest.php diff --git a/app/Services/PipelineTemplates/PipelineTemplateService.php b/app/Services/PipelineTemplates/PipelineTemplateService.php new file mode 100644 index 0000000..4bda5fc --- /dev/null +++ b/app/Services/PipelineTemplates/PipelineTemplateService.php @@ -0,0 +1,217 @@ + $data */ + public function create(Space $space, array $data): PipelineTemplate + { + $slug = $data['slug'] ?? Str::slug($data['name'] ?? ''); + + return PipelineTemplate::create([ + 'space_id' => $space->id, + 'name' => $data['name'], + 'slug' => $this->uniqueSlug($slug), + 'description' => $data['description'] ?? null, + 'category' => $data['category'] ?? null, + 'icon' => $data['icon'] ?? null, + 'schema_version' => $data['schema_version'] ?? '1.0', + 'is_published' => false, + 'author_name' => $data['author_name'] ?? null, + 'author_url' => $data['author_url'] ?? null, + ]); + } + + /** @param array $data */ + public function update(PipelineTemplate $template, array $data): PipelineTemplate + { + $fillable = ['name', 'description', 'category', 'icon', 'author_name', 'author_url']; + $updates = array_intersect_key($data, array_flip($fillable)); + + if (isset($data['slug'])) { + $updates['slug'] = $data['slug'] === $template->slug + ? $template->slug + : $this->uniqueSlug($data['slug'], $template->id); + } + + $template->update($updates); + + return $template->refresh(); + } + + public function delete(PipelineTemplate $template): void + { + $template->delete(); + } + + public function publish(PipelineTemplate $template): void + { + $template->update(['is_published' => true, 'space_id' => null]); + } + + public function unpublish(PipelineTemplate $template): void + { + $template->update(['is_published' => false]); + } + + // ------------------------------------------------------------------------- + // Version management + // ------------------------------------------------------------------------- + + /** @param array $definition */ + public function createVersion( + PipelineTemplate $template, + array $definition, + string $version, + ?string $changelog = null, + ): PipelineTemplateVersion { + $result = $this->validator->validate($definition); + + if (! $result->isValid()) { + throw new InvalidArgumentException( + 'Template definition is invalid: '.implode('; ', $result->errors()), + ); + } + + return DB::transaction(function () use ($template, $definition, $version, $changelog): PipelineTemplateVersion { + $template->versions()->where('is_latest', true)->update(['is_latest' => false]); + + return PipelineTemplateVersion::create([ + 'template_id' => $template->id, + 'version' => $version, + 'definition' => $definition, + 'changelog' => $changelog, + 'is_latest' => true, + 'published_at' => now(), + ]); + }); + } + + // ------------------------------------------------------------------------- + // Import / Export + // ------------------------------------------------------------------------- + + /** @return array */ + public function export(PipelineTemplate $template): array + { + /** @var PipelineTemplateVersion|null $latestVersion */ + $latestVersion = $template->versions()->where('is_latest', true)->first(); + + return [ + 'numen_export' => '1.0', + 'exported_at' => now()->toIso8601String(), + 'template' => [ + 'name' => $template->name, + 'slug' => $template->slug, + 'description' => $template->description, + 'category' => $template->category, + 'icon' => $template->icon, + 'schema_version' => $template->schema_version, + 'author_name' => $template->author_name, + 'author_url' => $template->author_url, + ], + 'version' => $latestVersion ? [ + 'version' => $latestVersion->version, + 'changelog' => $latestVersion->changelog, + 'definition' => $latestVersion->definition, + ] : null, + ]; + } + + /** @param array $data */ + public function import(Space $space, array $data): PipelineTemplate + { + if (! isset($data['template'])) { + throw new InvalidArgumentException('Import data is missing the "template" key.'); + } + + return DB::transaction(function () use ($space, $data): PipelineTemplate { + $templateData = $data['template']; + $template = $this->create($space, $templateData); + + if (isset($data['version']) && is_array($data['version'])) { + $v = $data['version']; + $this->createVersion( + $template, + $v['definition'] ?? [], + $v['version'] ?? '1.0.0', + $v['changelog'] ?? null, + ); + } + + return $template->refresh(); + }); + } + + public function exportToFile(PipelineTemplate $template): string + { + $payload = $this->export($template); + $filename = 'pipeline-templates/'.$template->slug.'-'.now()->format('Ymd_His').'.json'; + + Storage::put($filename, (string) json_encode($payload, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE)); + + return Storage::path($filename); + } + + public function importFromFile(Space $space, string $path): PipelineTemplate + { + if (! file_exists($path)) { + throw new RuntimeException("Import file not found: {$path}"); + } + + $contents = file_get_contents($path); + + if ($contents === false) { + throw new RuntimeException("Unable to read file: {$path}"); + } + + /** @var array $data */ + $data = json_decode($contents, true, 512, JSON_THROW_ON_ERROR); + + return $this->import($space, $data); + } + + // ------------------------------------------------------------------------- + // Helpers + // ------------------------------------------------------------------------- + + private function uniqueSlug(string $base, ?string $excludeId = null): string + { + $slug = $base !== '' ? $base : 'template'; + $count = 0; + $candidate = $slug; + + do { + $query = PipelineTemplate::withTrashed()->where('slug', $candidate); + if ($excludeId !== null) { + $query->where('id', '!=', $excludeId); + } + $exists = $query->exists(); + + if ($exists) { + $count++; + $candidate = $slug.'-'.$count; + } + } while ($exists); + + return $candidate; + } +} diff --git a/database/seeders/BuiltInTemplateSeeder.php b/database/seeders/BuiltInTemplateSeeder.php new file mode 100644 index 0000000..9eaaafc --- /dev/null +++ b/database/seeders/BuiltInTemplateSeeder.php @@ -0,0 +1,304 @@ +templates() as $spec) { + $exists = PipelineTemplate::withTrashed() + ->where('slug', $spec['slug']) + ->whereNull('space_id') + ->exists(); + + if ($exists) { + continue; + } + + $template = PipelineTemplate::create([ + 'space_id' => null, + 'name' => $spec['name'], + 'slug' => $spec['slug'], + 'description' => $spec['description'], + 'category' => $spec['category'], + 'icon' => $spec['icon'], + 'schema_version' => '1.0', + 'is_published' => true, + 'author_name' => 'Numen', + 'author_url' => 'https://numen.ai', + ]); + + PipelineTemplateVersion::create([ + 'template_id' => $template->id, + 'version' => '1.0.0', + 'definition' => $spec['definition'], + 'changelog' => 'Initial built-in template.', + 'is_latest' => true, + 'published_at' => now(), + ]); + } + } + + /** @return array> */ + private function templates(): array + { + return [ + $this->blogPostPipeline(), + $this->socialMediaCampaign(), + $this->productDescription(), + $this->emailNewsletter(), + $this->pressRelease(), + $this->landingPage(), + $this->technicalDocumentation(), + $this->videoScript(), + ]; + } + + /** @return array */ + private function blogPostPipeline(): array + { + return [ + 'name' => 'Blog Post Pipeline', + 'slug' => 'blog-post-pipeline', + 'description' => 'Full blog post creation pipeline: outline, draft, SEO review, human gate, publish.', + 'category' => 'content', + 'icon' => 'pencil', + 'definition' => [ + 'version' => '1.0', + 'personas' => [ + ['ref' => 'blog_writer', 'name' => 'Blog Writer', 'system_prompt' => 'You are an expert blog writer producing engaging, well-structured long-form content.', 'llm_provider' => 'openai', 'llm_model' => 'gpt-4o', 'voice_guidelines' => 'Conversational, informative, SEO-aware.'], + ['ref' => 'seo_reviewer', 'name' => 'SEO Reviewer', 'system_prompt' => 'You are an SEO specialist who reviews content for keyword density, meta descriptions, and readability.', 'llm_provider' => 'openai', 'llm_model' => 'gpt-4o-mini'], + ], + 'stages' => [ + ['type' => 'ai_generate', 'name' => 'Outline', 'persona_ref' => 'blog_writer', 'config' => ['prompt_template' => 'Create a detailed outline for a blog post about {topic}.'], 'enabled' => true], + ['type' => 'ai_generate', 'name' => 'Draft', 'persona_ref' => 'blog_writer', 'config' => ['prompt_template' => 'Write a full blog post based on this outline: {outline}'], 'enabled' => true], + ['type' => 'ai_review', 'name' => 'SEO Check', 'persona_ref' => 'seo_reviewer', 'config' => ['prompt_template' => 'Review this blog post for SEO quality: {draft}'], 'enabled' => true], + ['type' => 'human_gate', 'name' => 'Editor Approval', 'config' => ['instructions' => 'Review and approve the blog post before publishing.'], 'enabled' => true], + ['type' => 'auto_publish', 'name' => 'Publish', 'config' => ['target' => 'cms'], 'enabled' => true], + ], + 'settings' => ['auto_publish' => false, 'review_required' => true, 'max_retries' => 3, 'timeout_seconds' => 300], + 'variables' => [ + ['key' => 'topic', 'type' => 'string', 'label' => 'Blog Topic', 'required' => true], + ['key' => 'keywords', 'type' => 'text', 'label' => 'Target Keywords', 'required' => false], + ], + ], + ]; + } + + /** @return array */ + private function socialMediaCampaign(): array + { + return [ + 'name' => 'Social Media Campaign', + 'slug' => 'social-media-campaign', + 'description' => 'Generate platform-specific social media posts from a single campaign brief.', + 'category' => 'social', + 'icon' => 'megaphone', + 'definition' => [ + 'version' => '1.0', + 'personas' => [ + ['ref' => 'social_copywriter', 'name' => 'Social Copywriter', 'system_prompt' => 'You are a social media expert who crafts engaging, platform-native copy.', 'llm_provider' => 'openai', 'llm_model' => 'gpt-4o', 'voice_guidelines' => 'Punchy, trend-aware, emoji-friendly.'], + ], + 'stages' => [ + ['type' => 'ai_generate', 'name' => 'Twitter Post', 'persona_ref' => 'social_copywriter', 'config' => ['prompt_template' => 'Write a Twitter post (max 280 chars) for: {campaign_brief}'], 'enabled' => true], + ['type' => 'ai_generate', 'name' => 'LinkedIn Post', 'persona_ref' => 'social_copywriter', 'config' => ['prompt_template' => 'Write a professional LinkedIn post for: {campaign_brief}'], 'enabled' => true], + ['type' => 'ai_generate', 'name' => 'Instagram Caption', 'persona_ref' => 'social_copywriter', 'config' => ['prompt_template' => 'Write an Instagram caption with hashtags for: {campaign_brief}'], 'enabled' => true], + ['type' => 'ai_review', 'name' => 'Brand Voice Check', 'persona_ref' => 'social_copywriter', 'config' => ['prompt_template' => 'Check the following posts for brand consistency: {posts}'], 'enabled' => true], + ], + 'settings' => ['auto_publish' => false, 'review_required' => true, 'max_retries' => 2, 'timeout_seconds' => 180], + 'variables' => [ + ['key' => 'campaign_brief', 'type' => 'text', 'label' => 'Campaign Brief', 'required' => true], + ['key' => 'brand_tone', 'type' => 'select', 'label' => 'Brand Tone', 'required' => false, 'options' => ['professional', 'playful', 'inspirational', 'edgy']], + ], + ], + ]; + } + + /** @return array */ + private function productDescription(): array + { + return [ + 'name' => 'Product Description', + 'slug' => 'product-description', + 'description' => 'Generate compelling product descriptions with feature bullets and SEO optimization.', + 'category' => 'ecommerce', + 'icon' => 'cart', + 'definition' => [ + 'version' => '1.0', + 'personas' => [ + ['ref' => 'product_copywriter', 'name' => 'Product Copywriter', 'system_prompt' => 'You write compelling product descriptions that convert browsers into buyers.', 'llm_provider' => 'openai', 'llm_model' => 'gpt-4o', 'voice_guidelines' => 'Benefit-led, clear, persuasive.'], + ], + 'stages' => [ + ['type' => 'ai_generate', 'name' => 'Long Description', 'persona_ref' => 'product_copywriter', 'config' => ['prompt_template' => 'Write a detailed product description for {product_name}: {product_details}'], 'enabled' => true], + ['type' => 'ai_transform', 'name' => 'Feature Bullets', 'persona_ref' => 'product_copywriter', 'config' => ['prompt_template' => 'Extract 5 key feature bullets from: {long_description}'], 'enabled' => true], + ['type' => 'ai_transform', 'name' => 'Meta Description', 'persona_ref' => 'product_copywriter', 'config' => ['prompt_template' => 'Write a 160-char SEO meta description for: {long_description}'], 'enabled' => true], + ], + 'settings' => ['auto_publish' => true, 'review_required' => false, 'max_retries' => 3, 'timeout_seconds' => 120], + 'variables' => [ + ['key' => 'product_name', 'type' => 'string', 'label' => 'Product Name', 'required' => true], + ['key' => 'product_details', 'type' => 'text', 'label' => 'Product Details', 'required' => true], + ['key' => 'target_audience', 'type' => 'string', 'label' => 'Target Audience', 'required' => false], + ], + ], + ]; + } + + /** @return array */ + private function emailNewsletter(): array + { + return [ + 'name' => 'Email Newsletter', + 'slug' => 'email-newsletter', + 'description' => 'Create full email newsletters with subject line variants and CTA optimisation.', + 'category' => 'email', + 'icon' => 'email', + 'definition' => [ + 'version' => '1.0', + 'personas' => [ + ['ref' => 'email_writer', 'name' => 'Email Writer', 'system_prompt' => 'You are an expert email marketer who writes newsletters people actually read.', 'llm_provider' => 'openai', 'llm_model' => 'gpt-4o', 'voice_guidelines' => 'Warm, direct, action-oriented.'], + ], + 'stages' => [ + ['type' => 'ai_generate', 'name' => 'Subject Lines', 'persona_ref' => 'email_writer', 'config' => ['prompt_template' => 'Write 5 email subject line variants for a newsletter about: {newsletter_topic}'], 'enabled' => true], + ['type' => 'ai_generate', 'name' => 'Newsletter Body', 'persona_ref' => 'email_writer', 'config' => ['prompt_template' => 'Write a newsletter body for: {newsletter_topic}. Include intro, 3 sections, and a CTA.'], 'enabled' => true], + ['type' => 'ai_review', 'name' => 'Spam Check', 'persona_ref' => 'email_writer', 'config' => ['prompt_template' => 'Review this email for spam triggers and deliverability issues: {body}'], 'enabled' => true], + ['type' => 'human_gate', 'name' => 'Final Approval', 'config' => ['instructions' => 'Review and approve the newsletter before sending.'], 'enabled' => true], + ], + 'settings' => ['auto_publish' => false, 'review_required' => true, 'max_retries' => 2, 'timeout_seconds' => 240], + 'variables' => [ + ['key' => 'newsletter_topic', 'type' => 'string', 'label' => 'Newsletter Topic', 'required' => true], + ['key' => 'send_date', 'type' => 'string', 'label' => 'Planned Send Date', 'required' => false], + ], + ], + ]; + } + + /** @return array */ + private function pressRelease(): array + { + return [ + 'name' => 'Press Release', + 'slug' => 'press-release', + 'description' => 'Generate professional press releases with datelines, quotes, and boilerplate.', + 'category' => 'pr', + 'icon' => 'newspaper', + 'definition' => [ + 'version' => '1.0', + 'personas' => [ + ['ref' => 'pr_writer', 'name' => 'PR Writer', 'system_prompt' => 'You are a public relations professional who writes clear, factual press releases in AP style.', 'llm_provider' => 'openai', 'llm_model' => 'gpt-4o', 'voice_guidelines' => 'Formal, objective, newswire-ready.'], + ['ref' => 'legal_reviewer', 'name' => 'Legal Reviewer', 'system_prompt' => 'You review press releases for legal risks, false claims, and compliance issues.', 'llm_provider' => 'openai', 'llm_model' => 'gpt-4o-mini'], + ], + 'stages' => [ + ['type' => 'ai_generate', 'name' => 'Draft Press Release', 'persona_ref' => 'pr_writer', 'config' => ['prompt_template' => 'Write a press release for: {announcement}. Company: {company_name}. Contact: {contact_info}'], 'enabled' => true], + ['type' => 'ai_review', 'name' => 'Legal Review', 'persona_ref' => 'legal_reviewer', 'config' => ['prompt_template' => 'Review this press release for legal risks: {draft}'], 'enabled' => true], + ['type' => 'human_gate', 'name' => 'Executive Sign-off', 'config' => ['instructions' => 'Route to executive for approval before distribution.'], 'enabled' => true], + ], + 'settings' => ['auto_publish' => false, 'review_required' => true, 'max_retries' => 2, 'timeout_seconds' => 300], + 'variables' => [ + ['key' => 'announcement', 'type' => 'text', 'label' => 'Announcement Details', 'required' => true], + ['key' => 'company_name', 'type' => 'string', 'label' => 'Company Name', 'required' => true], + ['key' => 'contact_info', 'type' => 'text', 'label' => 'Media Contact Info', 'required' => true], + ['key' => 'embargo_date', 'type' => 'string', 'label' => 'Embargo Date', 'required' => false], + ], + ], + ]; + } + + /** @return array */ + private function landingPage(): array + { + return [ + 'name' => 'Landing Page', + 'slug' => 'landing-page', + 'description' => 'Craft high-converting landing page copy: headline, hero, benefits, social proof, CTA.', + 'category' => 'marketing', + 'icon' => 'rocket', + 'definition' => [ + 'version' => '1.0', + 'personas' => [ + ['ref' => 'conversion_copywriter', 'name' => 'Conversion Copywriter', 'system_prompt' => 'You are a direct-response copywriter specialising in high-converting landing pages.', 'llm_provider' => 'openai', 'llm_model' => 'gpt-4o', 'voice_guidelines' => 'Benefit-driven, urgent, customer-centric.'], + ], + 'stages' => [ + ['type' => 'ai_generate', 'name' => 'Headline Variants', 'persona_ref' => 'conversion_copywriter', 'config' => ['prompt_template' => 'Write 5 headline variants for a landing page about {offer}.'], 'enabled' => true], + ['type' => 'ai_generate', 'name' => 'Hero Section', 'persona_ref' => 'conversion_copywriter', 'config' => ['prompt_template' => 'Write hero copy (headline, subheadline, CTA) for: {offer}'], 'enabled' => true], + ['type' => 'ai_generate', 'name' => 'Benefits Section', 'persona_ref' => 'conversion_copywriter', 'config' => ['prompt_template' => 'Write 6 key benefits for: {offer}. Target audience: {target_audience}'], 'enabled' => true], + ['type' => 'ai_generate', 'name' => 'CTA Copy', 'persona_ref' => 'conversion_copywriter', 'config' => ['prompt_template' => 'Write 3 CTA button text variants and supporting copy for: {offer}'], 'enabled' => true], + ['type' => 'ai_review', 'name' => 'Conversion Review', 'persona_ref' => 'conversion_copywriter', 'config' => ['prompt_template' => 'Review the landing page copy for CRO: {full_copy}'], 'enabled' => true], + ], + 'settings' => ['auto_publish' => false, 'review_required' => true, 'max_retries' => 3, 'timeout_seconds' => 360], + 'variables' => [ + ['key' => 'offer', 'type' => 'text', 'label' => 'Offer / Product', 'required' => true], + ['key' => 'target_audience', 'type' => 'text', 'label' => 'Target Audience', 'required' => true], + ['key' => 'unique_value', 'type' => 'text', 'label' => 'Unique Value Prop', 'required' => false], + ], + ], + ]; + } + + /** @return array */ + private function technicalDocumentation(): array + { + return [ + 'name' => 'Technical Documentation', + 'slug' => 'technical-documentation', + 'description' => 'Generate developer-ready technical documentation: API refs, guides, and FAQs.', + 'category' => 'technical', + 'icon' => 'books', + 'definition' => [ + 'version' => '1.0', + 'personas' => [ + ['ref' => 'technical_writer', 'name' => 'Technical Writer', 'system_prompt' => 'You are a technical writer who creates clear, accurate developer documentation.', 'llm_provider' => 'openai', 'llm_model' => 'gpt-4o', 'voice_guidelines' => 'Precise, scannable, code-inclusive.'], + ], + 'stages' => [ + ['type' => 'ai_generate', 'name' => 'Overview Section', 'persona_ref' => 'technical_writer', 'config' => ['prompt_template' => 'Write an overview section for documentation of {feature_name}.'], 'enabled' => true], + ['type' => 'ai_generate', 'name' => 'Getting Started', 'persona_ref' => 'technical_writer', 'config' => ['prompt_template' => 'Write a Getting Started guide for {feature_name}. Include code examples.'], 'enabled' => true], + ['type' => 'ai_generate', 'name' => 'API Reference', 'persona_ref' => 'technical_writer', 'config' => ['prompt_template' => 'Document the following API endpoints in OpenAPI style: {api_spec}'], 'enabled' => true], + ['type' => 'ai_generate', 'name' => 'FAQ Section', 'persona_ref' => 'technical_writer', 'config' => ['prompt_template' => 'Write an FAQ section for {feature_name} based on common developer questions.'], 'enabled' => true], + ['type' => 'ai_review', 'name' => 'Accuracy Review', 'persona_ref' => 'technical_writer', 'config' => ['prompt_template' => 'Review the following documentation for technical accuracy and completeness: {draft}'], 'enabled' => true], + ['type' => 'human_gate', 'name' => 'Engineer Review', 'config' => ['instructions' => 'Have an engineer verify technical accuracy.'], 'enabled' => true], + ], + 'settings' => ['auto_publish' => false, 'review_required' => true, 'max_retries' => 3, 'timeout_seconds' => 480], + 'variables' => [ + ['key' => 'feature_name', 'type' => 'string', 'label' => 'Feature / Product Name', 'required' => true], + ['key' => 'api_spec', 'type' => 'text', 'label' => 'API Spec / Endpoints', 'required' => false], + ['key' => 'audience_level', 'type' => 'select', 'label' => 'Audience Level', 'required' => false, 'options' => ['beginner', 'intermediate', 'advanced']], + ], + ], + ]; + } + + /** @return array */ + private function videoScript(): array + { + return [ + 'name' => 'Video Script', + 'slug' => 'video-script', + 'description' => 'Generate full video scripts with hook, scene breakdown, narration, and call-to-action.', + 'category' => 'video', + 'icon' => 'film', + 'definition' => [ + 'version' => '1.0', + 'personas' => [ + ['ref' => 'script_writer', 'name' => 'Script Writer', 'system_prompt' => 'You write compelling video scripts for YouTube, explainer videos, and ads.', 'llm_provider' => 'openai', 'llm_model' => 'gpt-4o', 'voice_guidelines' => 'Engaging, visual, paced for on-camera delivery.'], + ], + 'stages' => [ + ['type' => 'ai_generate', 'name' => 'Hook', 'persona_ref' => 'script_writer', 'config' => ['prompt_template' => 'Write a compelling 5-second hook for a video about: {video_topic}'], 'enabled' => true], + ['type' => 'ai_generate', 'name' => 'Scene Breakdown', 'persona_ref' => 'script_writer', 'config' => ['prompt_template' => 'Create a scene-by-scene breakdown for a {video_length}-minute video about: {video_topic}'], 'enabled' => true], + ['type' => 'ai_generate', 'name' => 'Full Script', 'persona_ref' => 'script_writer', 'config' => ['prompt_template' => 'Write the full narration script. Topic: {video_topic}. Scenes: {scene_breakdown}'], 'enabled' => true], + ['type' => 'ai_review', 'name' => 'Pacing Review', 'persona_ref' => 'script_writer', 'config' => ['prompt_template' => 'Review this script for pacing, clarity, and engagement: {full_script}'], 'enabled' => true], + ['type' => 'human_gate', 'name' => 'Director Sign-off', 'config' => ['instructions' => 'Have the director review and approve the script.'], 'enabled' => true], + ], + 'settings' => ['auto_publish' => false, 'review_required' => true, 'max_retries' => 3, 'timeout_seconds' => 360], + 'variables' => [ + ['key' => 'video_topic', 'type' => 'string', 'label' => 'Video Topic', 'required' => true], + ['key' => 'video_length', 'type' => 'select', 'label' => 'Video Length (min)', 'required' => true, 'options' => ['1', '2', '3', '5', '10', '15']], + ['key' => 'video_style', 'type' => 'select', 'label' => 'Video Style', 'required' => false, 'options' => ['explainer', 'tutorial', 'testimonial', 'ad', 'documentary']], + ], + ], + ]; + } +} diff --git a/tests/Feature/PipelineTemplateServiceTest.php b/tests/Feature/PipelineTemplateServiceTest.php new file mode 100644 index 0000000..903cce3 --- /dev/null +++ b/tests/Feature/PipelineTemplateServiceTest.php @@ -0,0 +1,249 @@ +service = $this->app->make(PipelineTemplateService::class); + } + + /** @return array */ + private function validDefinition(): array + { + return [ + 'version' => '1.0', + 'personas' => [ + ['ref' => 'writer', 'name' => 'Writer', 'system_prompt' => 'You are a writer.', 'llm_provider' => 'openai', 'llm_model' => 'gpt-4o'], + ], + 'stages' => [ + ['type' => 'ai_generate', 'name' => 'Draft', 'persona_ref' => 'writer', 'config' => ['prompt_template' => 'Write about {topic}.'], 'enabled' => true], + ], + 'settings' => ['auto_publish' => false, 'review_required' => true, 'max_retries' => 2, 'timeout_seconds' => 120], + ]; + } + + public function test_create_makes_template_with_space(): void + { + $space = Space::factory()->create(); + $template = $this->service->create($space, ['name' => 'My Template', 'description' => 'A test', 'category' => 'content']); + + $this->assertInstanceOf(PipelineTemplate::class, $template); + $this->assertEquals($space->id, $template->space_id); + $this->assertEquals('My Template', $template->name); + $this->assertEquals('my-template', $template->slug); + $this->assertFalse($template->is_published); + } + + public function test_create_generates_unique_slug_on_collision(): void + { + $space = Space::factory()->create(); + $t1 = $this->service->create($space, ['name' => 'Duplicate']); + $t2 = $this->service->create($space, ['name' => 'Duplicate']); + $this->assertNotEquals($t1->slug, $t2->slug); + $this->assertEquals('duplicate', $t1->slug); + $this->assertEquals('duplicate-1', $t2->slug); + } + + public function test_update_changes_fields(): void + { + $space = Space::factory()->create(); + $template = $this->service->create($space, ['name' => 'Original']); + $updated = $this->service->update($template, ['name' => 'Updated Name', 'description' => 'Updated', 'category' => 'marketing']); + $this->assertEquals('Updated Name', $updated->name); + $this->assertEquals('Updated', $updated->description); + $this->assertEquals('marketing', $updated->category); + } + + public function test_delete_soft_deletes_template(): void + { + $space = Space::factory()->create(); + $template = $this->service->create($space, ['name' => 'To Delete']); + $this->service->delete($template); + $this->assertSoftDeleted('pipeline_templates', ['id' => $template->id]); + } + + public function test_publish_sets_is_published_and_clears_space_id(): void + { + $space = Space::factory()->create(); + $template = $this->service->create($space, ['name' => 'To Publish']); + $this->service->publish($template); + $template->refresh(); + $this->assertTrue($template->is_published); + $this->assertNull($template->space_id); + } + + public function test_unpublish_clears_is_published(): void + { + $space = Space::factory()->create(); + $template = $this->service->create($space, ['name' => 'To Unpublish']); + $this->service->publish($template); + $this->service->unpublish($template); + $template->refresh(); + $this->assertFalse($template->is_published); + } + + public function test_create_version_stores_version_as_latest(): void + { + $space = Space::factory()->create(); + $template = $this->service->create($space, ['name' => 'Versioned']); + $version = $this->service->createVersion($template, $this->validDefinition(), '1.0.0', 'First release'); + $this->assertInstanceOf(PipelineTemplateVersion::class, $version); + $this->assertTrue($version->is_latest); + $this->assertEquals('1.0.0', $version->version); + $this->assertEquals('First release', $version->changelog); + } + + public function test_create_version_unsets_previous_latest(): void + { + $space = Space::factory()->create(); + $template = $this->service->create($space, ['name' => 'Multi-version']); + $v1 = $this->service->createVersion($template, $this->validDefinition(), '1.0.0'); + $v2 = $this->service->createVersion($template, $this->validDefinition(), '1.1.0'); + $this->assertFalse($v1->refresh()->is_latest); + $this->assertTrue($v2->refresh()->is_latest); + } + + public function test_create_version_rejects_invalid_definition(): void + { + $space = Space::factory()->create(); + $template = $this->service->create($space, ['name' => 'Bad Version']); + $this->expectException(InvalidArgumentException::class); + $this->service->createVersion($template, ['version' => '1.0'], '1.0.0'); + } + + public function test_create_version_validates_persona_refs(): void + { + $space = Space::factory()->create(); + $template = $this->service->create($space, ['name' => 'Bad Ref']); + $badDef = $this->validDefinition(); + $badDef['stages'][0]['persona_ref'] = 'nonexistent_persona'; + $this->expectException(InvalidArgumentException::class); + $this->service->createVersion($template, $badDef, '1.0.0'); + } + + public function test_export_returns_serializable_array(): void + { + $space = Space::factory()->create(); + $template = $this->service->create($space, ['name' => 'Exportable', 'description' => 'desc']); + $this->service->createVersion($template, $this->validDefinition(), '1.2.3', 'changelog'); + $exported = $this->service->export($template); + $this->assertArrayHasKey('numen_export', $exported); + $this->assertArrayHasKey('template', $exported); + $this->assertArrayHasKey('version', $exported); + $this->assertEquals('Exportable', $exported['template']['name']); + $this->assertEquals('1.2.3', $exported['version']['version']); + $this->assertNotFalse(json_encode($exported)); + } + + public function test_import_creates_template_and_version(): void + { + $space = Space::factory()->create(); + $data = [ + 'numen_export' => '1.0', + 'template' => ['name' => 'Imported Template', 'description' => 'Imported', 'category' => 'content'], + 'version' => ['version' => '2.0.0', 'changelog' => 'Imported', 'definition' => $this->validDefinition()], + ]; + $imported = $this->service->import($space, $data); + $this->assertEquals('Imported Template', $imported->name); + $this->assertEquals($space->id, $imported->space_id); + $this->assertCount(1, $imported->versions); + $this->assertEquals('2.0.0', $imported->versions->first()->version); + } + + public function test_export_import_roundtrip(): void + { + $space = Space::factory()->create(); + $original = $this->service->create($space, ['name' => 'Roundtrip Template', 'category' => 'test']); + $this->service->createVersion($original, $this->validDefinition(), '3.0.0', 'Round trip'); + $exported = $this->service->export($original); + $space2 = Space::factory()->create(); + $imported = $this->service->import($space2, $exported); + $this->assertEquals('Roundtrip Template', $imported->name); + $this->assertEquals('3.0.0', $imported->versions->first()->version); + } + + public function test_export_to_file_creates_json_file(): void + { + Storage::fake('local'); + $space = Space::factory()->create(); + $template = $this->service->create($space, ['name' => 'File Export']); + $this->service->createVersion($template, $this->validDefinition(), '1.0.0'); + $path = $this->service->exportToFile($template); + $this->assertFileExists($path); + /** @var array $content */ + $content = json_decode((string) file_get_contents($path), true); + $this->assertEquals('File Export', $content['template']['name']); + } + + public function test_import_from_file_creates_template(): void + { + Storage::fake('local'); + $space = Space::factory()->create(); + $template = $this->service->create($space, ['name' => 'Source Template']); + $this->service->createVersion($template, $this->validDefinition(), '1.0.0'); + $path = $this->service->exportToFile($template); + $space2 = Space::factory()->create(); + $imported = $this->service->importFromFile($space2, $path); + $this->assertEquals('Source Template', $imported->name); + } + + public function test_import_throws_without_template_key(): void + { + $space = Space::factory()->create(); + $this->expectException(InvalidArgumentException::class); + $this->service->import($space, ['numen_export' => '1.0']); + } + + public function test_seeder_creates_all_8_built_in_templates(): void + { + $this->seed(BuiltInTemplateSeeder::class); + $slugs = ['blog-post-pipeline', 'social-media-campaign', 'product-description', 'email-newsletter', 'press-release', 'landing-page', 'technical-documentation', 'video-script']; + foreach ($slugs as $slug) { + $this->assertDatabaseHas('pipeline_templates', ['slug' => $slug]); + } + $this->assertEquals(8, PipelineTemplate::whereNull('space_id')->where('is_published', true)->count()); + } + + public function test_seeder_creates_versions_for_all_templates(): void + { + $this->seed(BuiltInTemplateSeeder::class); + $versionCount = PipelineTemplateVersion::whereHas('template', fn ($q) => $q->whereNull('space_id'))->where('is_latest', true)->count(); + $this->assertEquals(8, $versionCount); + } + + public function test_seeder_is_idempotent(): void + { + $this->seed(BuiltInTemplateSeeder::class); + $this->seed(BuiltInTemplateSeeder::class); + $this->assertEquals(8, PipelineTemplate::whereNull('space_id')->count()); + } + + public function test_seeder_templates_have_valid_definitions(): void + { + $this->seed(BuiltInTemplateSeeder::class); + $validator = $this->app->make(\App\Services\PipelineTemplates\TemplateSchemaValidator::class); + PipelineTemplateVersion::whereHas('template', fn ($q) => $q->whereNull('space_id')) + ->get() + ->each(function (PipelineTemplateVersion $v) use ($validator): void { + $result = $validator->validate($v->definition); + $this->assertTrue($result->isValid(), "Template {$v->template_id} invalid: ".implode('; ', $result->errors())); + }); + } +} From 6da5f5722c13f6ad19b4e4ff79e685aa079a245c Mon Sep 17 00:00:00 2001 From: numen-bot Date: Sun, 15 Mar 2026 19:52:45 +0000 Subject: [PATCH 04/11] feat(templates): PipelineTemplateInstallService (materialize pipelines + personas from templates) --- app/Models/ContentPipeline.php | 3 +- .../PipelineTemplates/PersonaResolver.php | 56 ++++ .../PipelineTemplateInstallService.php | 104 ++++++++ .../PipelineTemplates/VariableResolver.php | 107 ++++++++ ..._add_soft_deletes_to_content_pipelines.php | 24 ++ .../PipelineTemplateInstallServiceTest.php | 245 ++++++++++++++++++ .../VariableResolverTest.php | 136 ++++++++++ 7 files changed, 674 insertions(+), 1 deletion(-) create mode 100644 app/Services/PipelineTemplates/PersonaResolver.php create mode 100644 app/Services/PipelineTemplates/PipelineTemplateInstallService.php create mode 100644 app/Services/PipelineTemplates/VariableResolver.php create mode 100644 database/migrations/2026_03_15_500005_add_soft_deletes_to_content_pipelines.php create mode 100644 tests/Feature/PipelineTemplateInstallServiceTest.php create mode 100644 tests/Unit/PipelineTemplates/VariableResolverTest.php diff --git a/app/Models/ContentPipeline.php b/app/Models/ContentPipeline.php index 70b23bb..671bab6 100755 --- a/app/Models/ContentPipeline.php +++ b/app/Models/ContentPipeline.php @@ -7,6 +7,7 @@ use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Relations\BelongsTo; use Illuminate\Database\Eloquent\Relations\HasMany; +use Illuminate\Database\Eloquent\SoftDeletes; /** * @property string $id @@ -22,7 +23,7 @@ */ class ContentPipeline extends Model { - use HasFactory, HasUlids; + use HasFactory, HasUlids, SoftDeletes; protected $fillable = ['space_id', 'name', 'stages', 'trigger_config', 'is_active']; diff --git a/app/Services/PipelineTemplates/PersonaResolver.php b/app/Services/PipelineTemplates/PersonaResolver.php new file mode 100644 index 0000000..bad925e --- /dev/null +++ b/app/Services/PipelineTemplates/PersonaResolver.php @@ -0,0 +1,56 @@ +> $personaDefinitions + * @return array + */ + public function resolvePersonas(array $personaDefinitions, Space $space): array + { + $resolved = []; + foreach ($personaDefinitions as $def) { + $ref = $def['persona_ref'] ?? ($def['name'] ?? ''); + $name = $def['name'] ?? $ref; + if (empty($ref)) { + continue; + } + $persona = $this->findExistingPersona($space, $name) + ?? $this->createPersona($space, $def, $name); + $resolved[$ref] = $persona; + } + + return $resolved; + } + + private function findExistingPersona(Space $space, string $name): ?Persona + { + /** @var Persona|null */ + return $space->personas()->where('name', $name)->first(); + } + + /** @param array $def */ + private function createPersona(Space $space, array $def, string $name): Persona + { + return Persona::create([ + 'space_id' => $space->id, + 'name' => $name, + 'role' => $def['role'] ?? 'creator', + 'system_prompt' => $def['system_prompt'] ?? '', + 'capabilities' => $def['capabilities'] ?? ['content_generation'], + 'model_config' => $def['model_config'] ?? [ + 'model' => config('numen.models.generation', 'claude-sonnet-4-6'), + 'temperature' => 0.7, + 'max_tokens' => 4096, + ], + 'voice_guidelines' => $def['voice_guidelines'] ?? null, + 'constraints' => $def['constraints'] ?? null, + 'is_active' => true, + ]); + } +} diff --git a/app/Services/PipelineTemplates/PipelineTemplateInstallService.php b/app/Services/PipelineTemplates/PipelineTemplateInstallService.php new file mode 100644 index 0000000..7aa578a --- /dev/null +++ b/app/Services/PipelineTemplates/PipelineTemplateInstallService.php @@ -0,0 +1,104 @@ + $variableValues + * @param array $configOverrides + */ + public function install( + PipelineTemplateVersion $version, + Space $space, + array $variableValues = [], + array $configOverrides = [], + ): PipelineTemplateInstall { + return DB::transaction(function () use ($version, $space, $variableValues, $configOverrides): PipelineTemplateInstall { + $definition = $this->variableResolver->resolve($version->definition, $variableValues); + $personaDefs = $definition['personas'] ?? []; + $personas = $this->personaResolver->resolvePersonas($personaDefs, $space); + $stages = $this->buildStages($definition['stages'] ?? [], $personas); + $pipelineName = $definition['settings']['name'] + ?? $version->template->name + ?? 'Imported Pipeline'; + + $pipeline = ContentPipeline::create([ + 'space_id' => $space->id, + 'name' => $pipelineName, + 'stages' => $stages, + 'trigger_config' => $definition['settings']['trigger_config'] ?? [], + 'is_active' => true, + ]); + + return PipelineTemplateInstall::create([ + 'template_id' => $version->template_id, + 'version_id' => $version->id, + 'space_id' => $space->id, + 'pipeline_id' => $pipeline->id, + 'installed_at' => now(), + 'config_overrides' => empty($configOverrides) ? null : $configOverrides, + ]); + }); + } + + public function uninstall(PipelineTemplateInstall $install): void + { + DB::transaction(function () use ($install): void { + if ($install->pipeline_id !== null) { + /** @var ContentPipeline|null $pipeline */ + $pipeline = ContentPipeline::find($install->pipeline_id); + $pipeline?->delete(); + } + $install->delete(); + }); + } + + public function update( + PipelineTemplateInstall $install, + PipelineTemplateVersion $newVersion, + ): PipelineTemplateInstall { + return DB::transaction(function () use ($install, $newVersion): PipelineTemplateInstall { + $configOverrides = $install->config_overrides ?? []; + + if ($install->pipeline_id !== null) { + /** @var ContentPipeline|null $pipeline */ + $pipeline = ContentPipeline::find($install->pipeline_id); + $pipeline?->delete(); + } + + $newInstall = $this->install($newVersion, $install->space, [], $configOverrides); + $install->delete(); + + return $newInstall; + }); + } + + /** + * @param array> $stages + * @param array $personas + * @return array> + */ + private function buildStages(array $stages, array $personas): array + { + return array_map(function (array $stage) use ($personas): array { + $ref = $stage['persona_ref'] ?? null; + if ($ref !== null && isset($personas[$ref])) { + $stage['persona_id'] = $personas[$ref]->id; + } + + return $stage; + }, $stages); + } +} diff --git a/app/Services/PipelineTemplates/VariableResolver.php b/app/Services/PipelineTemplates/VariableResolver.php new file mode 100644 index 0000000..248a3ad --- /dev/null +++ b/app/Services/PipelineTemplates/VariableResolver.php @@ -0,0 +1,107 @@ + $definition + * @param array $values + * @return array + */ + public function resolve(array $definition, array $values): array + { + $variables = $definition['variables'] ?? []; + $this->validateRequiredVariables($variables, $values); + $coercedValues = $this->coerceValues($variables, $values); + + return $this->replacePlaceholders($definition, $coercedValues); + } + + /** + * @param array> $variables + * @param array $values + */ + private function validateRequiredVariables(array $variables, array $values): void + { + $missing = []; + foreach ($variables as $variable) { + $name = $variable['name'] ?? ''; + $required = $variable['required'] ?? true; + if ($required && ! array_key_exists($name, $values)) { + $missing[] = $name; + } + } + if (! empty($missing)) { + throw new InvalidArgumentException( + 'Missing required template variables: '.implode(', ', $missing), + ); + } + } + + /** + * @param array> $variables + * @param array $values + * @return array + */ + private function coerceValues(array $variables, array $values): array + { + $result = $values; + foreach ($variables as $variable) { + $name = $variable['name'] ?? ''; + $type = $variable['type'] ?? 'string'; + if (! array_key_exists($name, $values)) { + if (array_key_exists('default', $variable)) { + $result[$name] = $this->coerce($variable['default'], $type); + } + + continue; + } + $result[$name] = $this->coerce($values[$name], $type); + } + + return $result; + } + + private function coerce(mixed $value, string $type): mixed + { + return match ($type) { + 'number' => is_numeric($value) ? $value + 0 : (float) $value, + 'boolean' => filter_var($value, FILTER_VALIDATE_BOOLEAN, FILTER_NULL_ON_FAILURE) ?? (bool) $value, + 'select' => (string) $value, + 'multiselect' => is_array($value) ? $value : [(string) $value], + default => (string) $value, + }; + } + + /** + * @param array $definition + * @param array $values + * @return array + */ + private function replacePlaceholders(array $definition, array $values): array + { + array_walk_recursive($definition, function (mixed &$item) use ($values): void { + if (! is_string($item)) { + return; + } + $item = preg_replace_callback( + '/\{\{([a-zA-Z_][a-zA-Z0-9_]*)\}\}/', + function (array $matches) use ($values): string { + $key = $matches[1]; + if (! array_key_exists($key, $values)) { + return $matches[0]; + } + $val = $values[$key]; + + return is_array($val) ? implode(', ', $val) : (string) $val; + }, + $item, + ) ?? $item; + }); + + return $definition; + } +} diff --git a/database/migrations/2026_03_15_500005_add_soft_deletes_to_content_pipelines.php b/database/migrations/2026_03_15_500005_add_soft_deletes_to_content_pipelines.php new file mode 100644 index 0000000..d3ca582 --- /dev/null +++ b/database/migrations/2026_03_15_500005_add_soft_deletes_to_content_pipelines.php @@ -0,0 +1,24 @@ +softDeletes(); + }); + } + } + + public function down(): void + { + Schema::table('content_pipelines', function (Blueprint $table): void { + $table->dropSoftDeletes(); + }); + } +}; diff --git a/tests/Feature/PipelineTemplateInstallServiceTest.php b/tests/Feature/PipelineTemplateInstallServiceTest.php new file mode 100644 index 0000000..462a998 --- /dev/null +++ b/tests/Feature/PipelineTemplateInstallServiceTest.php @@ -0,0 +1,245 @@ +service = new PipelineTemplateInstallService( + new VariableResolver, + new PersonaResolver, + ); + } + + /** @return array */ + private function minimalDefinition(): array + { + return [ + 'version' => '1.0', + 'personas' => [ + [ + 'persona_ref' => 'writer', + 'name' => 'Writer', + 'role' => 'creator', + 'system_prompt' => 'You are a writer.', + 'capabilities' => ['content_generation'], + 'model_config' => ['model' => 'claude-sonnet-4-6', 'temperature' => 0.7, 'max_tokens' => 4096], + ], + ], + 'stages' => [ + ['name' => 'draft', 'type' => 'ai_generate', 'persona_ref' => 'writer'], + ['name' => 'publish', 'type' => 'auto_publish'], + ], + 'settings' => ['name' => 'Test Pipeline'], + 'variables' => [], + ]; + } + + private function makeVersion(Space $space, array $definition = []): PipelineTemplateVersion + { + $template = PipelineTemplate::factory()->create(['space_id' => $space->id]); + + return PipelineTemplateVersion::factory()->create([ + 'template_id' => $template->id, + 'definition' => $definition ?: $this->minimalDefinition(), + 'is_latest' => true, + 'published_at' => now(), + ]); + } + + public function test_install_creates_pipeline_and_install_record(): void + { + $space = Space::factory()->create(); + $version = $this->makeVersion($space); + + $install = $this->service->install($version, $space); + + $this->assertInstanceOf(PipelineTemplateInstall::class, $install); + $this->assertEquals($space->id, $install->space_id); + $this->assertEquals($version->id, $install->version_id); + $this->assertNotNull($install->pipeline_id); + + $pipeline = ContentPipeline::find($install->pipeline_id); + $this->assertNotNull($pipeline); + $this->assertEquals('Test Pipeline', $pipeline->name); + $this->assertEquals($space->id, $pipeline->space_id); + } + + public function test_install_creates_personas_from_definition(): void + { + $space = Space::factory()->create(); + $version = $this->makeVersion($space); + + $this->service->install($version, $space); + + $this->assertDatabaseHas('personas', [ + 'space_id' => $space->id, + 'name' => 'Writer', + ]); + } + + public function test_install_reuses_existing_persona_by_name(): void + { + $space = Space::factory()->create(); + $existingPersona = Persona::factory()->create([ + 'space_id' => $space->id, + 'name' => 'Writer', + ]); + + $version = $this->makeVersion($space); + $this->service->install($version, $space); + + $this->assertEquals(1, $space->personas()->where('name', 'Writer')->count()); + $this->assertEquals($existingPersona->id, $space->personas()->where('name', 'Writer')->first()->id); + } + + public function test_install_injects_persona_id_into_stage(): void + { + $space = Space::factory()->create(); + $version = $this->makeVersion($space); + + $install = $this->service->install($version, $space); + $pipeline = ContentPipeline::find($install->pipeline_id); + + $this->assertNotNull($pipeline); + $stages = $pipeline->stages; + $draftStage = collect($stages)->firstWhere('name', 'draft'); + + $this->assertNotNull($draftStage); + $this->assertArrayHasKey('persona_id', $draftStage); + } + + public function test_install_stores_config_overrides(): void + { + $space = Space::factory()->create(); + $version = $this->makeVersion($space); + $overrides = ['custom_setting' => 'foo']; + + $install = $this->service->install($version, $space, [], $overrides); + + $this->assertEquals($overrides, $install->config_overrides); + } + + public function test_install_null_config_overrides_when_empty(): void + { + $space = Space::factory()->create(); + $version = $this->makeVersion($space); + + $install = $this->service->install($version, $space); + + $this->assertNull($install->config_overrides); + } + + public function test_uninstall_soft_deletes_pipeline_and_removes_install(): void + { + $space = Space::factory()->create(); + $version = $this->makeVersion($space); + $install = $this->service->install($version, $space); + $pipelineId = $install->pipeline_id; + + $this->service->uninstall($install); + + $this->assertSoftDeleted('content_pipelines', ['id' => $pipelineId]); + $this->assertDatabaseMissing('pipeline_template_installs', ['id' => $install->id]); + } + + public function test_uninstall_handles_missing_pipeline_gracefully(): void + { + $space = Space::factory()->create(); + $template = PipelineTemplate::factory()->create(['space_id' => $space->id]); + $version = PipelineTemplateVersion::factory()->create(['template_id' => $template->id]); + + $install = PipelineTemplateInstall::factory()->create([ + 'template_id' => $template->id, + 'version_id' => $version->id, + 'space_id' => $space->id, + 'pipeline_id' => null, + ]); + + $this->service->uninstall($install); + + $this->assertDatabaseMissing('pipeline_template_installs', ['id' => $install->id]); + } + + public function test_update_creates_new_pipeline(): void + { + $space = Space::factory()->create(); + $template = PipelineTemplate::factory()->create(['space_id' => $space->id]); + $v1 = PipelineTemplateVersion::factory()->create([ + 'template_id' => $template->id, + 'definition' => $this->minimalDefinition(), + ]); + $v2 = PipelineTemplateVersion::factory()->create([ + 'template_id' => $template->id, + 'definition' => array_merge($this->minimalDefinition(), ['settings' => ['name' => 'Updated Pipeline']]), + ]); + + $install = $this->service->install($v1, $space); + $newInstall = $this->service->update($install, $v2); + + $this->assertInstanceOf(PipelineTemplateInstall::class, $newInstall); + $this->assertNotEquals($install->id, $newInstall->id); + + $newPipeline = ContentPipeline::find($newInstall->pipeline_id); + $this->assertNotNull($newPipeline); + $this->assertEquals('Updated Pipeline', $newPipeline->name); + } + + public function test_update_preserves_config_overrides(): void + { + $space = Space::factory()->create(); + $template = PipelineTemplate::factory()->create(['space_id' => $space->id]); + $v1 = PipelineTemplateVersion::factory()->create([ + 'template_id' => $template->id, + 'definition' => $this->minimalDefinition(), + ]); + $v2 = PipelineTemplateVersion::factory()->create([ + 'template_id' => $template->id, + 'definition' => $this->minimalDefinition(), + ]); + + $overrides = ['custom' => 'preserved']; + $install = $this->service->install($v1, $space, [], $overrides); + $newInstall = $this->service->update($install, $v2); + + $this->assertEquals($overrides, $newInstall->config_overrides); + } + + public function test_update_soft_deletes_old_pipeline(): void + { + $space = Space::factory()->create(); + $template = PipelineTemplate::factory()->create(['space_id' => $space->id]); + $v1 = PipelineTemplateVersion::factory()->create([ + 'template_id' => $template->id, + 'definition' => $this->minimalDefinition(), + ]); + $v2 = PipelineTemplateVersion::factory()->create([ + 'template_id' => $template->id, + 'definition' => $this->minimalDefinition(), + ]); + + $install = $this->service->install($v1, $space); + $oldPipelineId = $install->pipeline_id; + $this->service->update($install, $v2); + + $this->assertSoftDeleted('content_pipelines', ['id' => $oldPipelineId]); + } +} diff --git a/tests/Unit/PipelineTemplates/VariableResolverTest.php b/tests/Unit/PipelineTemplates/VariableResolverTest.php new file mode 100644 index 0000000..c986920 --- /dev/null +++ b/tests/Unit/PipelineTemplates/VariableResolverTest.php @@ -0,0 +1,136 @@ +resolver = new VariableResolver; + } + + public function test_replaces_string_placeholders(): void + { + $definition = [ + 'variables' => [ + ['name' => 'topic', 'type' => 'string', 'required' => true], + ], + 'stages' => [ + ['name' => 'draft', 'config' => ['prompt' => 'Write about {{topic}}.']], + ], + ]; + + $result = $this->resolver->resolve($definition, ['topic' => 'AI']); + + $this->assertEquals('Write about AI.', $result['stages'][0]['config']['prompt']); + } + + public function test_coerces_number_type(): void + { + $definition = [ + 'variables' => [ + ['name' => 'max_tokens', 'type' => 'number', 'required' => true], + ], + 'stages' => [], + ]; + + $result = $this->resolver->resolve($definition, ['max_tokens' => '512']); + + $this->assertIsNumeric(512); + } + + public function test_coerces_boolean_type(): void + { + $definition = [ + 'variables' => [ + ['name' => 'auto_publish', 'type' => 'boolean', 'required' => true], + ], + 'stages' => [], + ]; + + $result = $this->resolver->resolve($definition, ['auto_publish' => 'true']); + + $this->assertNotNull($result); + } + + public function test_throws_on_missing_required_variable(): void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessageMatches('/Missing required template variables.*topic/'); + + $definition = [ + 'variables' => [ + ['name' => 'topic', 'type' => 'string', 'required' => true], + ], + 'stages' => [], + ]; + + $this->resolver->resolve($definition, []); + } + + public function test_uses_default_value_when_not_provided(): void + { + $definition = [ + 'variables' => [ + ['name' => 'tone', 'type' => 'string', 'required' => false, 'default' => 'professional'], + ], + 'stages' => [ + ['name' => 'draft', 'config' => ['tone' => '{{tone}}']], + ], + ]; + + $result = $this->resolver->resolve($definition, []); + + $this->assertEquals('professional', $result['stages'][0]['config']['tone']); + } + + public function test_leaves_unknown_placeholder_intact(): void + { + $definition = [ + 'variables' => [], + 'stages' => [ + ['name' => 'draft', 'config' => ['prompt' => 'Hello {{unknown}}.']], + ], + ]; + + $result = $this->resolver->resolve($definition, []); + + $this->assertEquals('Hello {{unknown}}.', $result['stages'][0]['config']['prompt']); + } + + public function test_resolves_nested_arrays(): void + { + $definition = [ + 'variables' => [ + ['name' => 'brand', 'type' => 'string', 'required' => true], + ], + 'stages' => [ + ['name' => 'draft', 'meta' => ['title' => '{{brand}} Blog Post']], + ], + ]; + + $result = $this->resolver->resolve($definition, ['brand' => 'Numen']); + + $this->assertEquals('Numen Blog Post', $result['stages'][0]['meta']['title']); + } + + public function test_optional_variable_without_default_is_not_required(): void + { + $definition = [ + 'variables' => [ + ['name' => 'optional_tag', 'type' => 'string', 'required' => false], + ], + 'stages' => [], + ]; + + $result = $this->resolver->resolve($definition, []); + $this->assertIsArray($result); + } +} From a8f4c0e2888126cd260a962f94827277d209b5c1 Mon Sep 17 00:00:00 2001 From: numen-bot Date: Sun, 15 Mar 2026 20:18:44 +0000 Subject: [PATCH 05/11] feat(templates): REST API controllers, routes, form requests (14 endpoints) --- .../Templates/PipelineTemplateController.php | 86 +++++++ .../PipelineTemplateInstallController.php | 50 +++++ .../PipelineTemplateRatingController.php | 44 ++++ .../PipelineTemplateVersionController.php | 37 +++ .../Templates/CreateVersionRequest.php | 27 +++ .../Templates/InstallTemplateRequest.php | 22 ++ .../Templates/RateTemplateRequest.php | 22 ++ .../StorePipelineTemplateRequest.php | 34 +++ .../UpdatePipelineTemplateRequest.php | 26 +++ .../PipelineTemplateInstallResource.php | 28 +++ .../Resources/PipelineTemplateResource.php | 33 +++ .../PipelineTemplateVersionResource.php | 29 +++ routes/api.php | 24 ++ tests/Feature/PipelineTemplateApiTest.php | 212 ++++++++++++++++++ 14 files changed, 674 insertions(+) create mode 100644 app/Http/Controllers/Api/Templates/PipelineTemplateController.php create mode 100644 app/Http/Controllers/Api/Templates/PipelineTemplateInstallController.php create mode 100644 app/Http/Controllers/Api/Templates/PipelineTemplateRatingController.php create mode 100644 app/Http/Controllers/Api/Templates/PipelineTemplateVersionController.php create mode 100644 app/Http/Requests/Templates/CreateVersionRequest.php create mode 100644 app/Http/Requests/Templates/InstallTemplateRequest.php create mode 100644 app/Http/Requests/Templates/RateTemplateRequest.php create mode 100644 app/Http/Requests/Templates/StorePipelineTemplateRequest.php create mode 100644 app/Http/Requests/Templates/UpdatePipelineTemplateRequest.php create mode 100644 app/Http/Resources/PipelineTemplateInstallResource.php create mode 100644 app/Http/Resources/PipelineTemplateResource.php create mode 100644 app/Http/Resources/PipelineTemplateVersionResource.php create mode 100644 tests/Feature/PipelineTemplateApiTest.php diff --git a/app/Http/Controllers/Api/Templates/PipelineTemplateController.php b/app/Http/Controllers/Api/Templates/PipelineTemplateController.php new file mode 100644 index 0000000..11c76e6 --- /dev/null +++ b/app/Http/Controllers/Api/Templates/PipelineTemplateController.php @@ -0,0 +1,86 @@ +where('space_id', $space->id) + ->latest() + ->get(); + + $marketplace = PipelineTemplate::with('latestVersion') + ->whereNull('space_id') + ->where('is_published', true) + ->latest() + ->get(); + + return PipelineTemplateResource::collection($spaceTemplates->merge($marketplace)); + } + + public function show(Space $space, PipelineTemplate $template): PipelineTemplateResource + { + $template->load('latestVersion', 'versions'); + + return new PipelineTemplateResource($template); + } + + public function store(Space $space, StorePipelineTemplateRequest $request): JsonResponse + { + $data = $request->validated(); + $template = $this->service->create($space, $data); + + if (! empty($data['definition'])) { + $this->service->createVersion($template, $data['definition'], $data['version'] ?? '1.0.0', $data['changelog'] ?? null); + } + + $template->load('latestVersion'); + + return (new PipelineTemplateResource($template))->response()->setStatusCode(201); + } + + public function update(Space $space, PipelineTemplate $template, UpdatePipelineTemplateRequest $request): PipelineTemplateResource + { + $template = $this->service->update($template, $request->validated()); + $template->load('latestVersion'); + + return new PipelineTemplateResource($template); + } + + public function destroy(Space $space, PipelineTemplate $template): JsonResponse + { + $this->service->delete($template); + + return response()->json(null, 204); + } + + public function publish(Space $space, PipelineTemplate $template): PipelineTemplateResource + { + $this->service->publish($template); + + return new PipelineTemplateResource($template->refresh()); + } + + public function unpublish(Space $space, PipelineTemplate $template): PipelineTemplateResource + { + $this->service->unpublish($template); + + return new PipelineTemplateResource($template->refresh()); + } +} diff --git a/app/Http/Controllers/Api/Templates/PipelineTemplateInstallController.php b/app/Http/Controllers/Api/Templates/PipelineTemplateInstallController.php new file mode 100644 index 0000000..3cf2482 --- /dev/null +++ b/app/Http/Controllers/Api/Templates/PipelineTemplateInstallController.php @@ -0,0 +1,50 @@ +validated(); + $install = $this->installService->install($version, $space, $data['variable_values'] ?? [], $data['config_overrides'] ?? []); + $install->load('template', 'templateVersion'); + + return (new PipelineTemplateInstallResource($install))->response()->setStatusCode(201); + } + + public function destroy(PipelineTemplateInstall $install): JsonResponse + { + $this->installService->uninstall($install); + + return response()->json(null, 204); + } + + public function update(PipelineTemplateInstall $install, InstallTemplateRequest $request): PipelineTemplateInstallResource + { + $install->loadMissing('templateVersion.template'); + /** @var PipelineTemplateVersion $currentVersion */ + $currentVersion = $install->getRelation('templateVersion'); + /** @var \App\Models\PipelineTemplate $tmpl */ + $tmpl = $currentVersion->getRelation('template'); + /** @var PipelineTemplateVersion $newVersion */ + $newVersion = $tmpl->versions()->where('is_latest', true)->firstOrFail(); + $updatedInstall = $this->installService->update($install, $newVersion); + $updatedInstall->load('template', 'templateVersion'); + + return new PipelineTemplateInstallResource($updatedInstall); + } +} diff --git a/app/Http/Controllers/Api/Templates/PipelineTemplateRatingController.php b/app/Http/Controllers/Api/Templates/PipelineTemplateRatingController.php new file mode 100644 index 0000000..29ee6d8 --- /dev/null +++ b/app/Http/Controllers/Api/Templates/PipelineTemplateRatingController.php @@ -0,0 +1,44 @@ +ratings()->with('user')->latest()->get(); + $average = $ratings->avg('rating'); + + return response()->json([ + 'data' => $ratings->map(fn (PipelineTemplateRating $r) => [ + 'id' => $r->id, + 'rating' => $r->rating, + 'review' => $r->review, + 'user' => $r->user ? ['id' => $r->user->id, 'name' => $r->user->name] : null, + 'created_at' => $r->created_at->toIso8601String(), + ]), + 'meta' => [ + 'average_rating' => $average ? round((float) $average, 2) : null, + 'total' => $ratings->count(), + ], + ]); + } + + public function store(PipelineTemplate $template, RateTemplateRequest $request): JsonResponse + { + $rating = PipelineTemplateRating::updateOrCreate( + ['template_id' => $template->id, 'user_id' => $request->user()?->id], + ['rating' => $request->integer('rating'), 'review' => $request->string('review')->value() ?: null], + ); + + return response()->json([ + 'data' => ['id' => $rating->id, 'rating' => $rating->rating, 'review' => $rating->review, 'created_at' => $rating->created_at->toIso8601String()], + ], 201); + } +} diff --git a/app/Http/Controllers/Api/Templates/PipelineTemplateVersionController.php b/app/Http/Controllers/Api/Templates/PipelineTemplateVersionController.php new file mode 100644 index 0000000..72affff --- /dev/null +++ b/app/Http/Controllers/Api/Templates/PipelineTemplateVersionController.php @@ -0,0 +1,37 @@ +versions()->latest()->get()); + } + + public function store(PipelineTemplate $template, CreateVersionRequest $request): JsonResponse + { + $data = $request->validated(); + $version = $this->service->createVersion($template, $data['definition'], $data['version'], $data['changelog'] ?? null); + + return (new PipelineTemplateVersionResource($version))->response()->setStatusCode(201); + } + + public function show(PipelineTemplate $template, PipelineTemplateVersion $version): PipelineTemplateVersionResource + { + return new PipelineTemplateVersionResource($version); + } +} diff --git a/app/Http/Requests/Templates/CreateVersionRequest.php b/app/Http/Requests/Templates/CreateVersionRequest.php new file mode 100644 index 0000000..61c462d --- /dev/null +++ b/app/Http/Requests/Templates/CreateVersionRequest.php @@ -0,0 +1,27 @@ +> */ + public function rules(): array + { + return [ + 'version' => ['required', 'string', 'max:50'], + 'definition' => ['required', 'array'], + 'definition.schema_version' => ['required', 'string'], + 'definition.stages' => ['required', 'array', 'min:1'], + 'definition.settings' => ['nullable', 'array'], + 'definition.personas' => ['nullable', 'array'], + 'changelog' => ['nullable', 'string'], + ]; + } +} diff --git a/app/Http/Requests/Templates/InstallTemplateRequest.php b/app/Http/Requests/Templates/InstallTemplateRequest.php new file mode 100644 index 0000000..2ac2bf5 --- /dev/null +++ b/app/Http/Requests/Templates/InstallTemplateRequest.php @@ -0,0 +1,22 @@ +> */ + public function rules(): array + { + return [ + 'variable_values' => ['nullable', 'array'], + 'config_overrides' => ['nullable', 'array'], + ]; + } +} diff --git a/app/Http/Requests/Templates/RateTemplateRequest.php b/app/Http/Requests/Templates/RateTemplateRequest.php new file mode 100644 index 0000000..8c1730e --- /dev/null +++ b/app/Http/Requests/Templates/RateTemplateRequest.php @@ -0,0 +1,22 @@ +> */ + public function rules(): array + { + return [ + 'rating' => ['required', 'integer', 'min:1', 'max:5'], + 'review' => ['nullable', 'string', 'max:2000'], + ]; + } +} diff --git a/app/Http/Requests/Templates/StorePipelineTemplateRequest.php b/app/Http/Requests/Templates/StorePipelineTemplateRequest.php new file mode 100644 index 0000000..afe182d --- /dev/null +++ b/app/Http/Requests/Templates/StorePipelineTemplateRequest.php @@ -0,0 +1,34 @@ +> */ + public function rules(): array + { + return [ + 'name' => ['required', 'string', 'max:255'], + 'slug' => ['nullable', 'string', 'max:255'], + 'description' => ['nullable', 'string'], + 'category' => ['nullable', 'string', 'max:100'], + 'icon' => ['nullable', 'string', 'max:50'], + 'author_name' => ['nullable', 'string', 'max:255'], + 'author_url' => ['nullable', 'url', 'max:500'], + 'definition' => ['required', 'array'], + 'definition.schema_version' => ['required', 'string'], + 'definition.stages' => ['required', 'array', 'min:1'], + 'definition.settings' => ['nullable', 'array'], + 'definition.personas' => ['nullable', 'array'], + 'version' => ['nullable', 'string', 'max:50'], + 'changelog' => ['nullable', 'string'], + ]; + } +} diff --git a/app/Http/Requests/Templates/UpdatePipelineTemplateRequest.php b/app/Http/Requests/Templates/UpdatePipelineTemplateRequest.php new file mode 100644 index 0000000..f8f7f78 --- /dev/null +++ b/app/Http/Requests/Templates/UpdatePipelineTemplateRequest.php @@ -0,0 +1,26 @@ +> */ + public function rules(): array + { + return [ + 'name' => ['sometimes', 'required', 'string', 'max:255'], + 'description' => ['nullable', 'string'], + 'category' => ['nullable', 'string', 'max:100'], + 'icon' => ['nullable', 'string', 'max:50'], + 'author_name' => ['nullable', 'string', 'max:255'], + 'author_url' => ['nullable', 'url', 'max:500'], + ]; + } +} diff --git a/app/Http/Resources/PipelineTemplateInstallResource.php b/app/Http/Resources/PipelineTemplateInstallResource.php new file mode 100644 index 0000000..400951b --- /dev/null +++ b/app/Http/Resources/PipelineTemplateInstallResource.php @@ -0,0 +1,28 @@ + $this->id, + 'template_id' => $this->template_id, + 'version_id' => $this->version_id, + 'space_id' => $this->space_id, + 'pipeline_id' => $this->pipeline_id, + 'installed_at' => $this->installed_at->toIso8601String(), + 'config_overrides' => $this->config_overrides, + 'template' => $this->whenLoaded('template', fn () => new PipelineTemplateResource($this->template)), + 'version' => $this->whenLoaded('templateVersion', fn () => new PipelineTemplateVersionResource($this->templateVersion)), + 'created_at' => $this->created_at->toIso8601String(), + 'updated_at' => $this->updated_at->toIso8601String(), + ]; + } +} diff --git a/app/Http/Resources/PipelineTemplateResource.php b/app/Http/Resources/PipelineTemplateResource.php new file mode 100644 index 0000000..fdb80fd --- /dev/null +++ b/app/Http/Resources/PipelineTemplateResource.php @@ -0,0 +1,33 @@ + $this->id, + 'space_id' => $this->space_id, + 'name' => $this->name, + 'slug' => $this->slug, + 'description' => $this->description, + 'category' => $this->category, + 'icon' => $this->icon, + 'schema_version' => $this->schema_version, + 'is_published' => $this->is_published, + 'author_name' => $this->author_name, + 'author_url' => $this->author_url, + 'downloads_count' => $this->downloads_count, + 'latest_version' => $this->whenLoaded('latestVersion', fn () => new PipelineTemplateVersionResource($this->latestVersion)), + 'versions_count' => $this->whenLoaded('versions', fn () => $this->versions->count()), + 'created_at' => $this->created_at->toIso8601String(), + 'updated_at' => $this->updated_at->toIso8601String(), + ]; + } +} diff --git a/app/Http/Resources/PipelineTemplateVersionResource.php b/app/Http/Resources/PipelineTemplateVersionResource.php new file mode 100644 index 0000000..90add71 --- /dev/null +++ b/app/Http/Resources/PipelineTemplateVersionResource.php @@ -0,0 +1,29 @@ + $this->id, + 'template_id' => $this->template_id, + 'version' => $this->version, + 'changelog' => $this->changelog, + 'is_latest' => $this->is_latest, + 'published_at' => $this->published_at?->toIso8601String(), + 'definition' => $this->when( + $request->routeIs('api.pipeline-templates.versions.show'), + fn () => $this->definition, + ), + 'created_at' => $this->created_at->toIso8601String(), + 'updated_at' => $this->updated_at->toIso8601String(), + ]; + } +} diff --git a/routes/api.php b/routes/api.php index c800dca..0befc05 100644 --- a/routes/api.php +++ b/routes/api.php @@ -358,3 +358,27 @@ Route::delete('/conversations/{id}/confirm', [ChatController::class, 'cancelAction']); Route::get('/suggestions', [ChatController::class, 'suggestions']); }); + +// --- #36 Pipeline Templates API --- +use App\Http\Controllers\Api\Templates\PipelineTemplateController; +use App\Http\Controllers\Api\Templates\PipelineTemplateInstallController; +use App\Http\Controllers\Api\Templates\PipelineTemplateRatingController; +use App\Http\Controllers\Api\Templates\PipelineTemplateVersionController; + +Route::prefix('v1/spaces/{space}/pipeline-templates')->middleware('auth:sanctum')->group(function () { + Route::get('/', [PipelineTemplateController::class, 'index'])->name('api.pipeline-templates.index'); + Route::post('/', [PipelineTemplateController::class, 'store'])->name('api.pipeline-templates.store'); + Route::get('/{template}', [PipelineTemplateController::class, 'show'])->name('api.pipeline-templates.show'); + Route::patch('/{template}', [PipelineTemplateController::class, 'update'])->name('api.pipeline-templates.update'); + Route::delete('/{template}', [PipelineTemplateController::class, 'destroy'])->name('api.pipeline-templates.destroy'); + Route::post('/{template}/publish', [PipelineTemplateController::class, 'publish'])->name('api.pipeline-templates.publish'); + Route::post('/{template}/unpublish', [PipelineTemplateController::class, 'unpublish'])->name('api.pipeline-templates.unpublish'); + Route::get('/{template}/versions', [PipelineTemplateVersionController::class, 'index'])->name('api.pipeline-templates.versions.index'); + Route::post('/{template}/versions', [PipelineTemplateVersionController::class, 'store'])->name('api.pipeline-templates.versions.store'); + Route::get('/{template}/versions/{version}', [PipelineTemplateVersionController::class, 'show'])->name('api.pipeline-templates.versions.show'); + Route::post('/installs/{version}', [PipelineTemplateInstallController::class, 'store'])->name('api.pipeline-templates.installs.store'); + Route::patch('/installs/{install}', [PipelineTemplateInstallController::class, 'update'])->name('api.pipeline-templates.installs.update'); + Route::delete('/installs/{install}', [PipelineTemplateInstallController::class, 'destroy'])->name('api.pipeline-templates.installs.destroy'); + Route::get('/{template}/ratings', [PipelineTemplateRatingController::class, 'index'])->name('api.pipeline-templates.ratings.index'); + Route::post('/{template}/ratings', [PipelineTemplateRatingController::class, 'store'])->name('api.pipeline-templates.ratings.store'); +}); diff --git a/tests/Feature/PipelineTemplateApiTest.php b/tests/Feature/PipelineTemplateApiTest.php new file mode 100644 index 0000000..9966a78 --- /dev/null +++ b/tests/Feature/PipelineTemplateApiTest.php @@ -0,0 +1,212 @@ +user = User::factory()->create(); + $this->space = Space::factory()->create(); + } + + public function test_index_lists_space_and_marketplace_templates(): void + { + $spaceTemplate = PipelineTemplate::factory()->forSpace($this->space)->create(); + $marketplaceTemplate = PipelineTemplate::factory()->published()->create(); + $privateOther = PipelineTemplate::factory()->create(['space_id' => Space::factory()->create()->id]); + + $response = $this->actingAs($this->user) + ->getJson("/api/v1/spaces/{$this->space->id}/pipeline-templates"); + + $response->assertOk(); + $ids = collect($response->json('data'))->pluck('id'); + $this->assertContains($spaceTemplate->id, $ids->all()); + $this->assertContains($marketplaceTemplate->id, $ids->all()); + $this->assertNotContains($privateOther->id, $ids->all()); + } + + public function test_show_returns_template_with_versions(): void + { + $template = PipelineTemplate::factory()->forSpace($this->space)->create(); + PipelineTemplateVersion::factory()->latest()->create(['template_id' => $template->id]); + + $response = $this->actingAs($this->user) + ->getJson("/api/v1/spaces/{$this->space->id}/pipeline-templates/{$template->id}"); + + $response->assertOk() + ->assertJsonPath('data.id', $template->id) + ->assertJsonStructure(['data' => ['latest_version']]); + } + + public function test_store_creates_template_with_version(): void + { + $payload = [ + 'name' => 'My Template', + 'definition' => [ + 'schema_version' => '1.0', + 'stages' => [['name' => 'generate', 'type' => 'ai_generate']], + ], + 'version' => '1.0.0', + 'changelog' => 'Initial version', + ]; + + $response = $this->actingAs($this->user) + ->postJson("/api/v1/spaces/{$this->space->id}/pipeline-templates", $payload); + + $response->assertStatus(201) + ->assertJsonPath('data.name', 'My Template'); + $this->assertDatabaseHas('pipeline_templates', ['name' => 'My Template', 'space_id' => $this->space->id]); + } + + public function test_store_validates_required_fields(): void + { + $response = $this->actingAs($this->user) + ->postJson("/api/v1/spaces/{$this->space->id}/pipeline-templates", []); + + $response->assertStatus(422) + ->assertJsonValidationErrors(['name', 'definition']); + } + + public function test_update_modifies_template_metadata(): void + { + $template = PipelineTemplate::factory()->forSpace($this->space)->create(); + + $response = $this->actingAs($this->user) + ->patchJson("/api/v1/spaces/{$this->space->id}/pipeline-templates/{$template->id}", [ + 'name' => 'Updated Name', + ]); + + $response->assertOk() + ->assertJsonPath('data.name', 'Updated Name'); + } + + public function test_destroy_soft_deletes_template(): void + { + $template = PipelineTemplate::factory()->forSpace($this->space)->create(); + + $response = $this->actingAs($this->user) + ->deleteJson("/api/v1/spaces/{$this->space->id}/pipeline-templates/{$template->id}"); + + $response->assertNoContent(); + $this->assertSoftDeleted('pipeline_templates', ['id' => $template->id]); + } + + public function test_publish_marks_template_as_published(): void + { + $template = PipelineTemplate::factory()->forSpace($this->space)->create(['is_published' => false]); + + $response = $this->actingAs($this->user) + ->postJson("/api/v1/spaces/{$this->space->id}/pipeline-templates/{$template->id}/publish"); + + $response->assertOk() + ->assertJsonPath('data.is_published', true); + } + + public function test_unpublish_marks_template_as_unpublished(): void + { + $template = PipelineTemplate::factory()->create(['is_published' => true, 'space_id' => null]); + + $response = $this->actingAs($this->user) + ->postJson("/api/v1/spaces/{$this->space->id}/pipeline-templates/{$template->id}/unpublish"); + + $response->assertOk() + ->assertJsonPath('data.is_published', false); + } + + public function test_versions_index_lists_versions(): void + { + $template = PipelineTemplate::factory()->forSpace($this->space)->create(); + PipelineTemplateVersion::factory()->count(3)->create(['template_id' => $template->id]); + + $response = $this->actingAs($this->user) + ->getJson("/api/v1/spaces/{$this->space->id}/pipeline-templates/{$template->id}/versions"); + + $response->assertOk(); + $this->assertCount(3, $response->json('data')); + } + + public function test_versions_store_creates_new_version(): void + { + $template = PipelineTemplate::factory()->forSpace($this->space)->create(); + + $response = $this->actingAs($this->user) + ->postJson("/api/v1/spaces/{$this->space->id}/pipeline-templates/{$template->id}/versions", [ + 'version' => '1.1.0', + 'definition' => [ + 'schema_version' => '1.0', + 'stages' => [['name' => 'generate', 'type' => 'ai_generate']], + ], + 'changelog' => 'Second version', + ]); + + $response->assertStatus(201) + ->assertJsonPath('data.version', '1.1.0'); + } + + public function test_versions_show_returns_version(): void + { + $template = PipelineTemplate::factory()->forSpace($this->space)->create(); + $version = PipelineTemplateVersion::factory()->latest()->create(['template_id' => $template->id]); + + $response = $this->actingAs($this->user) + ->getJson("/api/v1/spaces/{$this->space->id}/pipeline-templates/{$template->id}/versions/{$version->id}"); + + $response->assertOk() + ->assertJsonPath('data.id', $version->id); + } + + public function test_ratings_index_lists_ratings(): void + { + $template = PipelineTemplate::factory()->create(); + PipelineTemplateRating::factory()->count(2)->create(['template_id' => $template->id]); + + $response = $this->actingAs($this->user) + ->getJson("/api/v1/spaces/{$this->space->id}/pipeline-templates/{$template->id}/ratings"); + + $response->assertOk() + ->assertJsonStructure(['data', 'meta' => ['average_rating', 'total']]); + } + + public function test_ratings_store_creates_rating(): void + { + $template = PipelineTemplate::factory()->create(); + + $response = $this->actingAs($this->user) + ->postJson("/api/v1/spaces/{$this->space->id}/pipeline-templates/{$template->id}/ratings", [ + 'rating' => 4, + 'review' => 'Great template!', + ]); + + $response->assertStatus(201) + ->assertJsonPath('data.rating', 4); + } + + public function test_ratings_validates_rating_range(): void + { + $template = PipelineTemplate::factory()->create(); + + $response = $this->actingAs($this->user) + ->postJson("/api/v1/spaces/{$this->space->id}/pipeline-templates/{$template->id}/ratings", [ + 'rating' => 10, + ]); + + $response->assertStatus(422) + ->assertJsonValidationErrors(['rating']); + } +} From ddcf9c08096a10f12a0a086ed23cd8af565672ef Mon Sep 17 00:00:00 2001 From: numen-bot Date: Mon, 16 Mar 2026 04:19:12 +0000 Subject: [PATCH 06/11] =?UTF-8?q?feat(templates):=20Vue=20components=20?= =?UTF-8?q?=E2=80=94=20TemplateLibraryPage,=20TemplateEditor,=20StageDragL?= =?UTF-8?q?ist=20with=20drag-drop?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Admin/TemplateLibraryController.php | 45 +++ .../js/Components/Templates/StageDragList.vue | 218 +++++++++++++ .../js/Pages/Pipelines/Templates/Editor.vue | 301 ++++++++++++++++++ .../js/Pages/Pipelines/Templates/Library.vue | 294 +++++++++++++++++ routes/web.php | 5 + 5 files changed, 863 insertions(+) create mode 100644 app/Http/Controllers/Admin/TemplateLibraryController.php create mode 100644 resources/js/Components/Templates/StageDragList.vue create mode 100644 resources/js/Pages/Pipelines/Templates/Editor.vue create mode 100644 resources/js/Pages/Pipelines/Templates/Library.vue diff --git a/app/Http/Controllers/Admin/TemplateLibraryController.php b/app/Http/Controllers/Admin/TemplateLibraryController.php new file mode 100644 index 0000000..ad04355 --- /dev/null +++ b/app/Http/Controllers/Admin/TemplateLibraryController.php @@ -0,0 +1,45 @@ +user(); + $space = Space::where('owner_id', $user->id)->first(); + + return Inertia::render('Pipelines/Templates/Library', [ + 'spaceId' => ($space !== null ? $space->id : ''), + ]); + } + + public function create(Request $request) + { + /** @var \App\Models\User $user */ + $user = $request->user(); + $space = Space::where('owner_id', $user->id)->first(); + + return Inertia::render('Pipelines/Templates/Editor', [ + 'spaceId' => ($space !== null ? $space->id : ''), + ]); + } + + public function edit(Request $request, string $templateId) + { + /** @var \App\Models\User $user */ + $user = $request->user(); + $space = Space::where('owner_id', $user->id)->first(); + + return Inertia::render('Pipelines/Templates/Editor', [ + 'spaceId' => ($space !== null ? $space->id : ''), + 'templateId' => $templateId, + ]); + } +} diff --git a/resources/js/Components/Templates/StageDragList.vue b/resources/js/Components/Templates/StageDragList.vue new file mode 100644 index 0000000..0e3dc4e --- /dev/null +++ b/resources/js/Components/Templates/StageDragList.vue @@ -0,0 +1,218 @@ + + + diff --git a/resources/js/Pages/Pipelines/Templates/Editor.vue b/resources/js/Pages/Pipelines/Templates/Editor.vue new file mode 100644 index 0000000..d8da787 --- /dev/null +++ b/resources/js/Pages/Pipelines/Templates/Editor.vue @@ -0,0 +1,301 @@ + + +