From 9a692b4608acb2987d5e4fcd2fcec483ed4f7c22 Mon Sep 17 00:00:00 2001 From: Paulo Castellano Date: Thu, 11 Jun 2026 15:47:29 -0300 Subject: [PATCH] Enhance automation functionality: Introduce workflow variables and improve node validation - Added support for workflow variables in automations, allowing users to define reusable values. - Implemented validation for Generate nodes to ensure intended image counts align with selected accounts. - Updated automation models and requests to handle new variables, including encryption for sensitive data. - Enhanced UI to display variables and their management within the automation editor. - Improved error handling for webhook and HTTP nodes to prevent requests to invalid URLs. - Refactored various components for better context resolution during automation runs. --- .../Automation/ActivateAutomation.php | 16 + .../Automation/CreateAutomation.php | 29 +- .../Automation/UpdateAutomation.php | 1 + .../Automation/Node/RunConditionNode.php | 5 +- .../Automation/Node/RunFetchRssNode.php | 19 +- .../Automation/Node/RunGenerateNode.php | 30 +- .../Automation/Node/RunHttpRequestNode.php | 19 +- .../Automation/Node/RunWebhookNode.php | 29 +- .../Automation/ProcessAutomationDelays.php | 19 +- .../Automations/UpdateAutomationRequest.php | 39 ++- app/Http/Resources/AutomationResource.php | 21 ++ app/Models/Automation.php | 73 +++++ app/Models/AutomationRun.php | 17 ++ .../Automation/GenerateNodeValidator.php | 92 ++++++ ...832_add_variables_to_automations_table.php | 28 ++ lang/en/automations.php | 28 +- lang/en/common.php | 1 + lang/es/automations.php | 28 +- lang/es/common.php | 1 + lang/pt-BR/automations.php | 28 +- lang/pt-BR/common.php | 1 + resources/css/app.css | 2 + .../js/components/ChannelConfigurator.vue | 173 +++++++++++ resources/js/components/CodeEditor.vue | 17 +- resources/js/components/JsonViewer.vue | 19 +- .../js/components/automations/BuildPanel.vue | 88 ++++++ .../components/automations/EditorSidebar.vue | 71 +++++ .../components/automations/TestRunPanel.vue | 45 +-- .../components/automations/VariablesPanel.vue | 89 ++++++ .../automations/config/GenerateNodeConfig.vue | 220 +++++++------- .../automations/nodes/DelayNode.vue | 8 +- .../posts/editor/PinterestSettings.vue | 2 +- .../components/posts/editor/ScheduleTab.vue | 281 ++++-------------- resources/js/composables/usePostCompliance.ts | 41 ++- resources/js/lib/utils.ts | 10 +- resources/js/pages/automations/Form.vue | 203 +++++-------- resources/js/pages/automations/Index.vue | 9 +- resources/js/types/automation/automation.ts | 6 + resources/js/types/channel.ts | 43 +++ .../Feature/Automation/AutomationCrudTest.php | 12 + .../Command/ProcessAutomationDelaysTest.php | 24 ++ tests/Feature/Automation/DryRunTest.php | 14 +- .../Automation/GenerateNodeValidationTest.php | 123 ++++++++ .../Automation/Node/FetchRssNodeTest.php | 46 +-- .../Automation/Node/GenerateNodeTest.php | 2 +- .../Automation/Node/HttpRequestNodeTest.php | 45 ++- .../Automation/Node/WebhookNodeTest.php | 70 ++++- .../Tree/ContentPipelineTreesTest.php | 30 +- tests/Feature/Automation/VariablesTest.php | 161 ++++++++++ 49 files changed, 1723 insertions(+), 655 deletions(-) create mode 100644 app/Services/Automation/GenerateNodeValidator.php create mode 100644 database/migrations/2026_06_11_181832_add_variables_to_automations_table.php create mode 100644 resources/js/components/ChannelConfigurator.vue create mode 100644 resources/js/components/automations/BuildPanel.vue create mode 100644 resources/js/components/automations/EditorSidebar.vue create mode 100644 resources/js/components/automations/VariablesPanel.vue create mode 100644 resources/js/types/channel.ts create mode 100644 tests/Feature/Automation/GenerateNodeValidationTest.php create mode 100644 tests/Feature/Automation/VariablesTest.php diff --git a/app/Actions/Automation/Automation/ActivateAutomation.php b/app/Actions/Automation/Automation/ActivateAutomation.php index f3abc452..7b83b918 100644 --- a/app/Actions/Automation/Automation/ActivateAutomation.php +++ b/app/Actions/Automation/Automation/ActivateAutomation.php @@ -4,11 +4,15 @@ namespace App\Actions\Automation\Automation; +use App\Enums\Automation\Node\Type as NodeType; use App\Enums\Automation\Status; use App\Models\Automation; +use App\Services\Automation\GenerateNodeValidator; class ActivateAutomation { + public function __construct(private GenerateNodeValidator $generateValidator) {} + public function __invoke(Automation $automation): Automation { $this->validate($automation); @@ -37,5 +41,17 @@ private function validate(Automation $automation): void if (! $hasTargetFromTrigger) { throw new \DomainException(__('automations.errors.trigger_must_be_connected')); } + + foreach ($nodes as $node) { + if (data_get($node, 'type') !== NodeType::Generate->value) { + continue; + } + + $issue = $this->generateValidator->issueFor((array) data_get($node, 'data', [])); + + if ($issue !== null) { + throw new \DomainException($issue); + } + } } } diff --git a/app/Actions/Automation/Automation/CreateAutomation.php b/app/Actions/Automation/Automation/CreateAutomation.php index b779bdfa..59db041a 100644 --- a/app/Actions/Automation/Automation/CreateAutomation.php +++ b/app/Actions/Automation/Automation/CreateAutomation.php @@ -5,9 +5,11 @@ namespace App\Actions\Automation\Automation; use App\Enums\Automation\Status; +use App\Enums\Automation\Trigger\Type as TriggerType; use App\Models\Automation; use App\Models\User; use App\Models\Workspace; +use Illuminate\Support\Str; class CreateAutomation { @@ -18,8 +20,33 @@ public function __invoke(Workspace $workspace, User $user, ?string $name = null) 'user_id' => $user->id, 'name' => $name ?: __('automations.default_name'), 'status' => Status::Draft, - 'nodes' => [], + 'nodes' => [$this->defaultTriggerNode()], 'connections' => [], ]); } + + /** + * Every automation has exactly one trigger — its entry point — so we seed it + * on creation. The trigger can't be added or deleted from the editor; only + * its type (schedule / post published / post scheduled) is configurable. + * + * @return array + */ + private function defaultTriggerNode(): array + { + return [ + 'id' => (string) Str::uuid(), + 'type' => 'trigger', + 'position' => ['x' => 0, 'y' => 0], + 'data' => [ + 'trigger_type' => TriggerType::Schedule->value, + 'cron' => '0 9 * * *', + 'schedule_field' => 'days', + 'schedule_days_interval' => 1, + 'schedule_hour' => 9, + 'schedule_minute' => 0, + 'schedule_timezone' => config('app.timezone'), + ], + ]; + } } diff --git a/app/Actions/Automation/Automation/UpdateAutomation.php b/app/Actions/Automation/Automation/UpdateAutomation.php index f8470d5d..02195e78 100644 --- a/app/Actions/Automation/Automation/UpdateAutomation.php +++ b/app/Actions/Automation/Automation/UpdateAutomation.php @@ -17,6 +17,7 @@ public function __invoke(Automation $automation, array $data): Automation 'name' => $data['name'] ?? $automation->name, 'nodes' => $data['nodes'] ?? $automation->nodes, 'connections' => $data['connections'] ?? $automation->connections, + 'variables' => $data['variables'] ?? $automation->variables, ]); return $automation->fresh(); diff --git a/app/Actions/Automation/Node/RunConditionNode.php b/app/Actions/Automation/Node/RunConditionNode.php index 753dbf82..34935f8e 100644 --- a/app/Actions/Automation/Node/RunConditionNode.php +++ b/app/Actions/Automation/Node/RunConditionNode.php @@ -17,9 +17,10 @@ public function __construct(private ExpressionResolver $resolver) {} public function __invoke(AutomationRun $run, array $config): NodeRunResult { - $field = $this->resolver->resolve(data_get($config, 'field', ''), $run->context ?? []); + $context = $run->resolverContext(); + $field = $this->resolver->resolve(data_get($config, 'field', ''), $context); $operator = Operator::from(data_get($config, 'operator', 'equals')); - $value = (string) data_get($config, 'value', ''); + $value = $this->resolver->resolve((string) data_get($config, 'value', ''), $context); $matched = match ($operator) { Operator::Contains => str_contains($field, $value), diff --git a/app/Actions/Automation/Node/RunFetchRssNode.php b/app/Actions/Automation/Node/RunFetchRssNode.php index e7374d9b..c66a0a5e 100644 --- a/app/Actions/Automation/Node/RunFetchRssNode.php +++ b/app/Actions/Automation/Node/RunFetchRssNode.php @@ -9,9 +9,12 @@ use App\Jobs\Automation\ProcessAutomationNode; use App\Models\AutomationNodeState; use App\Models\AutomationRun; +use App\Services\Automation\ExpressionResolver; +use App\Services\Brand\SafeHttpFetcher; use Carbon\CarbonImmutable; use Illuminate\Support\Facades\Http; use Illuminate\Support\Facades\Log; +use RuntimeException; use SimpleXMLElement; use Throwable; @@ -32,14 +35,28 @@ class RunFetchRssNode { private const ITEM_HANDLE = 'default'; + public function __construct( + private ExpressionResolver $resolver, + private SafeHttpFetcher $safeHttp, + ) {} + public function __invoke(AutomationRun $run, array $config): NodeRunResult { - $feedUrl = (string) data_get($config, 'feed_url', ''); + $feedUrl = $this->resolver->resolve((string) data_get($config, 'feed_url', ''), $run->resolverContext()); if ($feedUrl === '') { return NodeRunResult::failed('Fetch RSS node missing feed_url.'); } + try { + $this->safeHttp->guardAgainstSsrf($feedUrl); + } catch (RuntimeException) { + return NodeRunResult::failed(__('automations.errors.url_not_allowed'), [ + 'reason' => 'url_not_allowed', + 'url' => $feedUrl, + ]); + } + $response = Http::get($feedUrl); if (! $response->successful()) { diff --git a/app/Actions/Automation/Node/RunGenerateNode.php b/app/Actions/Automation/Node/RunGenerateNode.php index 7e71f1ba..286c66c8 100644 --- a/app/Actions/Automation/Node/RunGenerateNode.php +++ b/app/Actions/Automation/Node/RunGenerateNode.php @@ -15,6 +15,7 @@ use App\Models\Workspace; use App\Services\Ai\RecordAiUsage; use App\Services\Automation\ExpressionResolver; +use App\Services\Automation\GenerateNodeValidator; use App\Services\Image\PostImagePipeline; use Illuminate\Support\Facades\Log; @@ -26,7 +27,7 @@ public function __construct( public function __invoke(AutomationRun $run, array $config): NodeRunResult { - $context = $run->context ?? []; + $context = $run->resolverContext(); $prompt = $this->resolver->resolve(data_get($config, 'prompt_template', ''), $context); $accountsConfig = $this->resolveAccountsConfig($config); @@ -238,26 +239,25 @@ private function humanize(Workspace $workspace, array $structured, string $forma */ public function deriveFormat(array $accountsConfig, array $config): array { - $carouselCapable = [ - ContentType::InstagramFeed->value, - ContentType::LinkedInCarousel->value, - ContentType::LinkedInPageCarousel->value, - ContentType::PinterestCarousel->value, - ContentType::TikTokPhoto->value, - ]; - - $hasCarouselAccount = false; + // Multi-image capability comes from the same ContentType rules the + // publish flow uses — never a hardcoded list — so facebook_post, + // tiktok_photo, linkedin_carousel etc. are all recognised. We cap the + // slide count at MAX_GENERATED_IMAGES and at the tightest selected + // account's limit. + $maxImagesAcross = 0; foreach ($accountsConfig as $entry) { - if (in_array(data_get($entry, 'content_type'), $carouselCapable, strict: true)) { - $hasCarouselAccount = true; - break; + $contentType = ContentType::tryFrom((string) data_get($entry, 'content_type')); + if ($contentType instanceof ContentType && $contentType->supportsImage() && $contentType->maxMediaCount() > 1) { + $maxImagesAcross = max($maxImagesAcross, $contentType->maxMediaCount()); } } $targetSlideCount = (int) data_get($config, 'target_slide_count', 1); - if ($hasCarouselAccount && $targetSlideCount > 1) { - return ['format' => 'carousel', 'slide_count' => $targetSlideCount]; + if ($maxImagesAcross > 1 && $targetSlideCount > 1) { + $cap = min(GenerateNodeValidator::MAX_GENERATED_IMAGES, $maxImagesAcross); + + return ['format' => 'carousel', 'slide_count' => min($targetSlideCount, $cap)]; } return ['format' => 'single', 'slide_count' => 1]; diff --git a/app/Actions/Automation/Node/RunHttpRequestNode.php b/app/Actions/Automation/Node/RunHttpRequestNode.php index 67115183..f5a42ab1 100644 --- a/app/Actions/Automation/Node/RunHttpRequestNode.php +++ b/app/Actions/Automation/Node/RunHttpRequestNode.php @@ -10,10 +10,12 @@ use App\Models\AutomationNodeState; use App\Models\AutomationRun; use App\Services\Automation\ExpressionResolver; +use App\Services\Brand\SafeHttpFetcher; use Carbon\CarbonImmutable; use Illuminate\Http\Client\PendingRequest; use Illuminate\Support\Facades\Crypt; use Illuminate\Support\Facades\Http; +use RuntimeException; use Throwable; /** @@ -35,7 +37,10 @@ class RunHttpRequestNode { private const ITEM_HANDLE = 'default'; - public function __construct(private ExpressionResolver $resolver) {} + public function __construct( + private ExpressionResolver $resolver, + private SafeHttpFetcher $safeHttp, + ) {} public function __invoke(AutomationRun $run, array $config): NodeRunResult { @@ -45,13 +50,23 @@ public function __invoke(AutomationRun $run, array $config): NodeRunResult $itemKeyPath = data_get($config, 'item_key_path'); $itemDatePath = data_get($config, 'item_date_path'); $nodeId = (string) $run->current_node_id; - $context = $run->context ?? []; + $context = $run->resolverContext(); if ($url === '') { return NodeRunResult::failed('HTTP request node missing url.'); } $resolvedUrl = $this->resolver->resolve($url, $context); + + try { + $this->safeHttp->guardAgainstSsrf($resolvedUrl); + } catch (RuntimeException) { + return NodeRunResult::failed(__('automations.errors.url_not_allowed'), [ + 'reason' => 'url_not_allowed', + 'url' => $resolvedUrl, + ]); + } + $request = $this->buildRequest($config, $context); $body = $this->buildJsonBody($method, $config, $context); diff --git a/app/Actions/Automation/Node/RunWebhookNode.php b/app/Actions/Automation/Node/RunWebhookNode.php index 11387952..18ee13ff 100644 --- a/app/Actions/Automation/Node/RunWebhookNode.php +++ b/app/Actions/Automation/Node/RunWebhookNode.php @@ -7,23 +7,38 @@ use App\DataTransferObjects\Automation\NodeRunResult; use App\Models\AutomationRun; use App\Services\Automation\ExpressionResolver; +use App\Services\Brand\SafeHttpFetcher; use Illuminate\Support\Facades\Http; +use RuntimeException; class RunWebhookNode { - public function __construct(private ExpressionResolver $resolver) {} + public function __construct( + private ExpressionResolver $resolver, + private SafeHttpFetcher $safeHttp, + ) {} public function __invoke(AutomationRun $run, array $config): NodeRunResult { - $url = $this->resolver->resolve($config['url'] ?? '', $run->context ?? []); + $context = $run->resolverContext(); + $url = $this->resolver->resolve($config['url'] ?? '', $context); $method = strtoupper($config['method'] ?? 'POST'); + + try { + $this->safeHttp->guardAgainstSsrf($url); + } catch (RuntimeException) { + return NodeRunResult::failed(__('automations.errors.url_not_allowed'), [ + 'reason' => 'url_not_allowed', + 'url' => $url, + ]); + } $headers = []; foreach ($config['headers'] ?? [] as $k => $v) { - $headers[$k] = $this->resolver->resolve((string) $v, $run->context ?? []); + $headers[$k] = $this->resolver->resolve((string) $v, $context); } - $payloadJson = $this->resolver->resolve($config['payload_template'] ?? '{}', $run->context ?? []); + $payloadJson = $this->resolver->resolve($config['payload_template'] ?? '{}', $context); $trimmedPayload = trim($payloadJson); if ($trimmedPayload !== '' && $trimmedPayload !== 'null') { @@ -40,6 +55,12 @@ public function __invoke(AutomationRun $run, array $config): NodeRunResult $payload = []; } + if ($run->is_dry_run) { + return NodeRunResult::completed(output: [ + 'webhook' => ['method' => $method, 'url' => $url, 'dry_run' => true], + ]); + } + $response = Http::withHeaders($headers) ->withUserAgent(config('trypost.user_agent')) ->send($method, $url, ['json' => $payload]); diff --git a/app/Console/Commands/Automation/ProcessAutomationDelays.php b/app/Console/Commands/Automation/ProcessAutomationDelays.php index 211f0bbb..3cae03f0 100644 --- a/app/Console/Commands/Automation/ProcessAutomationDelays.php +++ b/app/Console/Commands/Automation/ProcessAutomationDelays.php @@ -11,7 +11,6 @@ use Illuminate\Console\Attributes\Description; use Illuminate\Console\Attributes\Signature; use Illuminate\Console\Command; -use Illuminate\Support\Facades\DB; #[Signature('automation:process-delays')] #[Description('Wake up runs that finished their delay window')] @@ -25,17 +24,23 @@ public function handle(AdvanceAutomationRun $advance): int ->where(fn ($query) => $query ->where('is_manual', true) ->orWhereHas('automation', fn ($inner) => $inner->where('status', AutomationStatus::Active))) - ->lockForUpdate() ->chunkById(50, function ($runs) use ($advance) { - DB::transaction(function () use ($runs, $advance) { - foreach ($runs as $run) { - $run->update([ + foreach ($runs as $run) { + $claimed = AutomationRun::query() + ->whereKey($run->id) + ->where('status', Status::Waiting) + ->update([ 'status' => Status::Running, 'next_action_at' => null, ]); - $advance($run, $run->current_node_id); + + if ($claimed === 0) { + continue; } - }); + + $run->refresh(); + $advance($run, $run->current_node_id); + } }); return self::SUCCESS; diff --git a/app/Http/Requests/App/Automations/UpdateAutomationRequest.php b/app/Http/Requests/App/Automations/UpdateAutomationRequest.php index d2181341..689c85dc 100644 --- a/app/Http/Requests/App/Automations/UpdateAutomationRequest.php +++ b/app/Http/Requests/App/Automations/UpdateAutomationRequest.php @@ -8,6 +8,8 @@ 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; @@ -38,6 +40,9 @@ public function rules(): array '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 @@ -56,6 +61,36 @@ public function rules(): array 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 */ @@ -73,7 +108,6 @@ public function attributes(): array 'nodes.*.data.method' => 'method', 'nodes.*.data.trigger_type' => 'trigger type', 'nodes.*.data.prompt_template' => 'prompt template', - 'nodes.*.data.image_source' => 'image source', 'nodes.*.data.accounts' => 'accounts', ]; } @@ -109,8 +143,7 @@ private function dataRulesForNodeType(?string $type): array NodeType::Generate->value => [ 'accounts' => ['required', 'array', 'min:1'], 'prompt_template' => ['required', 'string'], - 'image_source' => ['required', Rule::in(['ai', 'unsplash', 'none'])], - 'target_slide_count' => ['nullable', 'integer', 'min:1', 'max:20'], + 'target_slide_count' => ['nullable', 'integer', 'min:1', 'max:'.GenerateNodeValidator::MAX_GENERATED_IMAGES], ], NodeType::Delay->value => [ 'duration' => ['required', 'integer', 'min:1'], diff --git a/app/Http/Resources/AutomationResource.php b/app/Http/Resources/AutomationResource.php index 81821ce0..d696fd69 100644 --- a/app/Http/Resources/AutomationResource.php +++ b/app/Http/Resources/AutomationResource.php @@ -22,6 +22,7 @@ public function toArray(Request $request): array 'status' => $this->status->value, 'nodes' => $this->maskSensitiveNodeFields($this->nodes ?? []), 'connections' => $this->connections ?? [], + 'variables' => $this->maskVariables($this->variables ?? []), 'activated_at' => $this->activated_at, 'paused_at' => $this->paused_at, 'created_at' => $this->created_at, @@ -50,4 +51,24 @@ private function maskSensitiveNodeFields(array $nodes): array return $nodes; } + + /** + * Replace stored variable values with the placeholder so secrets never + * leave the server. The frontend references variables by key + * (`{{ variables.KEY }}`), so the masked value doesn't hinder reuse, and + * re-saving the placeholder keeps the stored ciphertext. + * + * @param array> $variables + * @return array> + */ + private function maskVariables(array $variables): array + { + foreach ($variables as &$variable) { + if (data_get($variable, 'value') !== null && data_get($variable, 'value') !== '') { + $variable['value'] = Automation::SENSITIVE_PLACEHOLDER; + } + } + + return $variables; + } } diff --git a/app/Models/Automation.php b/app/Models/Automation.php index bcadef78..b81fd554 100644 --- a/app/Models/Automation.php +++ b/app/Models/Automation.php @@ -33,6 +33,7 @@ class Automation extends Model 'status' => Status::class, 'nodes' => 'array', 'connections' => 'array', + 'variables' => 'array', 'activated_at' => 'datetime', 'paused_at' => 'datetime', ]; @@ -44,9 +45,35 @@ protected static function booted(): void $automation->nodes ?? [], $automation->getOriginal('nodes') ?? [], ); + $automation->variables = self::encryptVariables( + $automation->variables ?? [], + $automation->getOriginal('variables') ?? [], + ); }); } + /** + * Workflow variables decrypted into a `key => value` map for use during a + * run (e.g. `{{ variables.API_KEY }}` resolution). Encrypted at rest and + * never returned to the frontend in plain text. + * + * @return array + */ + public function resolvedVariables(): array + { + $resolved = []; + + foreach ($this->variables ?? [] as $variable) { + $key = data_get($variable, 'key'); + if (! is_string($key) || $key === '') { + continue; + } + $resolved[$key] = self::decryptValue((string) data_get($variable, 'value', '')); + } + + return $resolved; + } + public function workspace(): BelongsTo { return $this->belongsTo(Workspace::class); @@ -104,6 +131,52 @@ private static function encryptSensitiveFields(array $incoming, array|string $or return $incoming; } + /** + * Reconciles workflow variable values exactly like node credentials, matched + * by variable `key`: a PLACEHOLDER value keeps the existing ciphertext, + * plaintext gets encrypted, already-encrypted strings pass through. + * + * @param array> $incoming + * @param array>|string $original + * @return array> + */ + private static function encryptVariables(array $incoming, array|string $original): array + { + $original = is_array($original) ? $original : (json_decode($original, true) ?: []); + $originalByKey = collect($original)->keyBy('key'); + + foreach ($incoming as &$variable) { + $value = data_get($variable, 'value'); + if (! is_string($value) || $value === '') { + continue; + } + if ($value === self::SENSITIVE_PLACEHOLDER) { + $variable['value'] = (string) data_get($originalByKey->get($variable['key'] ?? null), 'value', ''); + + continue; + } + if (self::looksEncrypted($value)) { + continue; + } + $variable['value'] = Crypt::encryptString($value); + } + + return $incoming; + } + + private static function decryptValue(string $value): string + { + if ($value === '') { + return ''; + } + + try { + return Crypt::decryptString($value); + } catch (Throwable) { + return $value; + } + } + /** * Quick check for Laravel's `Crypt::encryptString` output without paying * the cost of a full decrypt attempt. Laravel wraps payloads as base64 diff --git a/app/Models/AutomationRun.php b/app/Models/AutomationRun.php index fb57401b..968ac3da 100644 --- a/app/Models/AutomationRun.php +++ b/app/Models/AutomationRun.php @@ -38,6 +38,23 @@ public function automation(): BelongsTo return $this->belongsTo(Automation::class); } + /** + * Context for template (`{{ ... }}`) resolution: the run context plus the + * automation's workflow variables, merged in-memory. Variables are NEVER + * persisted into the run context (they're encrypted at rest and would + * otherwise leak in plaintext via the run/node-run API), so we compute this + * on demand at resolve time only. + * + * @return array + */ + public function resolverContext(): array + { + return array_merge( + $this->context ?? [], + ['variables' => $this->automation->resolvedVariables()], + ); + } + public function triggerItem(): BelongsTo { return $this->belongsTo(AutomationTriggerItem::class, 'trigger_item_id'); diff --git a/app/Services/Automation/GenerateNodeValidator.php b/app/Services/Automation/GenerateNodeValidator.php new file mode 100644 index 00000000..750dbf92 --- /dev/null +++ b/app/Services/Automation/GenerateNodeValidator.php @@ -0,0 +1,92 @@ + $config + */ + public function issueFor(array $config): ?string + { + $accounts = data_get($config, 'accounts'); + + if (! is_array($accounts) || $accounts === []) { + return null; + } + + $imageCount = $this->intendedImageCount($config, $accounts); + + 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; + } + + /** + * Mirrors the frontend: the carousel slide count when any selected account + * supports multiple images, otherwise a single image when enabled. + * + * @param array $config + * @param array $accounts + */ + private function intendedImageCount(array $config, array $accounts): int + { + foreach ($accounts as $entry) { + $contentType = ContentType::tryFrom((string) data_get($entry, 'content_type')); + + if ($contentType instanceof ContentType && $contentType->supportsImage() && $contentType->maxMediaCount() > 1) { + return (int) data_get($config, 'target_slide_count', 2); + } + } + + return (bool) data_get($config, 'include_image', true) ? 1 : 0; + } + + 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; + } +} diff --git a/database/migrations/2026_06_11_181832_add_variables_to_automations_table.php b/database/migrations/2026_06_11_181832_add_variables_to_automations_table.php new file mode 100644 index 00000000..cd9f748b --- /dev/null +++ b/database/migrations/2026_06_11_181832_add_variables_to_automations_table.php @@ -0,0 +1,28 @@ +json('variables')->nullable()->after('connections'); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('automations', function (Blueprint $table) { + $table->dropColumn('variables'); + }); + } +}; diff --git a/lang/en/automations.php b/lang/en/automations.php index 19ffe0f6..8e7eef71 100644 --- a/lang/en/automations.php +++ b/lang/en/automations.php @@ -14,12 +14,33 @@ 'pause' => 'Pause', 'delete' => 'Delete', 'retry' => 'Retry', - 'add_node' => 'Add node', + ], + + 'tabs' => [ + 'build' => 'Build', + 'variables' => 'Variables', 'test' => 'Test', ], + 'categories' => [ + 'sources' => 'Sources', + 'content' => 'Content', + 'flow' => 'Flow', + 'output' => 'Output', + ], + + 'variables' => [ + 'title' => 'Workflow variables', + 'hint' => 'Reusable values referenced anywhere with {{ variables.KEY }}. Stored encrypted.', + 'empty' => 'No variables yet.', + 'key' => 'Key', + 'value' => 'Value', + 'key_placeholder' => 'API_KEY', + 'value_placeholder' => 'Value', + 'add' => 'New variable', + ], + 'test' => [ - 'title' => 'Test run', 'description' => 'Runs the automation end-to-end using a synthesized trigger payload. Useful for validating each node without waiting for the real schedule or feed.', 'starting' => 'Starting test run…', 'in_progress' => 'In progress', @@ -53,7 +74,6 @@ 'name' => 'Name', 'status' => 'Status', 'created' => 'Created', - 'actions' => 'Actions', ], ], @@ -76,7 +96,6 @@ 'pause_error_fallback' => 'Could not pause automation.', 'save_error_fallback' => 'Could not save automation.', 'save_success' => 'Automation saved.', - 'config_title' => ':type config', 'empty_canvas_title' => 'Start building your automation', 'empty_canvas_description' => 'Drag a node from the left panel to get started.', 'name_placeholder' => 'Untitled automation', @@ -253,6 +272,7 @@ 'no_generated_post' => 'No generated post found on run.', 'webhook_server_error' => 'Webhook server error.', 'webhook_invalid_payload_json' => 'The payload template is not valid JSON.', + 'url_not_allowed' => 'The request URL points to a private or unreachable address and was blocked.', 'node_no_longer_exists' => 'Node :node_id no longer exists in the automation.', 'no_trigger_connection' => 'No node connected to the Trigger node.', ], diff --git a/lang/en/common.php b/lang/en/common.php index 2061e5f9..c348d641 100644 --- a/lang/en/common.php +++ b/lang/en/common.php @@ -53,5 +53,6 @@ 'actions' => [ 'copy' => 'Copy', 'copied' => 'Copied', + 'copy_failed' => 'Failed to copy to clipboard', ], ]; diff --git a/lang/es/automations.php b/lang/es/automations.php index 62a4d1e3..2b66500a 100644 --- a/lang/es/automations.php +++ b/lang/es/automations.php @@ -14,12 +14,33 @@ 'pause' => 'Pausar', 'delete' => 'Eliminar', 'retry' => 'Reintentar', - 'add_node' => 'Agregar nodo', + ], + + 'tabs' => [ + 'build' => 'Construir', + 'variables' => 'Variables', 'test' => 'Probar', ], + 'categories' => [ + 'sources' => 'Fuentes', + 'content' => 'Contenido', + 'flow' => 'Flujo', + 'output' => 'Salida', + ], + + 'variables' => [ + 'title' => 'Variables del workflow', + 'hint' => 'Valores reutilizables referenciados en cualquier lugar con {{ variables.KEY }}. Almacenados cifrados.', + 'empty' => 'Aún no hay variables.', + 'key' => 'Clave', + 'value' => 'Valor', + 'key_placeholder' => 'API_KEY', + 'value_placeholder' => 'Valor', + 'add' => 'Nueva variable', + ], + 'test' => [ - 'title' => 'Ejecución de prueba', 'description' => 'Ejecuta la automatización de punta a punta usando un payload de disparo sintético. Útil para validar cada nodo sin esperar el cronograma o el feed real.', 'starting' => 'Iniciando ejecución de prueba…', 'in_progress' => 'En progreso', @@ -53,7 +74,6 @@ 'name' => 'Nombre', 'status' => 'Estado', 'created' => 'Creada', - 'actions' => 'Acciones', ], ], @@ -76,7 +96,6 @@ 'pause_error_fallback' => 'No se pudo pausar la automatización.', 'save_error_fallback' => 'No se pudo guardar la automatización.', 'save_success' => 'Automatización guardada.', - 'config_title' => 'Config. de :type', 'empty_canvas_title' => 'Empieza a construir tu automatización', 'empty_canvas_description' => 'Arrastra un nodo del panel izquierdo para empezar.', 'name_placeholder' => 'Automatización sin título', @@ -253,6 +272,7 @@ 'no_generated_post' => 'No se encontró un post generado en la ejecución.', 'webhook_server_error' => 'Error del servidor del webhook.', 'webhook_invalid_payload_json' => 'La plantilla de payload no es un JSON válido.', + 'url_not_allowed' => 'La URL de la petición apunta a una dirección privada o inaccesible y fue bloqueada.', 'node_no_longer_exists' => 'El nodo :node_id ya no existe en la automatización.', 'no_trigger_connection' => 'Ningún nodo está conectado al nodo disparador.', ], diff --git a/lang/es/common.php b/lang/es/common.php index 7015827a..261d31e3 100644 --- a/lang/es/common.php +++ b/lang/es/common.php @@ -53,5 +53,6 @@ 'actions' => [ 'copy' => 'Copiar', 'copied' => 'Copiado', + 'copy_failed' => 'No se pudo copiar al portapapeles', ], ]; diff --git a/lang/pt-BR/automations.php b/lang/pt-BR/automations.php index bc116fdd..95ff93e7 100644 --- a/lang/pt-BR/automations.php +++ b/lang/pt-BR/automations.php @@ -14,12 +14,33 @@ 'pause' => 'Pausar', 'delete' => 'Excluir', 'retry' => 'Tentar novamente', - 'add_node' => 'Adicionar nó', + ], + + 'tabs' => [ + 'build' => 'Montar', + 'variables' => 'Variáveis', 'test' => 'Testar', ], + 'categories' => [ + 'sources' => 'Fontes', + 'content' => 'Conteúdo', + 'flow' => 'Fluxo', + 'output' => 'Saída', + ], + + 'variables' => [ + 'title' => 'Variáveis do workflow', + 'hint' => 'Valores reutilizáveis referenciados em qualquer lugar com {{ variables.KEY }}. Armazenados encriptados.', + 'empty' => 'Nenhuma variável ainda.', + 'key' => 'Chave', + 'value' => 'Valor', + 'key_placeholder' => 'API_KEY', + 'value_placeholder' => 'Valor', + 'add' => 'Nova variável', + ], + 'test' => [ - 'title' => 'Execução de teste', 'description' => 'Executa a automação ponta a ponta usando um payload de gatilho sintético. Útil pra validar cada nó sem esperar o agendamento ou o feed real.', 'starting' => 'Iniciando execução de teste…', 'in_progress' => 'Em andamento', @@ -53,7 +74,6 @@ 'name' => 'Nome', 'status' => 'Status', 'created' => 'Criada em', - 'actions' => 'Ações', ], ], @@ -76,7 +96,6 @@ 'pause_error_fallback' => 'Não foi possível pausar a automação.', 'save_error_fallback' => 'Não foi possível salvar a automação.', 'save_success' => 'Automação salva.', - 'config_title' => 'Configuração :type', 'empty_canvas_title' => 'Comece a construir sua automação', 'empty_canvas_description' => 'Arraste um nó do painel esquerdo para começar.', 'name_placeholder' => 'Automação sem título', @@ -253,6 +272,7 @@ 'no_generated_post' => 'Nenhum post gerado encontrado para esta execução.', 'webhook_server_error' => 'Erro no servidor do webhook.', 'webhook_invalid_payload_json' => 'O template do payload não é um JSON válido.', + 'url_not_allowed' => 'A URL da requisição aponta para um endereço privado ou inacessível e foi bloqueada.', 'node_no_longer_exists' => 'O nó :node_id não existe mais nesta automação.', 'no_trigger_connection' => 'Nenhum nó conectado ao nó de trigger.', ], diff --git a/lang/pt-BR/common.php b/lang/pt-BR/common.php index 57bc6217..0b0c2c51 100644 --- a/lang/pt-BR/common.php +++ b/lang/pt-BR/common.php @@ -53,5 +53,6 @@ 'actions' => [ 'copy' => 'Copiar', 'copied' => 'Copiado', + 'copy_failed' => 'Falha ao copiar para a área de transferência', ], ]; diff --git a/resources/css/app.css b/resources/css/app.css index 3e4f3653..92db7297 100644 --- a/resources/css/app.css +++ b/resources/css/app.css @@ -225,6 +225,7 @@ .automation-node__icon-tile--rose { background: #ffe4e6; color: #be123c; } .automation-node__icon-tile--emerald { background: #d1fae5; color: #047857; } .automation-node__icon-tile--slate { background: #e2e8f0; color: #334155; } .automation-node__icon-tile--zinc { background: #e4e4e7; color: #27272a; } +.automation-node__icon-tile--cyan { background: #cffafe; color: #155e75; } .automation-node--accent-violet .automation-node__header { background: #f5f3ff; } .automation-node--accent-blue .automation-node__header { background: #eff6ff; } @@ -233,6 +234,7 @@ .automation-node--accent-rose .automation-node__header { background: #fff1f2; .automation-node--accent-emerald .automation-node__header { background: #ecfdf5; } .automation-node--accent-slate .automation-node__header { background: #f1f5f9; } .automation-node--accent-zinc .automation-node__header { background: #f4f4f5; } +.automation-node--accent-cyan .automation-node__header { background: #ecfeff; } .automation-node__title { min-width: 0; diff --git a/resources/js/components/ChannelConfigurator.vue b/resources/js/components/ChannelConfigurator.vue new file mode 100644 index 00000000..8df2a0fb --- /dev/null +++ b/resources/js/components/ChannelConfigurator.vue @@ -0,0 +1,173 @@ + + + diff --git a/resources/js/components/CodeEditor.vue b/resources/js/components/CodeEditor.vue index aa357dd4..7aaa605d 100644 --- a/resources/js/components/CodeEditor.vue +++ b/resources/js/components/CodeEditor.vue @@ -3,10 +3,12 @@ import { indentWithTab } from '@codemirror/commands'; import { json } from '@codemirror/lang-json'; import { EditorState } from '@codemirror/state'; import { EditorView, keymap, placeholder as placeholderExt } from '@codemirror/view'; +import { IconCopy } from '@tabler/icons-vue'; import { basicSetup } from 'codemirror'; import { onBeforeUnmount, onMounted, ref, watch } from 'vue'; import debounce from '@/debounce'; +import { copyToClipboard } from '@/lib/utils'; const props = withDefaults( defineProps<{ @@ -155,5 +157,18 @@ onBeforeUnmount(() => { diff --git a/resources/js/components/JsonViewer.vue b/resources/js/components/JsonViewer.vue index e53289bf..f7906c40 100644 --- a/resources/js/components/JsonViewer.vue +++ b/resources/js/components/JsonViewer.vue @@ -1,9 +1,8 @@