feat(ai-templates): ContentStyle enum as the typed source for the 3 styles

This commit is contained in:
Paulo Castellano 2026-06-17 19:52:38 -03:00
parent d8291cb155
commit 0574560570
12 changed files with 170 additions and 30 deletions

View file

@ -4,10 +4,14 @@
namespace App\Ai\Templates;
use App\Enums\Ai\ContentStyle;
use Illuminate\Contracts\JsonSchema\JsonSchema;
interface AiContentTemplate
{
/** The typed enum case for this style. */
public function style(): ContentStyle;
/** Stable key used in the request + registry, e.g. 'image_card'. */
public function key(): string;

View file

@ -4,6 +4,7 @@
namespace App\Ai\Templates;
use App\Enums\Ai\ContentStyle;
use InvalidArgumentException;
class AiTemplateRegistry
@ -30,15 +31,23 @@ public function keys(): array
return array_map(fn (AiContentTemplate $t) => $t->key(), $this->all());
}
public function find(string $key): AiContentTemplate
/** @return array<int, ContentStyle> */
public function styles(): array
{
return ContentStyle::cases();
}
public function find(ContentStyle|string $style): AiContentTemplate
{
$value = $style instanceof ContentStyle ? $style->value : $style;
foreach ($this->all() as $template) {
if ($template->key() === $key) {
if ($template->key() === $value) {
return $template;
}
}
throw new InvalidArgumentException("Unknown AI content template: {$key}");
throw new InvalidArgumentException("Unknown AI content template: {$value}");
}
public function default(): AiContentTemplate

View file

@ -5,6 +5,7 @@
namespace App\Ai\Templates;
use App\Ai\Templates\Concerns\ResolvesContentType;
use App\Enums\Ai\ContentStyle;
use App\Enums\PostPlatform\ContentType;
use App\Services\Image\PostImagePipeline;
use Illuminate\Contracts\JsonSchema\JsonSchema;
@ -13,29 +14,34 @@ class ImageCardTemplate implements AiContentTemplate
{
use ResolvesContentType;
public function style(): ContentStyle
{
return ContentStyle::ImageCard;
}
public function key(): string
{
return 'image_card';
return $this->style()->value;
}
public function name(): string
{
return 'posts.ai.templates.image_card.name';
return $this->style()->label();
}
public function description(): string
{
return 'posts.ai.templates.image_card.description';
return $this->style()->description();
}
public function previewAsset(): string
{
return '/images/ai-templates/image-card.png';
return $this->style()->previewAsset();
}
public function needsAccount(): bool
{
return false;
return $this->style()->needsAccount();
}
public function supportedFormats(): array

View file

@ -4,35 +4,41 @@
namespace App\Ai\Templates;
use App\Enums\Ai\ContentStyle;
use App\Enums\PostPlatform\ContentType;
use App\Services\Image\PostImagePipeline;
use Illuminate\Contracts\JsonSchema\JsonSchema;
class TweetCardImageTemplate implements AiContentTemplate
{
public function style(): ContentStyle
{
return ContentStyle::TweetCardImage;
}
public function key(): string
{
return 'tweet_card_image';
return $this->style()->value;
}
public function name(): string
{
return 'posts.ai.templates.tweet_card_image.name';
return $this->style()->label();
}
public function description(): string
{
return 'posts.ai.templates.tweet_card_image.description';
return $this->style()->description();
}
public function previewAsset(): string
{
return '/images/ai-templates/tweet-card-image.png';
return $this->style()->previewAsset();
}
public function needsAccount(): bool
{
return true;
return $this->style()->needsAccount();
}
/** @return array<int, string> */

View file

@ -4,35 +4,41 @@
namespace App\Ai\Templates;
use App\Enums\Ai\ContentStyle;
use App\Enums\PostPlatform\ContentType;
use App\Services\Image\PostImagePipeline;
use Illuminate\Contracts\JsonSchema\JsonSchema;
class TweetCardTemplate implements AiContentTemplate
{
public function style(): ContentStyle
{
return ContentStyle::TweetCard;
}
public function key(): string
{
return 'tweet_card';
return $this->style()->value;
}
public function name(): string
{
return 'posts.ai.templates.tweet_card.name';
return $this->style()->label();
}
public function description(): string
{
return 'posts.ai.templates.tweet_card.description';
return $this->style()->description();
}
public function previewAsset(): string
{
return '/images/ai-templates/tweet-card.png';
return $this->style()->previewAsset();
}
public function needsAccount(): bool
{
return true;
return $this->style()->needsAccount();
}
/** @return array<int, string> */

View file

@ -0,0 +1,48 @@
<?php
declare(strict_types=1);
namespace App\Enums\Ai;
enum ContentStyle: string
{
case ImageCard = 'image_card';
case TweetCard = 'tweet_card';
case TweetCardImage = 'tweet_card_image';
/** i18n key for the style's display name. */
public function label(): string
{
return "posts.ai.templates.{$this->value}.name";
}
/** i18n key for the style's description. */
public function description(): string
{
return "posts.ai.templates.{$this->value}.description";
}
/** Whether this style requires a connected social account. */
public function needsAccount(): bool
{
return match ($this) {
self::ImageCard => false,
self::TweetCard, self::TweetCardImage => true,
};
}
/** Public path to the picker preview thumbnail. */
public function previewAsset(): string
{
return match ($this) {
self::ImageCard => '/images/ai-templates/image-card.png',
self::TweetCard => '/images/ai-templates/tweet-card.png',
self::TweetCardImage => '/images/ai-templates/tweet-card-image.png',
};
}
public static function default(): self
{
return self::ImageCard;
}
}

View file

@ -4,7 +4,7 @@
namespace App\Http\Requests\App\Ai;
use App\Ai\Templates\AiTemplateRegistry;
use App\Enums\Ai\ContentStyle;
use App\Enums\PostPlatform\ContentType;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule;
@ -35,21 +35,17 @@ public function rules(): array
'image_count' => ['nullable', 'integer', 'min:0', 'max:10'],
'prompt' => ['required', 'string', 'max:2000'],
'date' => ['nullable', 'date_format:Y-m-d'],
'template' => ['sometimes', 'string', Rule::in(app(AiTemplateRegistry::class)->keys())],
'template' => ['sometimes', 'string', Rule::enum(ContentStyle::class)],
];
}
public function withValidator(Validator $validator): void
{
$validator->after(function (Validator $validator): void {
$key = (string) $this->input('template', 'image_card');
$registry = app(AiTemplateRegistry::class);
$style = ContentStyle::tryFrom((string) $this->input('template', ContentStyle::default()->value))
?? ContentStyle::default();
if (! in_array($key, $registry->keys(), true)) {
return;
}
if ($registry->find($key)->needsAccount() && blank($this->input('social_account_id'))) {
if ($style->needsAccount() && blank($this->input('social_account_id'))) {
$validator->errors()->add('social_account_id', trans('validation.required', ['attribute' => 'social account']));
}
});

View file

@ -5,6 +5,8 @@
use App\Ai\Templates\AiTemplateRegistry;
use App\Ai\Templates\ImageCardTemplate;
use App\Ai\Templates\TweetCardImageTemplate;
use App\Ai\Templates\TweetCardTemplate;
use App\Enums\Ai\ContentStyle;
test('registry resolves keys and defaults to image_card', function () {
$registry = app(AiTemplateRegistry::class);
@ -14,6 +16,23 @@
->and($registry->default()->key())->toBe('image_card');
});
test('registry find accepts a ContentStyle enum', function () {
$registry = app(AiTemplateRegistry::class);
expect($registry->find(ContentStyle::TweetCard))->toBeInstanceOf(TweetCardTemplate::class)
->and($registry->find(ContentStyle::ImageCard))->toBeInstanceOf(ImageCardTemplate::class)
->and($registry->find(ContentStyle::TweetCardImage))->toBeInstanceOf(TweetCardImageTemplate::class);
});
test('registry find accepts a string key', function () {
$registry = app(AiTemplateRegistry::class);
expect($registry->find('tweet_card'))->toBeInstanceOf(TweetCardTemplate::class);
});
test('registry styles returns all ContentStyle cases', function () {
$registry = app(AiTemplateRegistry::class);
expect($registry->styles())->toBe(ContentStyle::cases());
});
test('registry throws on unknown key', function () {
expect(fn () => app(AiTemplateRegistry::class)->find('nope'))->toThrow(InvalidArgumentException::class);
});

View file

@ -4,6 +4,7 @@
use App\Ai\Templates\ImageCardTemplate;
use App\Ai\Templates\TemplateContext;
use App\Enums\Ai\ContentStyle;
use App\Models\Workspace;
use Illuminate\JsonSchema\JsonSchemaTypeFactory;
@ -12,7 +13,8 @@
$workspace = Workspace::factory()->create();
$context = new TemplateContext($workspace, null, 'instagram_feed', 0, false);
expect($t->key())->toBe('image_card')
expect($t->style())->toBe(ContentStyle::ImageCard)
->and($t->key())->toBe('image_card')
->and($t->needsAccount())->toBeFalse()
->and($t->generatorFormat())->toBe('single')
->and($t->promptView($context))->toBe('prompts.post_content.generator')

View file

@ -4,6 +4,7 @@
use App\Ai\Templates\TemplateContext;
use App\Ai\Templates\TweetCardImageTemplate;
use App\Enums\Ai\ContentStyle;
use App\Models\Workspace;
use Illuminate\JsonSchema\JsonSchemaTypeFactory;
@ -13,7 +14,8 @@
$singleContext = new TemplateContext($workspace, null, 'x_post', 1, false);
$carouselContext = new TemplateContext($workspace, null, 'instagram_carousel', 2, true);
expect($t->key())->toBe('tweet_card_image')
expect($t->style())->toBe(ContentStyle::TweetCardImage)
->and($t->key())->toBe('tweet_card_image')
->and($t->needsAccount())->toBeTrue()
->and($t->generatorFormat())->toBe('tweet_card')
->and($t->promptView($singleContext))->toBe('prompts.post_content.tweet_card_image')

View file

@ -4,6 +4,7 @@
use App\Ai\Templates\TemplateContext;
use App\Ai\Templates\TweetCardTemplate;
use App\Enums\Ai\ContentStyle;
use App\Models\Workspace;
use Illuminate\JsonSchema\JsonSchemaTypeFactory;
@ -13,7 +14,8 @@
$singleContext = new TemplateContext($workspace, null, 'x_post', 1, false);
$carouselContext = new TemplateContext($workspace, null, 'instagram_carousel', 2, true);
expect($t->key())->toBe('tweet_card')
expect($t->style())->toBe(ContentStyle::TweetCard)
->and($t->key())->toBe('tweet_card')
->and($t->needsAccount())->toBeTrue()
->and($t->generatorFormat())->toBe('tweet_card')
->and($t->promptView($singleContext))->toBe('prompts.post_content.tweet_card')

View file

@ -0,0 +1,40 @@
<?php
declare(strict_types=1);
use App\Enums\Ai\ContentStyle;
test('enum has exactly 3 cases with the correct string values', function () {
expect(ContentStyle::cases())->toHaveCount(3);
expect(ContentStyle::ImageCard->value)->toBe('image_card');
expect(ContentStyle::TweetCard->value)->toBe('tweet_card');
expect(ContentStyle::TweetCardImage->value)->toBe('tweet_card_image');
});
test('default returns ImageCard', function () {
expect(ContentStyle::default())->toBe(ContentStyle::ImageCard);
});
test('needsAccount is false only for ImageCard', function () {
expect(ContentStyle::ImageCard->needsAccount())->toBeFalse();
expect(ContentStyle::TweetCard->needsAccount())->toBeTrue();
expect(ContentStyle::TweetCardImage->needsAccount())->toBeTrue();
});
test('previewAsset returns the correct public path for each case', function () {
expect(ContentStyle::ImageCard->previewAsset())->toBe('/images/ai-templates/image-card.png');
expect(ContentStyle::TweetCard->previewAsset())->toBe('/images/ai-templates/tweet-card.png');
expect(ContentStyle::TweetCardImage->previewAsset())->toBe('/images/ai-templates/tweet-card-image.png');
});
test('label returns the expected i18n key format', function () {
expect(ContentStyle::ImageCard->label())->toBe('posts.ai.templates.image_card.name');
expect(ContentStyle::TweetCard->label())->toBe('posts.ai.templates.tweet_card.name');
expect(ContentStyle::TweetCardImage->label())->toBe('posts.ai.templates.tweet_card_image.name');
});
test('description returns the expected i18n key format', function () {
expect(ContentStyle::ImageCard->description())->toBe('posts.ai.templates.image_card.description');
expect(ContentStyle::TweetCard->description())->toBe('posts.ai.templates.tweet_card.description');
expect(ContentStyle::TweetCardImage->description())->toBe('posts.ai.templates.tweet_card_image.description');
});