Replace free-text brand_tone/brand_voice_notes with a single structured brand_voice_traits JSON column backed by the BrandVoiceTrait enum, exposed as choice-chip pills in the brand settings UI and autofillable from a site. Brand voice and visuals become per-automation toggles on the Generate node. Unify the image controls into one 0-10 picker (0 = text-only, 1 = single, 2+ = carousel) and feed the generator the most restrictive selected network so copy fits every platform. Pass that same platform context through the humanizer pass — extracted into a shared ResolvesPlatformCopyBudget trait — so the rewrite can no longer drift past the character cap the generator respected, in both the automation and manual creation flows. Persist the trigger node's schedule editor fields on save (they were silently dropped by validated() for lacking validation rules).
73 lines
2.2 KiB
PHP
73 lines
2.2 KiB
PHP
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
namespace App\Services\Automation;
|
|
|
|
use App\Enums\PostPlatform\ContentType;
|
|
|
|
/**
|
|
* Backend mirror of the Generate node's frontend compliance: validates that the
|
|
* number of AI images intended for each selected account fits that account's
|
|
* content-type media rules. Uses the ContentType enum as the single source of
|
|
* truth (same `maxMediaCount`/`supportsImage`/`requiresMedia` the publish flow
|
|
* uses) and reuses the post editor's compliance messages so the wording matches
|
|
* exactly. AI image generation is capped at MAX_GENERATED_IMAGES regardless of
|
|
* how many a platform technically allows.
|
|
*/
|
|
final class GenerateNodeValidator
|
|
{
|
|
public const MAX_GENERATED_IMAGES = 10;
|
|
|
|
/**
|
|
* First compliance issue for a generate node's config, or null when valid.
|
|
*
|
|
* @param array<string, mixed> $config
|
|
*/
|
|
public function issueFor(array $config): ?string
|
|
{
|
|
$accounts = data_get($config, 'accounts');
|
|
|
|
if (! is_array($accounts) || $accounts === []) {
|
|
return null;
|
|
}
|
|
|
|
// Single source of truth: 0 = text-only, 1 = single image, 2+ = carousel.
|
|
$imageCount = (int) data_get($config, 'target_slide_count', 1);
|
|
|
|
foreach ($accounts as $entry) {
|
|
$contentType = ContentType::tryFrom((string) data_get($entry, 'content_type'));
|
|
|
|
if (! $contentType instanceof ContentType) {
|
|
continue;
|
|
}
|
|
|
|
$issue = $this->issueForAccount($contentType, $imageCount);
|
|
|
|
if ($issue !== null) {
|
|
return $issue;
|
|
}
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
private function issueForAccount(ContentType $contentType, int $imageCount): ?string
|
|
{
|
|
if ($contentType->requiresMedia() && $imageCount === 0) {
|
|
return __('posts.edit.compliance.requires_media');
|
|
}
|
|
|
|
if ($imageCount > 0 && ! $contentType->supportsImage()) {
|
|
return __('posts.edit.compliance.no_images');
|
|
}
|
|
|
|
$max = min(self::MAX_GENERATED_IMAGES, $contentType->maxMediaCount());
|
|
|
|
if ($imageCount > $max) {
|
|
return __('posts.edit.compliance.too_many_files', ['max' => (string) $max]);
|
|
}
|
|
|
|
return null;
|
|
}
|
|
}
|