trypost/app/Http/Requests/App/Automations/UpdateAutomationRequest.php
Paulo Castellano a09b1f45c2 Structure brand voice and make generated copy platform-aware
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).
2026-06-12 17:09:39 -03:00

187 lines
8 KiB
PHP

<?php
declare(strict_types=1);
namespace App\Http\Requests\App\Automations;
use App\Enums\Automation\Condition\Operator as ConditionOperator;
use App\Enums\Automation\Node\Type as NodeType;
use App\Enums\Automation\Publish\Mode as PublishMode;
use App\Enums\Automation\Trigger\Type as TriggerType;
use App\Services\Automation\GenerateNodeValidator;
use Illuminate\Contracts\Validation\Validator;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule;
class UpdateAutomationRequest extends FormRequest
{
public function authorize(): bool
{
return true;
}
/**
* @return array<string, mixed>
*/
public function rules(): array
{
$rules = [
'name' => ['sometimes', 'string', 'max:120'],
'nodes' => ['sometimes', 'array'],
'nodes.*.id' => ['required', 'string'],
'nodes.*.type' => ['required', 'string', Rule::in(array_column(NodeType::cases(), 'value'))],
'nodes.*.position' => ['required', 'array'],
'nodes.*.position.x' => ['required', 'numeric'],
'nodes.*.position.y' => ['required', 'numeric'],
'nodes.*.data' => ['required', 'array'],
'connections' => ['sometimes', 'array'],
'connections.*.id' => ['required', 'string'],
'connections.*.source' => ['required', 'string'],
'connections.*.target' => ['required', 'string'],
'connections.*.source_handle' => ['nullable', 'string'],
'connections.*.target_handle' => ['nullable', 'string'],
'variables' => ['sometimes', 'array', 'max:50'],
'variables.*.key' => ['required', 'string', 'max:60', 'regex:/^[A-Za-z_][A-Za-z0-9_]*$/', 'distinct'],
'variables.*.value' => ['nullable', 'string'],
];
// Per-node data validation. We build these dynamically so each node's
// type drives the shape of its `data` payload, and so errors come back
// with full paths like `nodes.2.data.feed_url` for the frontend to map.
$nodes = $this->input('nodes', []);
if (is_array($nodes)) {
foreach ($nodes as $i => $node) {
$type = data_get($node, 'type');
foreach ($this->dataRulesForNodeType($type) as $field => $fieldRules) {
$rules["nodes.{$i}.data.{$field}"] = $fieldRules;
}
}
}
return $rules;
}
/**
* Block saving a Generate node whose intended image count doesn't fit a
* selected account's content-type (mirrors the inline frontend validation),
* keyed so the frontend surfaces it under that node's accounts field.
*/
public function withValidator(Validator $validator): void
{
$validator->after(function (Validator $validator): void {
$nodes = $this->input('nodes', []);
if (! is_array($nodes)) {
return;
}
$generateValidator = app(GenerateNodeValidator::class);
foreach ($nodes as $i => $node) {
if (data_get($node, 'type') !== NodeType::Generate->value) {
continue;
}
$issue = $generateValidator->issueFor((array) data_get($node, 'data', []));
if ($issue !== null) {
$validator->errors()->add("nodes.{$i}.data.accounts", $issue);
}
}
});
}
/**
* @return array<string, mixed>
*/
public function attributes(): array
{
return [
'nodes.*.data.feed_url' => 'Feed URL',
'nodes.*.data.url' => 'URL',
'nodes.*.data.cron' => 'cron expression',
'nodes.*.data.duration' => 'duration',
'nodes.*.data.unit' => 'unit',
'nodes.*.data.field' => 'field',
'nodes.*.data.operator' => 'operator',
'nodes.*.data.mode' => 'mode',
'nodes.*.data.method' => 'method',
'nodes.*.data.trigger_type' => 'trigger type',
'nodes.*.data.prompt_template' => 'prompt template',
'nodes.*.data.accounts' => 'accounts',
];
}
/**
* @return array<string, array<int, mixed>>
*/
private function dataRulesForNodeType(?string $type): array
{
return match ($type) {
NodeType::Trigger->value => [
'trigger_type' => ['required', Rule::in(array_column(TriggerType::cases(), 'value'))],
'cron' => ['required_if:nodes.*.data.trigger_type,'.TriggerType::Schedule->value, 'string'],
'schedule_field' => ['sometimes', Rule::in(['minutes', 'hours', 'days', 'weeks', 'months', 'custom'])],
'schedule_minutes_interval' => ['sometimes', 'integer', 'min:1', 'max:59'],
'schedule_hours_interval' => ['sometimes', 'integer', 'min:1', 'max:23'],
'schedule_days_interval' => ['sometimes', 'integer', 'min:1', 'max:31'],
'schedule_hour' => ['sometimes', 'integer', 'min:0', 'max:23'],
'schedule_minute' => ['sometimes', 'integer', 'min:0', 'max:59'],
'schedule_weekdays' => ['sometimes', 'array'],
'schedule_weekdays.*' => ['integer', 'min:0', 'max:6'],
'schedule_day_of_month' => ['sometimes', 'integer', 'min:1', 'max:31'],
'schedule_custom_cron' => ['sometimes', 'nullable', 'string'],
'schedule_timezone' => ['sometimes', 'string', 'timezone'],
],
NodeType::FetchRss->value => [
'feed_url' => ['required', 'url'],
],
NodeType::HttpRequest->value => [
'url' => ['required', 'url'],
'method' => ['required', Rule::in(['GET', 'POST', 'PUT', 'PATCH', 'DELETE'])],
'auth_type' => ['required', Rule::in(['none', 'bearer', 'basic', 'api_key'])],
'auth_token' => ['nullable', 'string'],
'auth_username' => ['nullable', 'string'],
'auth_password' => ['nullable', 'string'],
'auth_header_name' => ['nullable', 'string'],
'body_template' => ['nullable', 'string'],
'headers' => ['nullable', 'array'],
'headers.*' => ['string'],
'items_path' => ['nullable', 'string'],
'item_key_path' => ['nullable', 'string'],
'item_date_path' => ['nullable', 'string'],
],
NodeType::Generate->value => [
'accounts' => ['required', 'array', 'min:1'],
'prompt_template' => ['required', 'string'],
'target_slide_count' => ['nullable', 'integer', 'min:0', 'max:'.GenerateNodeValidator::MAX_GENERATED_IMAGES],
'use_brand_voice' => ['sometimes', 'boolean'],
'use_brand_visuals' => ['sometimes', 'boolean'],
],
NodeType::Delay->value => [
'duration' => ['required', 'integer', 'min:1'],
'unit' => ['required', Rule::in(['minutes', 'hours', 'days'])],
],
NodeType::Condition->value => [
'field' => ['required', 'string'],
'operator' => ['required', Rule::in(array_column(ConditionOperator::cases(), 'value'))],
'value' => ['nullable', 'string'],
],
NodeType::Publish->value => [
'mode' => ['required', Rule::in(array_column(PublishMode::cases(), 'value'))],
'scheduled_offset' => ['nullable', 'integer', 'min:0'],
],
NodeType::Webhook->value => [
'url' => ['required', 'url'],
'method' => ['required', Rule::in(['GET', 'POST', 'PUT', 'PATCH', 'DELETE'])],
'payload_template' => ['nullable', 'string'],
'headers' => ['nullable', 'array'],
'headers.*' => ['string'],
],
NodeType::End->value => [
'reason' => ['nullable', 'string'],
],
default => [],
};
}
}