From b23ab0166e16e5d26b6b7b1046fb6193cccb068f Mon Sep 17 00:00:00 2001 From: Paulo Castellano Date: Sun, 24 May 2026 09:17:19 -0300 Subject: [PATCH 1/7] feat(automations): implement automation features and UI enhancements - Added new automation-related routes and controllers for managing automations. - Introduced automation nodes in the UI with distinct styles and interactions. - Updated sidebar to include navigation for automations. - Enhanced post creation logic to support automation metadata. - Refactored content type and platform enums into types for better type safety. - Added localization for automation-related terms in English, Spanish, and Portuguese. - Improved error handling in various components to accommodate new features. --- .../Automation/ActivateAutomation.php | 41 ++ .../Automation/CreateAutomation.php | 25 + .../Automation/Automation/PauseAutomation.php | 21 + .../Automation/UpdateAutomation.php | 65 ++ .../Automation/Node/RunConditionNode.php | 61 ++ app/Actions/Automation/Node/RunDelayNode.php | 26 + app/Actions/Automation/Node/RunEndNode.php | 23 + .../Automation/Node/RunFetchRssNode.php | 200 +++++ .../Automation/Node/RunGenerateNode.php | 283 ++++++++ .../Automation/Node/RunHttpRequestNode.php | 299 ++++++++ .../Automation/Node/RunPublishNode.php | 59 ++ .../Automation/Node/RunWebhookNode.php | 46 ++ .../Automation/Run/AdvanceAutomationRun.php | 32 + .../Automation/Run/DispatchAutomationRun.php | 54 ++ .../Automation/Run/RetryRunFromNode.php | 27 + app/Actions/Automation/Run/TestAutomation.php | 121 ++++ .../DispatchPostTriggerAutomations.php | 84 +++ .../Trigger/FireScheduleTrigger.php | 36 + .../TriggerItem/EnrollTriggerItem.php | 35 + app/Actions/Post/CreatePost.php | 13 +- app/Broadcasting/AutomationChannel.php | 16 + .../Automation/FireScheduleTriggers.php | 34 + .../Automation/ProcessAutomationDelays.php | 39 + .../Automation/NodeRunResult.php | 34 + app/Enums/Automation/Condition/Operator.php | 16 + app/Enums/Automation/Node/Type.php | 18 + app/Enums/Automation/NodeRun/Status.php | 13 + app/Enums/Automation/Publish/Mode.php | 12 + app/Enums/Automation/Run/Status.php | 15 + app/Enums/Automation/Status.php | 12 + app/Enums/Automation/Trigger/Type.php | 12 + app/Events/AutomationRunUpdated.php | 53 ++ .../Controllers/App/AutomationController.php | 196 +++++ .../Automations/ActivateAutomationRequest.php | 23 + .../Automations/PauseAutomationRequest.php | 23 + .../App/Automations/RetryRunRequest.php | 25 + .../Automations/StoreAutomationRequest.php | 25 + .../App/Automations/TestAutomationRequest.php | 25 + .../Automations/UpdateAutomationRequest.php | 141 ++++ .../Resources/AutomationNodeRunResource.php | 29 + app/Http/Resources/AutomationResource.php | 53 ++ app/Http/Resources/AutomationRunResource.php | 33 + .../AutomationTriggerItemResource.php | 25 + app/Jobs/Automation/ProcessAutomationNode.php | 135 ++++ app/Models/Automation.php | 125 ++++ app/Models/AutomationNodeRun.php | 33 + app/Models/AutomationNodeState.php | 38 + app/Models/AutomationRun.php | 59 ++ app/Models/AutomationTriggerItem.php | 32 + app/Observers/AutomationNodeRunObserver.php | 23 + app/Observers/AutomationRunObserver.php | 25 + app/Observers/PostObserver.php | 34 + app/Policies/AutomationPolicy.php | 46 ++ app/Providers/AppServiceProvider.php | 13 + .../Automation/ExpressionResolver.php | 42 ++ bootstrap/app.php | 6 + config/horizon.php | 25 + database/factories/AutomationFactory.php | 60 ++ .../factories/AutomationNodeRunFactory.php | 26 + database/factories/AutomationRunFactory.php | 49 ++ .../AutomationTriggerItemFactory.php | 25 + ..._05_22_211640_create_automations_table.php | 37 + ..._create_automation_trigger_items_table.php | 33 + ...22_211642_create_automation_runs_table.php | 42 ++ ...1643_create_automation_node_runs_table.php | 39 + ...34_create_automation_node_states_table.php | 32 + lang/en/automations.php | 248 +++++++ lang/en/common.php | 7 + lang/en/sidebar.php | 1 + lang/es/automations.php | 248 +++++++ lang/es/common.php | 7 + lang/es/sidebar.php | 1 + lang/pt-BR/automations.php | 248 +++++++ lang/pt-BR/common.php | 7 + lang/pt-BR/sidebar.php | 1 + package-lock.json | 168 ++++- package.json | 5 + resources/css/app.css | 118 +++ resources/js/components/AppSidebar.vue | 7 + resources/js/components/JsonViewer.vue | 51 ++ .../automations/AutomationConnectionLine.vue | 54 ++ .../components/automations/TestRunPanel.vue | 225 ++++++ .../config/ConditionNodeConfig.vue | 68 ++ .../automations/config/DelayNodeConfig.vue | 56 ++ .../automations/config/EndNodeConfig.vue | 33 + .../automations/config/FetchRssNodeConfig.vue | 33 + .../automations/config/GenerateNodeConfig.vue | 337 +++++++++ .../config/HttpRequestNodeConfig.vue | 166 +++++ .../automations/config/PublishNodeConfig.vue | 56 ++ .../automations/config/TriggerNodeConfig.vue | 282 ++++++++ .../automations/config/WebhookNodeConfig.vue | 69 ++ .../automations/nodes/ConditionNode.vue | 60 ++ .../automations/nodes/DelayNode.vue | 46 ++ .../components/automations/nodes/EndNode.vue | 36 + .../automations/nodes/FetchRssNode.vue | 41 ++ .../automations/nodes/GenerateNode.vue | 48 ++ .../automations/nodes/HttpRequestNode.vue | 45 ++ .../automations/nodes/PublishNode.vue | 48 ++ .../automations/nodes/TriggerNode.vue | 37 + .../automations/nodes/WebhookNode.vue | 46 ++ .../automations/schedule-summary.ts | 166 +++++ .../components/members/InviteMemberDialog.vue | 2 +- .../components/posts/create/AiPostWizard.vue | 2 +- .../posts/editor/FacebookSettings.vue | 4 +- .../posts/editor/InstagramSettings.vue | 6 +- .../posts/editor/LinkedInSettings.vue | 6 +- .../posts/editor/PinterestSettings.vue | 6 +- .../components/posts/editor/ScheduleTab.vue | 2 +- .../posts/editor/TikTokSettings.vue | 6 +- .../posts/previews/InstagramPreview.vue | 2 +- resources/js/components/settings/UsersTab.vue | 2 +- .../js/composables/echo/useAutomationEcho.ts | 9 + .../history/commands/AddEdgeCommand.ts | 22 + .../history/commands/AddNodeCommand.ts | 22 + .../history/commands/BulkCommand.ts | 24 + .../history/commands/MoveNodeCommand.ts | 25 + .../history/commands/RemoveEdgeCommand.ts | 22 + .../history/commands/RemoveNodeCommand.ts | 22 + .../history/commands/UpdateNodeDataCommand.ts | 25 + resources/js/composables/history/types.ts | 11 + .../js/composables/history/useHistory.ts | 105 +++ resources/js/composables/usePostCompliance.ts | 4 +- resources/js/composables/useShortcut.ts | 59 ++ resources/js/pages/automations/Form.vue | 681 ++++++++++++++++++ resources/js/pages/automations/Index.vue | 132 ++++ resources/js/pages/automations/Show.vue | 205 ++++++ resources/js/types/automation/automation.ts | 20 + resources/js/types/automation/node-type.ts | 13 + .../js/types/automation/raw-connection.ts | 13 + resources/js/types/automation/run.ts | 9 + .../js/types/automation/schedule-data.ts | 28 + .../js/types/automation/schedule-field.ts | 10 + resources/js/types/automation/trigger-item.ts | 9 + resources/js/types/automation/trigger-type.ts | 7 + resources/js/{enums => types}/content-type.ts | 0 resources/js/{enums => types}/platform.ts | 0 .../js/{enums => types}/workspace-role.ts | 0 routes/app.php | 14 + routes/channels.php | 3 + routes/console.php | 4 + .../Feature/Automation/AutomationCrudTest.php | 132 ++++ .../Automation/AutomationEncryptionTest.php | 94 +++ .../Automation/AutomationModelTest.php | 37 + .../Command/FireScheduleTriggersTest.php | 19 + .../Command/ProcessAutomationDelaysTest.php | 50 ++ tests/Feature/Automation/DryRunTest.php | 265 +++++++ .../Automation/Node/ConditionNodeTest.php | 86 +++ .../Feature/Automation/Node/DelayNodeTest.php | 26 + tests/Feature/Automation/Node/EndNodeTest.php | 24 + .../Automation/Node/FetchRssNodeTest.php | 108 +++ .../Automation/Node/GenerateNodeTest.php | 132 ++++ .../Automation/Node/HttpRequestNodeTest.php | 142 ++++ .../Automation/Node/PublishNodeTest.php | 49 ++ .../Automation/Node/WebhookNodeTest.php | 55 ++ .../Automation/Run/RetryRunFromNodeTest.php | 39 + .../Automation/Run/RunLifecycleTest.php | 47 ++ .../Automation/Run/TestAutomationTest.php | 93 +++ .../Automation/Trigger/PostTriggersTest.php | 98 +++ .../Automation/Trigger/ScheduleTest.php | 36 + .../Automation/ExpressionResolverTest.php | 50 ++ 160 files changed, 9562 insertions(+), 27 deletions(-) create mode 100644 app/Actions/Automation/Automation/ActivateAutomation.php create mode 100644 app/Actions/Automation/Automation/CreateAutomation.php create mode 100644 app/Actions/Automation/Automation/PauseAutomation.php create mode 100644 app/Actions/Automation/Automation/UpdateAutomation.php create mode 100644 app/Actions/Automation/Node/RunConditionNode.php create mode 100644 app/Actions/Automation/Node/RunDelayNode.php create mode 100644 app/Actions/Automation/Node/RunEndNode.php create mode 100644 app/Actions/Automation/Node/RunFetchRssNode.php create mode 100644 app/Actions/Automation/Node/RunGenerateNode.php create mode 100644 app/Actions/Automation/Node/RunHttpRequestNode.php create mode 100644 app/Actions/Automation/Node/RunPublishNode.php create mode 100644 app/Actions/Automation/Node/RunWebhookNode.php create mode 100644 app/Actions/Automation/Run/AdvanceAutomationRun.php create mode 100644 app/Actions/Automation/Run/DispatchAutomationRun.php create mode 100644 app/Actions/Automation/Run/RetryRunFromNode.php create mode 100644 app/Actions/Automation/Run/TestAutomation.php create mode 100644 app/Actions/Automation/Trigger/DispatchPostTriggerAutomations.php create mode 100644 app/Actions/Automation/Trigger/FireScheduleTrigger.php create mode 100644 app/Actions/Automation/TriggerItem/EnrollTriggerItem.php create mode 100644 app/Broadcasting/AutomationChannel.php create mode 100644 app/Console/Commands/Automation/FireScheduleTriggers.php create mode 100644 app/Console/Commands/Automation/ProcessAutomationDelays.php create mode 100644 app/DataTransferObjects/Automation/NodeRunResult.php create mode 100644 app/Enums/Automation/Condition/Operator.php create mode 100644 app/Enums/Automation/Node/Type.php create mode 100644 app/Enums/Automation/NodeRun/Status.php create mode 100644 app/Enums/Automation/Publish/Mode.php create mode 100644 app/Enums/Automation/Run/Status.php create mode 100644 app/Enums/Automation/Status.php create mode 100644 app/Enums/Automation/Trigger/Type.php create mode 100644 app/Events/AutomationRunUpdated.php create mode 100644 app/Http/Controllers/App/AutomationController.php create mode 100644 app/Http/Requests/App/Automations/ActivateAutomationRequest.php create mode 100644 app/Http/Requests/App/Automations/PauseAutomationRequest.php create mode 100644 app/Http/Requests/App/Automations/RetryRunRequest.php create mode 100644 app/Http/Requests/App/Automations/StoreAutomationRequest.php create mode 100644 app/Http/Requests/App/Automations/TestAutomationRequest.php create mode 100644 app/Http/Requests/App/Automations/UpdateAutomationRequest.php create mode 100644 app/Http/Resources/AutomationNodeRunResource.php create mode 100644 app/Http/Resources/AutomationResource.php create mode 100644 app/Http/Resources/AutomationRunResource.php create mode 100644 app/Http/Resources/AutomationTriggerItemResource.php create mode 100644 app/Jobs/Automation/ProcessAutomationNode.php create mode 100644 app/Models/Automation.php create mode 100644 app/Models/AutomationNodeRun.php create mode 100644 app/Models/AutomationNodeState.php create mode 100644 app/Models/AutomationRun.php create mode 100644 app/Models/AutomationTriggerItem.php create mode 100644 app/Observers/AutomationNodeRunObserver.php create mode 100644 app/Observers/AutomationRunObserver.php create mode 100644 app/Observers/PostObserver.php create mode 100644 app/Policies/AutomationPolicy.php create mode 100644 app/Services/Automation/ExpressionResolver.php create mode 100644 database/factories/AutomationFactory.php create mode 100644 database/factories/AutomationNodeRunFactory.php create mode 100644 database/factories/AutomationRunFactory.php create mode 100644 database/factories/AutomationTriggerItemFactory.php create mode 100644 database/migrations/2026_05_22_211640_create_automations_table.php create mode 100644 database/migrations/2026_05_22_211641_create_automation_trigger_items_table.php create mode 100644 database/migrations/2026_05_22_211642_create_automation_runs_table.php create mode 100644 database/migrations/2026_05_22_211643_create_automation_node_runs_table.php create mode 100644 database/migrations/2026_05_23_164734_create_automation_node_states_table.php create mode 100644 lang/en/automations.php create mode 100644 lang/es/automations.php create mode 100644 lang/pt-BR/automations.php create mode 100644 resources/js/components/JsonViewer.vue create mode 100644 resources/js/components/automations/AutomationConnectionLine.vue create mode 100644 resources/js/components/automations/TestRunPanel.vue create mode 100644 resources/js/components/automations/config/ConditionNodeConfig.vue create mode 100644 resources/js/components/automations/config/DelayNodeConfig.vue create mode 100644 resources/js/components/automations/config/EndNodeConfig.vue create mode 100644 resources/js/components/automations/config/FetchRssNodeConfig.vue create mode 100644 resources/js/components/automations/config/GenerateNodeConfig.vue create mode 100644 resources/js/components/automations/config/HttpRequestNodeConfig.vue create mode 100644 resources/js/components/automations/config/PublishNodeConfig.vue create mode 100644 resources/js/components/automations/config/TriggerNodeConfig.vue create mode 100644 resources/js/components/automations/config/WebhookNodeConfig.vue create mode 100644 resources/js/components/automations/nodes/ConditionNode.vue create mode 100644 resources/js/components/automations/nodes/DelayNode.vue create mode 100644 resources/js/components/automations/nodes/EndNode.vue create mode 100644 resources/js/components/automations/nodes/FetchRssNode.vue create mode 100644 resources/js/components/automations/nodes/GenerateNode.vue create mode 100644 resources/js/components/automations/nodes/HttpRequestNode.vue create mode 100644 resources/js/components/automations/nodes/PublishNode.vue create mode 100644 resources/js/components/automations/nodes/TriggerNode.vue create mode 100644 resources/js/components/automations/nodes/WebhookNode.vue create mode 100644 resources/js/components/automations/schedule-summary.ts create mode 100644 resources/js/composables/echo/useAutomationEcho.ts create mode 100644 resources/js/composables/history/commands/AddEdgeCommand.ts create mode 100644 resources/js/composables/history/commands/AddNodeCommand.ts create mode 100644 resources/js/composables/history/commands/BulkCommand.ts create mode 100644 resources/js/composables/history/commands/MoveNodeCommand.ts create mode 100644 resources/js/composables/history/commands/RemoveEdgeCommand.ts create mode 100644 resources/js/composables/history/commands/RemoveNodeCommand.ts create mode 100644 resources/js/composables/history/commands/UpdateNodeDataCommand.ts create mode 100644 resources/js/composables/history/types.ts create mode 100644 resources/js/composables/history/useHistory.ts create mode 100644 resources/js/composables/useShortcut.ts create mode 100644 resources/js/pages/automations/Form.vue create mode 100644 resources/js/pages/automations/Index.vue create mode 100644 resources/js/pages/automations/Show.vue create mode 100644 resources/js/types/automation/automation.ts create mode 100644 resources/js/types/automation/node-type.ts create mode 100644 resources/js/types/automation/raw-connection.ts create mode 100644 resources/js/types/automation/run.ts create mode 100644 resources/js/types/automation/schedule-data.ts create mode 100644 resources/js/types/automation/schedule-field.ts create mode 100644 resources/js/types/automation/trigger-item.ts create mode 100644 resources/js/types/automation/trigger-type.ts rename resources/js/{enums => types}/content-type.ts (100%) rename resources/js/{enums => types}/platform.ts (100%) rename resources/js/{enums => types}/workspace-role.ts (100%) create mode 100644 tests/Feature/Automation/AutomationCrudTest.php create mode 100644 tests/Feature/Automation/AutomationEncryptionTest.php create mode 100644 tests/Feature/Automation/AutomationModelTest.php create mode 100644 tests/Feature/Automation/Command/FireScheduleTriggersTest.php create mode 100644 tests/Feature/Automation/Command/ProcessAutomationDelaysTest.php create mode 100644 tests/Feature/Automation/DryRunTest.php create mode 100644 tests/Feature/Automation/Node/ConditionNodeTest.php create mode 100644 tests/Feature/Automation/Node/DelayNodeTest.php create mode 100644 tests/Feature/Automation/Node/EndNodeTest.php create mode 100644 tests/Feature/Automation/Node/FetchRssNodeTest.php create mode 100644 tests/Feature/Automation/Node/GenerateNodeTest.php create mode 100644 tests/Feature/Automation/Node/HttpRequestNodeTest.php create mode 100644 tests/Feature/Automation/Node/PublishNodeTest.php create mode 100644 tests/Feature/Automation/Node/WebhookNodeTest.php create mode 100644 tests/Feature/Automation/Run/RetryRunFromNodeTest.php create mode 100644 tests/Feature/Automation/Run/RunLifecycleTest.php create mode 100644 tests/Feature/Automation/Run/TestAutomationTest.php create mode 100644 tests/Feature/Automation/Trigger/PostTriggersTest.php create mode 100644 tests/Feature/Automation/Trigger/ScheduleTest.php create mode 100644 tests/Unit/Automation/ExpressionResolverTest.php diff --git a/app/Actions/Automation/Automation/ActivateAutomation.php b/app/Actions/Automation/Automation/ActivateAutomation.php new file mode 100644 index 00000000..f3abc452 --- /dev/null +++ b/app/Actions/Automation/Automation/ActivateAutomation.php @@ -0,0 +1,41 @@ +validate($automation); + + $automation->update([ + 'status' => Status::Active, + 'activated_at' => now(), + 'paused_at' => null, + ]); + + return $automation; + } + + private function validate(Automation $automation): void + { + $nodes = $automation->nodes ?? []; + $connections = $automation->connections ?? []; + + $triggers = collect($nodes)->where('type', 'trigger'); + if ($triggers->count() !== 1) { + throw new \DomainException(__('automations.errors.must_have_one_trigger')); + } + + $trigger = $triggers->first(); + $hasTargetFromTrigger = collect($connections)->contains('source', $trigger['id']); + if (! $hasTargetFromTrigger) { + throw new \DomainException(__('automations.errors.trigger_must_be_connected')); + } + } +} diff --git a/app/Actions/Automation/Automation/CreateAutomation.php b/app/Actions/Automation/Automation/CreateAutomation.php new file mode 100644 index 00000000..ce6d6040 --- /dev/null +++ b/app/Actions/Automation/Automation/CreateAutomation.php @@ -0,0 +1,25 @@ + $workspace->id, + 'user_id' => $user->id, + 'name' => $name, + 'status' => Status::Draft, + 'nodes' => [], + 'connections' => [], + ]); + } +} diff --git a/app/Actions/Automation/Automation/PauseAutomation.php b/app/Actions/Automation/Automation/PauseAutomation.php new file mode 100644 index 00000000..96f20cb0 --- /dev/null +++ b/app/Actions/Automation/Automation/PauseAutomation.php @@ -0,0 +1,21 @@ +update([ + 'status' => Status::Paused, + 'paused_at' => now(), + ]); + + return $automation; + } +} diff --git a/app/Actions/Automation/Automation/UpdateAutomation.php b/app/Actions/Automation/Automation/UpdateAutomation.php new file mode 100644 index 00000000..f8470d5d --- /dev/null +++ b/app/Actions/Automation/Automation/UpdateAutomation.php @@ -0,0 +1,65 @@ +detectCycles($data['nodes'] ?? [], $data['connections'] ?? []); + + $automation->update([ + 'name' => $data['name'] ?? $automation->name, + 'nodes' => $data['nodes'] ?? $automation->nodes, + 'connections' => $data['connections'] ?? $automation->connections, + ]); + + return $automation->fresh(); + } + + private function detectCycles(array $nodes, array $connections): void + { + $adj = []; + foreach ($connections as $c) { + $adj[$c['source']][] = $c['target']; + } + + /** @var array $state state: 'white' (unvisited), 'gray' (in stack), 'black' (done) */ + $state = []; + foreach ($nodes as $node) { + $state[$node['id']] = 'white'; + } + + foreach ($nodes as $node) { + if ($state[$node['id']] === 'white' && $this->hasCycleFrom($node['id'], $adj, $state)) { + throw new DomainException(__('automations.errors.graph_contains_cycle')); + } + } + } + + private function hasCycleFrom(string $node, array $adj, array &$state): bool + { + $state[$node] = 'gray'; + + foreach ($adj[$node] ?? [] as $next) { + if (! isset($state[$next])) { + continue; + } + if ($state[$next] === 'gray') { + return true; + } + if ($state[$next] === 'white' && $this->hasCycleFrom($next, $adj, $state)) { + return true; + } + } + + $state[$node] = 'black'; + + return false; + } +} diff --git a/app/Actions/Automation/Node/RunConditionNode.php b/app/Actions/Automation/Node/RunConditionNode.php new file mode 100644 index 00000000..753dbf82 --- /dev/null +++ b/app/Actions/Automation/Node/RunConditionNode.php @@ -0,0 +1,61 @@ +resolver->resolve(data_get($config, 'field', ''), $run->context ?? []); + $operator = Operator::from(data_get($config, 'operator', 'equals')); + $value = (string) data_get($config, 'value', ''); + + $matched = match ($operator) { + Operator::Contains => str_contains($field, $value), + Operator::NotContains => ! str_contains($field, $value), + Operator::Equals => $field === $value, + Operator::NotEquals => $field !== $value, + Operator::Matches => $this->safeRegexMatch($value, $field), + Operator::GreaterThan => is_numeric($field) && is_numeric($value) && (float) $field > (float) $value, + Operator::LessThan => is_numeric($field) && is_numeric($value) && (float) $field < (float) $value, + }; + + return NodeRunResult::completed( + output: ['condition' => ['resolved_field' => $field, 'matched' => $matched]], + nextHandle: $matched ? 'yes' : 'no', + ); + } + + private function safeRegexMatch(string $pattern, string $subject): bool + { + if (strlen($pattern) > self::MAX_REGEX_LENGTH) { + return false; + } + + $escaped = str_replace('~', '\~', $pattern); + $regex = "~{$escaped}~u"; + + try { + $result = @preg_match($regex, $subject); + } catch (\Throwable) { + return false; + } + + if ($result === false || preg_last_error() !== PREG_NO_ERROR) { + return false; + } + + return $result === 1; + } +} diff --git a/app/Actions/Automation/Node/RunDelayNode.php b/app/Actions/Automation/Node/RunDelayNode.php new file mode 100644 index 00000000..2c322cae --- /dev/null +++ b/app/Actions/Automation/Node/RunDelayNode.php @@ -0,0 +1,26 @@ + now()->addMinutes($duration), + 'hours' => now()->addHours($duration), + 'days' => now()->addDays($duration), + default => throw new \InvalidArgumentException("Unknown delay unit: {$unit}"), + }; + + return NodeRunResult::sleep($until); + } +} diff --git a/app/Actions/Automation/Node/RunEndNode.php b/app/Actions/Automation/Node/RunEndNode.php new file mode 100644 index 00000000..f03dcba5 --- /dev/null +++ b/app/Actions/Automation/Node/RunEndNode.php @@ -0,0 +1,23 @@ + [ + 'ended_at' => now()->toIso8601String(), + 'reason' => $reason ?: null, + ], + ]); + } +} diff --git a/app/Actions/Automation/Node/RunFetchRssNode.php b/app/Actions/Automation/Node/RunFetchRssNode.php new file mode 100644 index 00000000..691f43df --- /dev/null +++ b/app/Actions/Automation/Node/RunFetchRssNode.php @@ -0,0 +1,200 @@ +successful()) { + return NodeRunResult::failed('Feed request failed.', ['status' => $response->status()]); + } + + try { + $xml = new SimpleXMLElement($response->body()); + } catch (Throwable $e) { + Log::warning('Fetch RSS node: malformed feed', [ + 'run_id' => $run->id, + 'feed_url' => $feedUrl, + 'error' => $e->getMessage(), + ]); + + return NodeRunResult::failed('Malformed RSS feed.'); + } + + $nodeId = (string) $run->current_node_id; + // Dry runs bypass the watermark entirely: they neither load nor advance + // the persisted state, AND they use an epoch watermark so every item in + // the feed is treated as new — otherwise a "now" default would silently + // return zero items on any feed that hasn't published in the last second. + $state = $run->is_dry_run ? null : AutomationNodeState::for($run->automation_id, $nodeId); + $watermark = $run->is_dry_run + ? CarbonImmutable::createFromTimestamp(0) + : $this->parseWatermark(data_get($state->data, 'last_item_date')); + + [$newItems, $newestSeen] = $this->collectNewItems($xml, $watermark); + + if ($state !== null && $newestSeen !== null) { + $state->update(['data' => array_merge($state->data ?? [], [ + 'last_item_date' => $newestSeen->toIso8601String(), + ])]); + } + + if ($newItems === []) { + return NodeRunResult::completed(['fetch' => ['count' => 0]], nextHandle: 'no_items'); + } + + $first = array_shift($newItems); + + if (! $run->is_dry_run) { + $this->spawnSiblings($run, $nodeId, $newItems); + } + + return NodeRunResult::completed([ + 'fetch' => ['count' => count($newItems) + 1, 'spawned' => $run->is_dry_run ? 0 : count($newItems)], + 'fetched' => $first, + ]); + } + + private function collectNewItems(SimpleXMLElement $xml, CarbonImmutable $watermark): array + { + $items = []; + $newestSeen = null; + + foreach ($xml->channel->item ?? [] as $item) { + $key = (string) ($item->guid ?? $item->link); + if ($key === '') { + continue; + } + + $pubDate = $this->parsePubDate((string) $item->pubDate); + if ($pubDate === null) { + continue; + } + + if ($newestSeen === null || $pubDate->greaterThan($newestSeen)) { + $newestSeen = $pubDate; + } + + if (! $pubDate->greaterThan($watermark)) { + continue; + } + + $items[] = [ + '_pubDate' => $pubDate, + 'key' => $key, + 'title' => (string) $item->title, + 'link' => (string) $item->link, + 'description' => (string) $item->description, + 'pubDate' => (string) $item->pubDate, + ]; + } + + // Process oldest-first so siblings inherit a stable order matching feed chronology. + usort($items, fn ($a, $b) => $a['_pubDate']->getTimestamp() <=> $b['_pubDate']->getTimestamp()); + + // Drop the internal sort key — downstream nodes shouldn't see it. + $items = array_map(function (array $item): array { + unset($item['_pubDate']); + + return $item; + }, $items); + + return [$items, $newestSeen]; + } + + private function spawnSiblings(AutomationRun $parent, string $fetchNodeId, array $items): void + { + if ($items === []) { + return; + } + + $nextNodeId = $this->findNextNodeId($parent, $fetchNodeId); + + foreach ($items as $item) { + $sibling = AutomationRun::create([ + 'automation_id' => $parent->automation_id, + 'is_manual' => $parent->is_manual, + 'is_dry_run' => $parent->is_dry_run, + 'status' => RunStatus::Pending, + 'context' => array_merge($parent->context ?? [], ['fetched' => $item]), + ]); + + if ($nextNodeId === null) { + $sibling->update(['status' => RunStatus::Completed, 'finished_at' => now()]); + + continue; + } + + ProcessAutomationNode::dispatch($sibling, $nextNodeId); + } + } + + private function findNextNodeId(AutomationRun $run, string $fromNodeId): ?string + { + $connection = collect($run->automation->connections ?? []) + ->first(fn ($c) => $c['source'] === $fromNodeId && ($c['source_handle'] ?? 'default') === 'default'); + + return $connection['target'] ?? null; + } + + private function parseWatermark(?string $stored): CarbonImmutable + { + if ($stored === null) { + return CarbonImmutable::now(); + } + + try { + return CarbonImmutable::parse($stored); + } catch (Throwable) { + return CarbonImmutable::now(); + } + } + + private function parsePubDate(string $raw): ?CarbonImmutable + { + if ($raw === '') { + return null; + } + + try { + return CarbonImmutable::parse($raw); + } catch (Throwable) { + return null; + } + } +} diff --git a/app/Actions/Automation/Node/RunGenerateNode.php b/app/Actions/Automation/Node/RunGenerateNode.php new file mode 100644 index 00000000..cec14a56 --- /dev/null +++ b/app/Actions/Automation/Node/RunGenerateNode.php @@ -0,0 +1,283 @@ +context ?? []; + $prompt = $this->resolver->resolve(data_get($config, 'prompt_template', ''), $context); + + $accountsConfig = $this->resolveAccountsConfig($config); + ['format' => $format, 'slide_count' => $slideCount] = $this->deriveFormat($accountsConfig, $config); + + $accountIds = array_values(array_filter(array_map( + fn ($a) => data_get($a, 'social_account_id'), + $accountsConfig, + ))); + + $workspace = $run->automation->workspace; + + $activeAccounts = SocialAccount::query() + ->whereIn('id', $accountIds) + ->where('workspace_id', $workspace->id) + ->active() + ->get() + ->keyBy('id'); + + if ($accountIds !== [] && $activeAccounts->isEmpty()) { + Log::warning('RunGenerateNode: no active social accounts found, skipping account assignment', [ + 'automation_id' => $run->automation_id, + 'social_account_ids' => $accountIds, + ]); + } + + $agent = new PostContentGenerator( + workspace: $workspace, + format: $format, + slideCount: $slideCount, + ); + + $generatorResponse = $agent->prompt($prompt); + + RecordAiUsage::recordText( + workspace: $workspace, + promptTokens: $generatorResponse->usage->promptTokens, + completionTokens: $generatorResponse->usage->completionTokens, + provider: (string) config('ai.default'), + model: (string) config('ai.default_text_model'), + metadata: ['agent' => 'post_generator', 'format' => $format, 'source' => 'automation'], + ); + + $structured = $generatorResponse->structured ?? []; + + $structured = $this->humanize($workspace, $structured, $format); + + $content = $format === 'carousel' + ? (string) data_get($structured, 'caption', '') + : (string) data_get($structured, 'content', ''); + + $user = $this->resolveUser($run); + + $platforms = []; + foreach ($accountsConfig as $entry) { + $accountId = data_get($entry, 'social_account_id'); + if (! $accountId || ! $activeAccounts->has($accountId)) { + if ($accountId) { + Log::warning('RunGenerateNode: account no longer active, skipping', [ + 'automation_id' => $run->automation_id, + 'social_account_id' => $accountId, + ]); + } + + continue; + } + + $platforms[] = [ + 'social_account_id' => $accountId, + 'content_type' => data_get($entry, 'content_type'), + 'meta' => data_get($entry, 'meta', []), + ]; + } + + // Dry runs do the AI work (so the user sees a real generation) but + // never persist a Post. Downstream nodes (Publish) read `is_dry_run` + // and skip their persistence too. + if ($run->is_dry_run) { + return NodeRunResult::completed(output: [ + 'generated' => [ + 'post_id' => null, + 'content' => $content, + 'dry_run' => true, + ], + ]); + } + + $post = CreatePost::execute($workspace, $user, [ + 'content' => $content, + 'media' => [], + 'platforms' => $platforms, + ]); + + $run->update(['generated_post_id' => $post->id]); + + return NodeRunResult::completed(output: [ + 'generated' => [ + 'post_id' => $post->id, + 'content' => $content, + 'post_url' => route('app.posts.show', $post->id), + ], + ]); + } + + /** + * @param array $structured + * @return array + */ + private function humanize(Workspace $workspace, array $structured, string $format): array + { + try { + $input = $format === 'carousel' + ? [ + 'caption' => data_get($structured, 'caption', ''), + 'slides' => array_map( + fn ($s) => [ + 'title' => data_get($s, 'title', ''), + 'body' => data_get($s, 'body', ''), + ], + data_get($structured, 'slides', []), + ), + ] + : [ + 'content' => data_get($structured, 'content', ''), + 'image_title' => data_get($structured, 'image_title', ''), + 'image_body' => data_get($structured, 'image_body', ''), + ]; + + $humanizer = new PostContentHumanizer($workspace, $format); + $response = $humanizer->prompt(json_encode($input, JSON_UNESCAPED_UNICODE)); + $humanized = $response->structured ?? []; + + RecordAiUsage::recordText( + workspace: $workspace, + promptTokens: $response->usage->promptTokens, + completionTokens: $response->usage->completionTokens, + provider: (string) config('ai.default'), + model: (string) config('ai.default_text_model'), + metadata: ['agent' => 'post_humanizer', 'format' => $format, 'source' => 'automation'], + ); + + if ($format === 'carousel') { + $structured['caption'] = data_get($humanized, 'caption', $structured['caption'] ?? ''); + $originalSlides = $structured['slides'] ?? []; + $humanizedSlides = data_get($humanized, 'slides', []); + + foreach ($originalSlides as $i => $slide) { + if (isset($humanizedSlides[$i])) { + $originalSlides[$i]['title'] = data_get($humanizedSlides[$i], 'title', $slide['title'] ?? ''); + $originalSlides[$i]['body'] = data_get($humanizedSlides[$i], 'body', $slide['body'] ?? ''); + } + } + + $structured['slides'] = $originalSlides; + } else { + $structured['content'] = data_get($humanized, 'content', $structured['content'] ?? ''); + $structured['image_title'] = data_get($humanized, 'image_title', $structured['image_title'] ?? ''); + $structured['image_body'] = data_get($humanized, 'image_body', $structured['image_body'] ?? ''); + } + } catch (\Throwable $e) { + Log::warning('RunGenerateNode: PostContentHumanizer failed, using generator output as-is', [ + 'error' => $e->getMessage(), + ]); + } + + return $structured; + } + + /** + * Derive the generator format and slide count from per-account content types. + * + * Carousel-capable content types: + * - instagram_carousel (Instagram feed carousel) + * - linkedin_carousel (LinkedIn personal carousel PDF) + * - linkedin_page_carousel (LinkedIn page carousel PDF) + * - pinterest_carousel (Pinterest carousel pin) + * - tiktok_photo (TikTok photo carousel) + * + * When at least one account has a carousel-capable content type AND + * target_slide_count > 1, the generator is told to produce a carousel with + * that many slides. Otherwise it falls back to a single-post format. + * + * @param array}> $accountsConfig + * @param array $config + * @return array{format: string, slide_count: int} + */ + public function deriveFormat(array $accountsConfig, array $config): array + { + $carouselCapable = [ + ContentType::InstagramCarousel->value, + ContentType::LinkedInCarousel->value, + ContentType::LinkedInPageCarousel->value, + ContentType::PinterestCarousel->value, + ContentType::TikTokPhoto->value, + ]; + + $hasCarouselAccount = false; + foreach ($accountsConfig as $entry) { + if (in_array(data_get($entry, 'content_type'), $carouselCapable, strict: true)) { + $hasCarouselAccount = true; + break; + } + } + + $targetSlideCount = (int) data_get($config, 'target_slide_count', 1); + + if ($hasCarouselAccount && $targetSlideCount > 1) { + return ['format' => 'carousel', 'slide_count' => $targetSlideCount]; + } + + return ['format' => 'single', 'slide_count' => 1]; + } + + private function resolveUser(AutomationRun $run): User + { + if ($run->automation->user_id) { + return $run->automation->user; + } + + return $run->automation->workspace->owner; + } + + /** + * Read the current `accounts` shape and fall back to the legacy + * `social_account_ids` array so older automations keep running until + * the user re-opens and saves the node. + * + * @param array $config + * @return array}> + */ + private function resolveAccountsConfig(array $config): array + { + $accounts = data_get($config, 'accounts'); + + if (is_array($accounts)) { + return array_values(array_map(fn ($entry) => [ + 'social_account_id' => (string) data_get($entry, 'social_account_id', ''), + 'content_type' => data_get($entry, 'content_type'), + 'meta' => (array) data_get($entry, 'meta', []), + ], $accounts)); + } + + $legacy = data_get($config, 'social_account_ids', []); + + if (! is_array($legacy)) { + return []; + } + + return array_values(array_map(fn ($id) => [ + 'social_account_id' => (string) $id, + 'content_type' => null, + 'meta' => [], + ], $legacy)); + } +} diff --git a/app/Actions/Automation/Node/RunHttpRequestNode.php b/app/Actions/Automation/Node/RunHttpRequestNode.php new file mode 100644 index 00000000..bf24074c --- /dev/null +++ b/app/Actions/Automation/Node/RunHttpRequestNode.php @@ -0,0 +1,299 @@ +current_node_id; + $context = $run->context ?? []; + + if ($url === '') { + return NodeRunResult::failed('HTTP request node missing url.'); + } + + $resolvedUrl = $this->resolver->resolve($url, $context); + $request = $this->buildRequest($config, $context); + $body = $this->buildJsonBody($method, $config, $context); + + try { + $response = match ($method) { + 'GET' => $request->get($resolvedUrl), + 'DELETE' => $request->delete($resolvedUrl), + 'POST' => $request->post($resolvedUrl, $body), + 'PUT' => $request->put($resolvedUrl, $body), + 'PATCH' => $request->patch($resolvedUrl, $body), + default => null, + }; + } catch (Throwable $e) { + return NodeRunResult::failed('HTTP request threw an exception.', ['message' => $e->getMessage()]); + } + + if ($response === null) { + return NodeRunResult::failed("Unsupported HTTP method: {$method}"); + } + + if (! $response->successful()) { + return NodeRunResult::failed('HTTP request failed.', [ + 'status' => $response->status(), + 'body' => substr($response->body(), 0, 500), + ]); + } + + $body = $response->json(); + $useItems = is_string($itemsPath) && $itemsPath !== ''; + + if (! $useItems) { + // Single-response mode: pass the whole body forward as `fetched`. + return NodeRunResult::completed([ + 'fetch' => ['count' => 1, 'spawned' => 0], + 'fetched' => $body, + ]); + } + + $rawItems = data_get($body, $itemsPath, []); + if (! is_array($rawItems)) { + return NodeRunResult::failed('Items path did not resolve to an array.'); + } + + // Dry runs bypass the watermark entirely: they neither load nor advance + // the persisted state, AND they process every item so the user actually + // sees data flow (with a "now" watermark, fresh tests would yield 0 + // items on any feed older than today and look broken). + $useWatermark = is_string($itemDatePath) && $itemDatePath !== '' && ! $run->is_dry_run; + $state = $useWatermark ? AutomationNodeState::for($run->automation_id, $nodeId) : null; + $watermark = $useWatermark + ? $this->parseWatermark(data_get($state->data, 'last_item_date')) + : null; + $newestSeen = null; + + $newItems = []; + + foreach ($rawItems as $item) { + if (! is_array($item)) { + continue; + } + + if ($useWatermark) { + $itemDate = $this->parseDate(data_get($item, $itemDatePath)); + if ($itemDate === null) { + continue; + } + if ($newestSeen === null || $itemDate->greaterThan($newestSeen)) { + $newestSeen = $itemDate; + } + if (! $itemDate->greaterThan($watermark)) { + continue; + } + } + + $key = $itemKeyPath ? data_get($item, $itemKeyPath) : null; + $key = ($key === null || $key === '') ? hash('sha256', json_encode($item)) : (string) $key; + + $newItems[] = array_merge($item, ['_key' => $key]); + } + + if ($useWatermark && $newestSeen !== null && $state !== null) { + $state->update(['data' => array_merge($state->data ?? [], [ + 'last_item_date' => $newestSeen->toIso8601String(), + ])]); + } + + if ($newItems === []) { + return NodeRunResult::completed(['fetch' => ['count' => 0]], nextHandle: 'no_items'); + } + + $first = array_shift($newItems); + + // Dry runs skip sibling spawning so the test stays a single in-memory + // walk through one item — see TestAutomation's dry-run contract. + if (! $run->is_dry_run) { + $this->spawnSiblings($run, $nodeId, $newItems); + } + + return NodeRunResult::completed([ + 'fetch' => ['count' => count($newItems) + 1, 'spawned' => $run->is_dry_run ? 0 : count($newItems)], + 'fetched' => $first, + ]); + } + + /** + * @param array $config + * @param array $context + */ + private function buildRequest(array $config, array $context): PendingRequest + { + $request = Http::asJson(); + + $headers = []; + foreach ((array) data_get($config, 'headers', []) as $k => $v) { + $headers[$k] = $this->resolver->resolve((string) $v, $context); + } + + $authType = data_get($config, 'auth_type', 'none'); + if ($authType === 'bearer') { + $token = $this->decrypt((string) data_get($config, 'auth_token', '')); + if ($token !== '') { + $request = $request->withToken($this->resolver->resolve($token, $context)); + } + } elseif ($authType === 'basic') { + $user = (string) data_get($config, 'auth_username', ''); + $pass = $this->decrypt((string) data_get($config, 'auth_password', '')); + if ($user !== '' || $pass !== '') { + $request = $request->withBasicAuth( + $this->resolver->resolve($user, $context), + $this->resolver->resolve($pass, $context), + ); + } + } elseif ($authType === 'api_key') { + $headerName = (string) data_get($config, 'auth_header_name', 'X-API-Key'); + $token = $this->decrypt((string) data_get($config, 'auth_token', '')); + if ($token !== '') { + $headers[$headerName] = $this->resolver->resolve($token, $context); + } + } + + if ($headers !== []) { + $request = $request->withHeaders($headers); + } + + return $request; + } + + /** + * @param array $config + * @param array $context + * @return array + */ + private function buildJsonBody(string $method, array $config, array $context): array + { + if (! in_array($method, ['POST', 'PUT', 'PATCH'], true)) { + return []; + } + + $template = (string) data_get($config, 'body_template', ''); + if ($template === '') { + return []; + } + + $rendered = $this->resolver->resolve($template, $context); + $decoded = json_decode($rendered, true); + + return is_array($decoded) ? $decoded : []; + } + + /** + * @param array> $items + */ + private function spawnSiblings(AutomationRun $parent, string $fetchNodeId, array $items): void + { + if ($items === []) { + return; + } + + $nextNodeId = $this->findNextNodeId($parent, $fetchNodeId); + + foreach ($items as $item) { + $sibling = AutomationRun::create([ + 'automation_id' => $parent->automation_id, + 'is_manual' => $parent->is_manual, + 'is_dry_run' => $parent->is_dry_run, + 'status' => RunStatus::Pending, + 'context' => array_merge($parent->context ?? [], ['fetched' => $item]), + ]); + + if ($nextNodeId === null) { + $sibling->update(['status' => RunStatus::Completed, 'finished_at' => now()]); + + continue; + } + + ProcessAutomationNode::dispatch($sibling, $nextNodeId); + } + } + + private function findNextNodeId(AutomationRun $run, string $fromNodeId): ?string + { + $connection = collect($run->automation->connections ?? []) + ->first(fn ($c) => $c['source'] === $fromNodeId && ($c['source_handle'] ?? 'default') === 'default'); + + return $connection['target'] ?? null; + } + + private function decrypt(string $value): string + { + if ($value === '') { + return ''; + } + + try { + return Crypt::decryptString($value); + } catch (Throwable) { + // Value isn't an encrypted payload (legacy plain text or already decrypted). + return $value; + } + } + + private function parseWatermark(?string $stored): CarbonImmutable + { + if ($stored === null) { + return CarbonImmutable::now(); + } + + try { + return CarbonImmutable::parse($stored); + } catch (Throwable) { + return CarbonImmutable::now(); + } + } + + private function parseDate(mixed $raw): ?CarbonImmutable + { + if (! is_string($raw) && ! is_numeric($raw)) { + return null; + } + + try { + return CarbonImmutable::parse((string) $raw); + } catch (Throwable) { + return null; + } + } +} diff --git a/app/Actions/Automation/Node/RunPublishNode.php b/app/Actions/Automation/Node/RunPublishNode.php new file mode 100644 index 00000000..4f635e74 --- /dev/null +++ b/app/Actions/Automation/Node/RunPublishNode.php @@ -0,0 +1,59 @@ +is_dry_run) { + return NodeRunResult::completed(output: [ + 'publish' => ['mode' => $mode->value, 'post_id' => null, 'dry_run' => true], + ]); + } + + $post = $run->generatedPost; + + if ($post === null) { + return NodeRunResult::failed(__('automations.errors.no_generated_post')); + } + + match ($mode) { + Mode::Now => $this->publishNow($post), + Mode::Scheduled => $this->schedule($post, (int) ($config['scheduled_offset'] ?? 60)), + Mode::Draft => null, + }; + + return NodeRunResult::completed(output: [ + 'publish' => ['mode' => $mode->value, 'post_id' => $post->id], + ]); + } + + private function publishNow(Post $post): void + { + $post->update(['status' => PostStatus::Publishing]); + PublishPost::dispatch($post); + } + + private function schedule(Post $post, int $offsetMinutes): void + { + $post->update([ + 'status' => PostStatus::Scheduled, + 'scheduled_at' => now()->addMinutes($offsetMinutes), + ]); + } +} diff --git a/app/Actions/Automation/Node/RunWebhookNode.php b/app/Actions/Automation/Node/RunWebhookNode.php new file mode 100644 index 00000000..8ff26a81 --- /dev/null +++ b/app/Actions/Automation/Node/RunWebhookNode.php @@ -0,0 +1,46 @@ +resolver->resolve($config['url'] ?? '', $run->context ?? []); + $method = strtoupper($config['method'] ?? 'POST'); + $headers = []; + + foreach ($config['headers'] ?? [] as $k => $v) { + $headers[$k] = $this->resolver->resolve((string) $v, $run->context ?? []); + } + + $payloadJson = $this->resolver->resolve($config['payload_template'] ?? '{}', $run->context ?? []); + $payload = json_decode($payloadJson, true) ?? []; + + $response = Http::withHeaders($headers) + ->send($method, $url, ['json' => $payload]); + + if ($response->serverError()) { + return NodeRunResult::failed(__('automations.errors.webhook_server_error'), [ + 'status' => $response->status(), + 'body' => substr($response->body(), 0, 500), + ]); + } + + return NodeRunResult::completed(output: [ + 'webhook' => [ + 'status' => $response->status(), + 'body' => substr($response->body(), 0, 500), + ], + ]); + } +} diff --git a/app/Actions/Automation/Run/AdvanceAutomationRun.php b/app/Actions/Automation/Run/AdvanceAutomationRun.php new file mode 100644 index 00000000..41b78844 --- /dev/null +++ b/app/Actions/Automation/Run/AdvanceAutomationRun.php @@ -0,0 +1,32 @@ +automation; + + $connection = collect($automation->connections ?? []) + ->first(fn ($c) => $c['source'] === $fromNodeId && ($c['source_handle'] ?? 'default') === $handle); + + if ($connection === null) { + $run->update([ + 'status' => Status::Completed, + 'finished_at' => now(), + 'current_node_id' => null, + ]); + + return; + } + + ProcessAutomationNode::dispatch($run, $connection['target']); + } +} diff --git a/app/Actions/Automation/Run/DispatchAutomationRun.php b/app/Actions/Automation/Run/DispatchAutomationRun.php new file mode 100644 index 00000000..2b59a9f0 --- /dev/null +++ b/app/Actions/Automation/Run/DispatchAutomationRun.php @@ -0,0 +1,54 @@ +findFirstRealNodeId($automation); + + $run = AutomationRun::create([ + 'automation_id' => $automation->id, + 'trigger_item_id' => $triggerItem->id, + 'status' => Status::Pending, + 'context' => ['trigger' => $triggerItem->payload], + ]); + + if ($firstNodeId === null) { + $run->update([ + 'status' => Status::Failed, + 'error' => ['message' => __('automations.errors.no_trigger_connection')], + 'finished_at' => now(), + ]); + + return $run; + } + + ProcessAutomationNode::dispatch($run, $firstNodeId); + + return $run; + } + + private function findFirstRealNodeId(Automation $automation): ?string + { + $triggerNode = collect($automation->nodes ?? [])->firstWhere('type', 'trigger'); + + if ($triggerNode === null) { + return null; + } + + $connection = collect($automation->connections ?? []) + ->firstWhere('source', $triggerNode['id']); + + return $connection['target'] ?? null; + } +} diff --git a/app/Actions/Automation/Run/RetryRunFromNode.php b/app/Actions/Automation/Run/RetryRunFromNode.php new file mode 100644 index 00000000..432699ad --- /dev/null +++ b/app/Actions/Automation/Run/RetryRunFromNode.php @@ -0,0 +1,27 @@ +status !== Status::Failed) { + throw new \DomainException(__('automations.errors.only_failed_can_retry')); + } + + $run->update([ + 'status' => Status::Pending, + 'error' => null, + 'finished_at' => null, + ]); + + ProcessAutomationNode::dispatch($run, $nodeId); + } +} diff --git a/app/Actions/Automation/Run/TestAutomation.php b/app/Actions/Automation/Run/TestAutomation.php new file mode 100644 index 00000000..85420d39 --- /dev/null +++ b/app/Actions/Automation/Run/TestAutomation.php @@ -0,0 +1,121 @@ +nodes ?? [])->firstWhere('type', 'trigger'); + $context = ['trigger' => $this->synthesizePayload($automation, $triggerNode ?? [])]; + + $firstNodeId = $this->findFirstRealNodeId($automation, $triggerNode); + + $run = AutomationRun::create([ + 'automation_id' => $automation->id, + 'status' => Status::Pending, + 'is_manual' => true, + 'is_dry_run' => ! $withRealData, + 'context' => $context, + ]); + + if ($firstNodeId === null) { + $run->update([ + 'status' => Status::Failed, + 'error' => ['message' => __('automations.errors.no_trigger_connection')], + 'finished_at' => now(), + ]); + + return $run; + } + + ProcessAutomationNode::dispatch($run, $firstNodeId); + + return $run; + } + + /** + * @param array $triggerNode + * @return array + */ + private function synthesizePayload(Automation $automation, array $triggerNode): array + { + $type = data_get($triggerNode, 'data.trigger_type'); + + return match ($type) { + TriggerType::PostPublished->value, TriggerType::PostScheduled->value => $this->synthesizePostPayload($automation, (string) $type), + default => ['event' => $type ?? TriggerType::Schedule->value, 'fired_at' => now()->toIso8601String(), 'manual' => true], + }; + } + + /** + * Picks the most recent post in the automation's workspace so the test run + * reflects something the user actually sees. Falls back to a placeholder + * payload when the workspace has no posts yet. + * + * @return array + */ + private function synthesizePostPayload(Automation $automation, string $event): array + { + $post = Post::query() + ->where('workspace_id', $automation->workspace_id) + ->latest() + ->first(); + + $base = [ + 'event' => $event, + 'fired_at' => now()->toIso8601String(), + 'manual' => true, + ]; + + if ($post === null) { + return array_merge($base, ['post' => null, 'fetch_error' => 'no posts in workspace']); + } + + return array_merge($base, [ + 'post' => [ + 'id' => $post->id, + 'content' => $post->content, + 'status' => $post->status->value, + 'scheduled_at' => $post->scheduled_at?->toIso8601String(), + 'published_at' => $post->published_at?->toIso8601String(), + ], + ]); + } + + /** + * @param array|null $triggerNode + */ + private function findFirstRealNodeId(Automation $automation, ?array $triggerNode): ?string + { + if ($triggerNode === null) { + return null; + } + + $connection = collect($automation->connections ?? []) + ->firstWhere('source', $triggerNode['id']); + + return $connection['target'] ?? null; + } +} diff --git a/app/Actions/Automation/Trigger/DispatchPostTriggerAutomations.php b/app/Actions/Automation/Trigger/DispatchPostTriggerAutomations.php new file mode 100644 index 00000000..60d94286 --- /dev/null +++ b/app/Actions/Automation/Trigger/DispatchPostTriggerAutomations.php @@ -0,0 +1,84 @@ +where('workspace_id', $post->workspace_id) + ->where('status', AutomationStatus::Active) + ->get(); + + foreach ($automations as $automation) { + $triggerNode = collect($automation->nodes ?? [])->firstWhere('type', 'trigger'); + if (data_get($triggerNode, 'data.trigger_type') !== $triggerType->value) { + continue; + } + + $this->dispatchRun($automation, $triggerNode, $post); + } + } + + private function dispatchRun(Automation $automation, array $triggerNode, Post $post): void + { + $context = [ + 'trigger' => [ + 'event' => $triggerNode['data']['trigger_type'], + 'fired_at' => now()->toIso8601String(), + 'post' => [ + 'id' => $post->id, + 'content' => $post->content, + 'status' => $post->status->value, + 'scheduled_at' => $post->scheduled_at?->toIso8601String(), + 'published_at' => $post->published_at?->toIso8601String(), + ], + ], + ]; + + $run = AutomationRun::create([ + 'automation_id' => $automation->id, + 'status' => RunStatus::Pending, + 'context' => $context, + ]); + + $connection = collect($automation->connections ?? []) + ->firstWhere('source', $triggerNode['id']); + + $nextNodeId = $connection['target'] ?? null; + + if ($nextNodeId === null) { + $run->update([ + 'status' => RunStatus::Failed, + 'error' => ['message' => __('automations.errors.no_trigger_connection')], + 'finished_at' => now(), + ]); + + return; + } + + ProcessAutomationNode::dispatch($run, $nextNodeId); + } +} diff --git a/app/Actions/Automation/Trigger/FireScheduleTrigger.php b/app/Actions/Automation/Trigger/FireScheduleTrigger.php new file mode 100644 index 00000000..22afe8c1 --- /dev/null +++ b/app/Actions/Automation/Trigger/FireScheduleTrigger.php @@ -0,0 +1,36 @@ +nodes ?? [])->firstWhere('type', 'trigger'); + $cron = data_get($triggerNode, 'data.cron'); + $timezone = data_get($triggerNode, 'data.schedule_timezone', config('app.timezone')); + + if ($cron === null) { + return false; + } + + $expression = new CronExpression($cron); + + if (! $expression->isDue(now(), $timezone)) { + return false; + } + + $key = now()->format('Y-m-d\TH:i'); + $payload = ['fired_at' => now()->toIso8601String()]; + + return ($this->enroll)($automation, $key, $payload) !== null; + } +} diff --git a/app/Actions/Automation/TriggerItem/EnrollTriggerItem.php b/app/Actions/Automation/TriggerItem/EnrollTriggerItem.php new file mode 100644 index 00000000..0afd0033 --- /dev/null +++ b/app/Actions/Automation/TriggerItem/EnrollTriggerItem.php @@ -0,0 +1,35 @@ +id) + ->where('item_key', $itemKey) + ->first(); + + if ($existing !== null) { + return null; + } + + $item = AutomationTriggerItem::create([ + 'automation_id' => $automation->id, + 'item_key' => $itemKey, + 'payload' => $payload, + 'first_seen_at' => now(), + ]); + + return ($this->dispatchRun)($automation, $item); + } +} diff --git a/app/Actions/Post/CreatePost.php b/app/Actions/Post/CreatePost.php index bfdb75bd..215838b6 100644 --- a/app/Actions/Post/CreatePost.php +++ b/app/Actions/Post/CreatePost.php @@ -31,7 +31,7 @@ class CreatePost * media?: array, * date?: ?string, * scheduled_at?: ?string, - * platforms?: array, + * platforms?: array}>, * label_ids?: array * } $data */ @@ -62,6 +62,17 @@ public static function execute(Workspace $workspace, User $user, array $data): P $updates['content_type'] = $contentType; } + $meta = data_get($platformData, 'meta'); + if (is_array($meta) && $meta !== []) { + $existing = $post->postPlatforms() + ->where('social_account_id', $accountId) + ->first(); + + if ($existing) { + $updates['meta'] = array_merge($existing->meta ?? [], $meta); + } + } + $post->postPlatforms() ->where('social_account_id', $accountId) ->update($updates); diff --git a/app/Broadcasting/AutomationChannel.php b/app/Broadcasting/AutomationChannel.php new file mode 100644 index 00000000..03afd739 --- /dev/null +++ b/app/Broadcasting/AutomationChannel.php @@ -0,0 +1,16 @@ +workspace->hasMember($user); + } +} diff --git a/app/Console/Commands/Automation/FireScheduleTriggers.php b/app/Console/Commands/Automation/FireScheduleTriggers.php new file mode 100644 index 00000000..277b49e5 --- /dev/null +++ b/app/Console/Commands/Automation/FireScheduleTriggers.php @@ -0,0 +1,34 @@ +where('status', Status::Active) + ->chunkById(50, function ($automations) use ($fire) { + foreach ($automations as $automation) { + $triggerNode = collect($automation->nodes ?? [])->firstWhere('type', 'trigger'); + if (($triggerNode['data']['trigger_type'] ?? null) !== 'schedule') { + continue; + } + $fire($automation); + } + }); + + return self::SUCCESS; + } +} diff --git a/app/Console/Commands/Automation/ProcessAutomationDelays.php b/app/Console/Commands/Automation/ProcessAutomationDelays.php new file mode 100644 index 00000000..346764c7 --- /dev/null +++ b/app/Console/Commands/Automation/ProcessAutomationDelays.php @@ -0,0 +1,39 @@ +where('status', Status::Waiting) + ->where('next_action_at', '<=', now()) + ->lockForUpdate() + ->chunkById(50, function ($runs) use ($advance) { + DB::transaction(function () use ($runs, $advance) { + foreach ($runs as $run) { + $run->update([ + 'status' => Status::Running, + 'next_action_at' => null, + ]); + $advance($run, $run->current_node_id); + } + }); + }); + + return self::SUCCESS; + } +} diff --git a/app/DataTransferObjects/Automation/NodeRunResult.php b/app/DataTransferObjects/Automation/NodeRunResult.php new file mode 100644 index 00000000..45250b42 --- /dev/null +++ b/app/DataTransferObjects/Automation/NodeRunResult.php @@ -0,0 +1,34 @@ + $message], $extra ?? [])); + } +} diff --git a/app/Enums/Automation/Condition/Operator.php b/app/Enums/Automation/Condition/Operator.php new file mode 100644 index 00000000..5eb5d16c --- /dev/null +++ b/app/Enums/Automation/Condition/Operator.php @@ -0,0 +1,16 @@ +run->automation_id}"), + ]; + } + + /** + * @return array + */ + public function broadcastWith(): array + { + return [ + 'run_id' => $this->run->id, + 'automation_id' => $this->run->automation_id, + 'status' => $this->run->status->value, + ]; + } + + public function broadcastQueue(): string + { + return 'broadcasts'; + } +} diff --git a/app/Http/Controllers/App/AutomationController.php b/app/Http/Controllers/App/AutomationController.php new file mode 100644 index 00000000..b93e861f --- /dev/null +++ b/app/Http/Controllers/App/AutomationController.php @@ -0,0 +1,196 @@ + AutomationResource::collection( + Automation::query() + ->where('workspace_id', request()->user()->current_workspace_id) + ->orderByDesc('created_at') + ->paginate(config('app.pagination.default')) + )); + + return Inertia::render('automations/Index', [ + 'automations' => $automations, + ]); + } + + public function store(StoreAutomationRequest $request, CreateAutomation $create): RedirectResponse + { + $name = $request->validated('name'); + + if (! $name || $name === 'automations.default_name') { + $name = __('automations.default_name'); + } + + $automation = $create( + $request->user()->currentWorkspace, + $request->user(), + $name, + ); + + return redirect()->route('app.automations.edit', $automation->id); + } + + public function edit(Automation $automation): Response + { + $this->authorize('update', $automation); + + $socialAccounts = $automation->workspace->socialAccounts()->active()->get(); + + $platformConfigs = $socialAccounts->mapWithKeys(fn ($account) => [ + $account->id => new PlatformConfigResource($account), + ]); + + $pinterestBoards = $socialAccounts + ->where('platform', Platform::Pinterest) + ->mapWithKeys(fn ($account) => [ + $account->id => rescue( + fn () => app(PinterestPublisher::class)->getBoards($account), + [], + report: false, + ), + ]); + + $tiktokCreatorInfos = $socialAccounts + ->where('platform', Platform::TikTok) + ->mapWithKeys(fn ($account) => [ + $account->id => rescue( + fn () => app(TikTokCreatorInfo::class)->fetch($account), + null, + report: false, + ), + ]) + ->filter(); + + return Inertia::render('automations/Form', [ + 'automation' => AutomationResource::make($automation), + 'socialAccounts' => SocialAccountResource::collection($socialAccounts), + 'platformConfigs' => $platformConfigs, + 'pinterestBoards' => $pinterestBoards, + 'tiktokCreatorInfos' => $tiktokCreatorInfos, + ]); + } + + public function show(Automation $automation): Response + { + $this->authorize('view', $automation); + + return Inertia::render('automations/Show', [ + 'automation' => AutomationResource::make($automation), + 'runs' => AutomationRunResource::collection($automation->runs()->excludingDryRuns()->latest()->take(50)->get()), + 'triggerItems' => AutomationTriggerItemResource::collection( + $automation->triggerItems()->with('run')->latest()->take(50)->get() + ), + ]); + } + + public function update(UpdateAutomationRequest $request, Automation $automation, UpdateAutomation $update): RedirectResponse + { + $this->authorize('update', $automation); + + $update($automation, $request->validated()); + + return back(); + } + + public function destroy(Automation $automation): RedirectResponse + { + $this->authorize('delete', $automation); + $automation->delete(); + + session()->flash('flash.banner', __('automations.flash.deleted')); + session()->flash('flash.bannerStyle', 'success'); + + return redirect()->route('app.automations.index'); + } + + public function activate(ActivateAutomationRequest $request, Automation $automation, ActivateAutomation $activate): RedirectResponse + { + $this->authorize('activate', $automation); + + $activate($automation); + + return back(); + } + + public function pause(PauseAutomationRequest $request, Automation $automation, PauseAutomation $pause): RedirectResponse + { + $this->authorize('pause', $automation); + + $pause($automation); + + return back(); + } + + public function retryRun( + RetryRunRequest $request, + RetryRunFromNode $retry, + Automation $automation, + AutomationRun $run, + ): \Illuminate\Http\Response { + $this->authorize('update', $automation); + abort_unless($run->automation_id === $automation->id, 404); + + $nodeId = $request->validated('node_id') ?? $run->current_node_id; + $retry($run, $nodeId); + + return response()->noContent(); + } + + public function test(TestAutomationRequest $request, Automation $automation, TestAutomation $test): JsonResponse + { + $this->authorize('update', $automation); + + $run = $test($automation, (bool) $request->validated('with_real_data', false)); + + return response()->json(['run_id' => $run->id]); + } + + public function showRun(Automation $automation, AutomationRun $run): JsonResponse + { + $this->authorize('view', $automation); + abort_unless($run->automation_id === $automation->id, 404); + + $run->load('nodeRuns'); + + return response()->json([ + 'run' => AutomationRunResource::make($run)->resolve(), + 'node_runs' => AutomationNodeRunResource::collection($run->nodeRuns)->resolve(), + ]); + } +} diff --git a/app/Http/Requests/App/Automations/ActivateAutomationRequest.php b/app/Http/Requests/App/Automations/ActivateAutomationRequest.php new file mode 100644 index 00000000..a6f718da --- /dev/null +++ b/app/Http/Requests/App/Automations/ActivateAutomationRequest.php @@ -0,0 +1,23 @@ + + */ + public function rules(): array + { + return []; + } +} diff --git a/app/Http/Requests/App/Automations/PauseAutomationRequest.php b/app/Http/Requests/App/Automations/PauseAutomationRequest.php new file mode 100644 index 00000000..e0535860 --- /dev/null +++ b/app/Http/Requests/App/Automations/PauseAutomationRequest.php @@ -0,0 +1,23 @@ + + */ + public function rules(): array + { + return []; + } +} diff --git a/app/Http/Requests/App/Automations/RetryRunRequest.php b/app/Http/Requests/App/Automations/RetryRunRequest.php new file mode 100644 index 00000000..ad8d42af --- /dev/null +++ b/app/Http/Requests/App/Automations/RetryRunRequest.php @@ -0,0 +1,25 @@ + + */ + public function rules(): array + { + return [ + 'node_id' => ['nullable', 'string', 'max:255'], + ]; + } +} diff --git a/app/Http/Requests/App/Automations/StoreAutomationRequest.php b/app/Http/Requests/App/Automations/StoreAutomationRequest.php new file mode 100644 index 00000000..df17d028 --- /dev/null +++ b/app/Http/Requests/App/Automations/StoreAutomationRequest.php @@ -0,0 +1,25 @@ + + */ + public function rules(): array + { + return [ + 'name' => ['required', 'string', 'max:120'], + ]; + } +} diff --git a/app/Http/Requests/App/Automations/TestAutomationRequest.php b/app/Http/Requests/App/Automations/TestAutomationRequest.php new file mode 100644 index 00000000..0d3139ef --- /dev/null +++ b/app/Http/Requests/App/Automations/TestAutomationRequest.php @@ -0,0 +1,25 @@ + + */ + public function rules(): array + { + return [ + 'with_real_data' => ['nullable', 'boolean'], + ]; + } +} diff --git a/app/Http/Requests/App/Automations/UpdateAutomationRequest.php b/app/Http/Requests/App/Automations/UpdateAutomationRequest.php new file mode 100644 index 00000000..d2181341 --- /dev/null +++ b/app/Http/Requests/App/Automations/UpdateAutomationRequest.php @@ -0,0 +1,141 @@ + + */ + 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'], + ]; + + // 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; + } + + /** + * @return array + */ + 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.image_source' => 'image source', + 'nodes.*.data.accounts' => 'accounts', + ]; + } + + /** + * @return array> + */ + 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'], + ], + 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'], + 'image_source' => ['required', Rule::in(['ai', 'unsplash', 'none'])], + 'target_slide_count' => ['nullable', 'integer', 'min:1', 'max:20'], + ], + 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 => [], + }; + } +} diff --git a/app/Http/Resources/AutomationNodeRunResource.php b/app/Http/Resources/AutomationNodeRunResource.php new file mode 100644 index 00000000..bdad385c --- /dev/null +++ b/app/Http/Resources/AutomationNodeRunResource.php @@ -0,0 +1,29 @@ + + */ + public function toArray(Request $request): array + { + return [ + 'id' => $this->id, + 'node_id' => $this->node_id, + 'node_type' => $this->node_type->value, + 'status' => $this->status->value, + 'input' => $this->input, + 'output' => $this->output, + 'error' => $this->error, + 'started_at' => $this->started_at, + 'finished_at' => $this->finished_at, + ]; + } +} diff --git a/app/Http/Resources/AutomationResource.php b/app/Http/Resources/AutomationResource.php new file mode 100644 index 00000000..81821ce0 --- /dev/null +++ b/app/Http/Resources/AutomationResource.php @@ -0,0 +1,53 @@ + + */ + public function toArray(Request $request): array + { + return [ + 'id' => $this->id, + 'workspace_id' => $this->workspace_id, + 'name' => $this->name, + 'status' => $this->status->value, + 'nodes' => $this->maskSensitiveNodeFields($this->nodes ?? []), + 'connections' => $this->connections ?? [], + 'activated_at' => $this->activated_at, + 'paused_at' => $this->paused_at, + 'created_at' => $this->created_at, + 'updated_at' => $this->updated_at, + ]; + } + + /** + * Replace any stored credentials with a placeholder before they leave + * the server. The frontend treats the placeholder as "keep current" on + * save (see Automation::booted()), so editing other fields doesn't wipe + * the stored secret. + * + * @param array> $nodes + * @return array> + */ + private function maskSensitiveNodeFields(array $nodes): array + { + foreach ($nodes as &$node) { + foreach (Automation::SENSITIVE_NODE_FIELDS as $field) { + if (data_get($node, "data.{$field}") !== null && data_get($node, "data.{$field}") !== '') { + data_set($node, "data.{$field}", Automation::SENSITIVE_PLACEHOLDER); + } + } + } + + return $nodes; + } +} diff --git a/app/Http/Resources/AutomationRunResource.php b/app/Http/Resources/AutomationRunResource.php new file mode 100644 index 00000000..08f434f4 --- /dev/null +++ b/app/Http/Resources/AutomationRunResource.php @@ -0,0 +1,33 @@ + + */ + public function toArray(Request $request): array + { + return [ + 'id' => $this->id, + 'automation_id' => $this->automation_id, + 'trigger_item_id' => $this->trigger_item_id, + 'current_node_id' => $this->current_node_id, + 'status' => $this->status->value, + 'is_manual' => (bool) $this->is_manual, + 'is_dry_run' => (bool) $this->is_dry_run, + 'next_action_at' => $this->next_action_at, + 'generated_post_id' => $this->generated_post_id, + 'context' => $this->context, + 'error' => $this->error, + 'started_at' => $this->started_at, + 'finished_at' => $this->finished_at, + ]; + } +} diff --git a/app/Http/Resources/AutomationTriggerItemResource.php b/app/Http/Resources/AutomationTriggerItemResource.php new file mode 100644 index 00000000..3003a175 --- /dev/null +++ b/app/Http/Resources/AutomationTriggerItemResource.php @@ -0,0 +1,25 @@ + + */ + public function toArray(Request $request): array + { + return [ + 'id' => $this->id, + 'item_key' => $this->item_key, + 'payload' => $this->payload, + 'first_seen_at' => $this->first_seen_at, + 'run' => AutomationRunResource::make($this->whenLoaded('run')), + ]; + } +} diff --git a/app/Jobs/Automation/ProcessAutomationNode.php b/app/Jobs/Automation/ProcessAutomationNode.php new file mode 100644 index 00000000..41c9b57f --- /dev/null +++ b/app/Jobs/Automation/ProcessAutomationNode.php @@ -0,0 +1,135 @@ +onQueue('automations'); + } + + public function handle(AdvanceAutomationRun $advance): void + { + $this->run->refresh(); + + if (! in_array($this->run->status, [RunStatus::Pending, RunStatus::Running, RunStatus::Waiting], true)) { + return; + } + + $node = collect($this->run->automation->nodes ?? [])->firstWhere('id', $this->nodeId); + + if ($node === null) { + $this->run->update([ + 'status' => RunStatus::Failed, + 'error' => ['message' => __('automations.errors.node_no_longer_exists', ['node_id' => $this->nodeId])], + 'finished_at' => now(), + ]); + + return; + } + + $nodeType = NodeType::from($node['type']); + + $this->run->update([ + 'status' => RunStatus::Running, + 'current_node_id' => $this->nodeId, + 'started_at' => $this->run->started_at ?? now(), + ]); + + $nodeRun = AutomationNodeRun::create([ + 'run_id' => $this->run->id, + 'node_id' => $this->nodeId, + 'node_type' => $nodeType, + 'status' => NodeRunStatus::Running, + 'input' => $this->run->context, + 'started_at' => now(), + ]); + + try { + $result = $this->executeNode($nodeType, $node['data'] ?? []); + } catch (Throwable $e) { + $result = NodeRunResult::failed($e->getMessage(), ['class' => $e::class]); + } + + $nodeRun->update([ + 'status' => $result->status, + 'output' => $result->output, + 'error' => $result->error, + 'finished_at' => now(), + ]); + + if ($result->status === NodeRunStatus::Failed) { + $this->run->update([ + 'status' => RunStatus::Failed, + 'error' => array_merge(['node_id' => $this->nodeId], $result->error ?? []), + 'finished_at' => now(), + ]); + + return; + } + + $this->run->update([ + 'context' => array_merge($this->run->context ?? [], $result->output), + ]); + + if ($result->sleepUntil !== null) { + $this->run->update([ + 'status' => RunStatus::Waiting, + 'next_action_at' => $result->sleepUntil, + ]); + + return; + } + + $advance($this->run, $this->nodeId, $result->nextHandle); + } + + private function executeNode(NodeType $type, array $config): NodeRunResult + { + $handler = match ($type) { + NodeType::Generate => app(RunGenerateNode::class), + NodeType::Delay => app(RunDelayNode::class), + NodeType::Condition => app(RunConditionNode::class), + NodeType::Publish => app(RunPublishNode::class), + NodeType::Webhook => app(RunWebhookNode::class), + NodeType::End => app(RunEndNode::class), + NodeType::FetchRss => app(RunFetchRssNode::class), + NodeType::HttpRequest => app(RunHttpRequestNode::class), + NodeType::Trigger => throw new LogicException('Trigger nodes are not executed as run steps.'), + }; + + return $handler($this->run, $config); + } +} diff --git a/app/Models/Automation.php b/app/Models/Automation.php new file mode 100644 index 00000000..bcadef78 --- /dev/null +++ b/app/Models/Automation.php @@ -0,0 +1,125 @@ + Status::class, + 'nodes' => 'array', + 'connections' => 'array', + 'activated_at' => 'datetime', + 'paused_at' => 'datetime', + ]; + + protected static function booted(): void + { + static::saving(function (self $automation): void { + $automation->nodes = self::encryptSensitiveFields( + $automation->nodes ?? [], + $automation->getOriginal('nodes') ?? [], + ); + }); + } + + public function workspace(): BelongsTo + { + return $this->belongsTo(Workspace::class); + } + + public function user(): BelongsTo + { + return $this->belongsTo(User::class); + } + + public function triggerItems(): HasMany + { + return $this->hasMany(AutomationTriggerItem::class); + } + + public function runs(): HasMany + { + return $this->hasMany(AutomationRun::class); + } + + /** + * Walks both the incoming and stored node lists and reconciles sensitive + * fields: a PLACEHOLDER value means "user didn't change it" (frontend + * never received the real value) so we keep the existing ciphertext. Plain + * text values get encrypted; already-encrypted strings pass through. + * + * @param array> $incoming + * @param array>|string $original + * @return array> + */ + private static function encryptSensitiveFields(array $incoming, array|string $original): array + { + $original = is_array($original) ? $original : (json_decode($original, true) ?: []); + $originalById = collect($original)->keyBy('id'); + + foreach ($incoming as &$node) { + $originalNode = $originalById->get($node['id'] ?? null); + foreach (self::SENSITIVE_NODE_FIELDS as $field) { + $value = data_get($node, "data.{$field}"); + if (! is_string($value) || $value === '') { + continue; + } + if ($value === self::SENSITIVE_PLACEHOLDER) { + data_set($node, "data.{$field}", data_get($originalNode, "data.{$field}", '')); + + continue; + } + if (self::looksEncrypted($value)) { + continue; + } + data_set($node, "data.{$field}", Crypt::encryptString($value)); + } + } + + return $incoming; + } + + /** + * Quick check for Laravel's `Crypt::encryptString` output without paying + * the cost of a full decrypt attempt. Laravel wraps payloads as base64 + * JSON beginning with the canonical `eyJpdiI` ("{"iv":"...) prefix. + */ + private static function looksEncrypted(string $value): bool + { + if (! str_starts_with($value, 'eyJ')) { + return false; + } + try { + Crypt::decryptString($value); + + return true; + } catch (Throwable) { + return false; + } + } +} diff --git a/app/Models/AutomationNodeRun.php b/app/Models/AutomationNodeRun.php new file mode 100644 index 00000000..2059b38a --- /dev/null +++ b/app/Models/AutomationNodeRun.php @@ -0,0 +1,33 @@ + Status::class, + 'node_type' => NodeType::class, + 'input' => 'array', + 'output' => 'array', + 'error' => 'array', + 'started_at' => 'datetime', + 'finished_at' => 'datetime', + ]; + + public function run(): BelongsTo + { + return $this->belongsTo(AutomationRun::class, 'run_id'); + } +} diff --git a/app/Models/AutomationNodeState.php b/app/Models/AutomationNodeState.php new file mode 100644 index 00000000..68bc5648 --- /dev/null +++ b/app/Models/AutomationNodeState.php @@ -0,0 +1,38 @@ + 'array', + ]; + + public function automation(): BelongsTo + { + return $this->belongsTo(Automation::class); + } + + /** + * Idempotent lookup for the state row of a given node within an automation, + * creating an empty row on first access. Use this in poll/fire actions that + * need to read or update an internal watermark. + */ + public static function for(string $automationId, string $nodeId): self + { + return self::firstOrCreate( + ['automation_id' => $automationId, 'node_id' => $nodeId], + ['data' => []], + ); + } +} diff --git a/app/Models/AutomationRun.php b/app/Models/AutomationRun.php new file mode 100644 index 00000000..bf7c8007 --- /dev/null +++ b/app/Models/AutomationRun.php @@ -0,0 +1,59 @@ + Status::class, + 'context' => 'array', + 'error' => 'array', + 'is_manual' => 'boolean', + 'is_dry_run' => 'boolean', + 'next_action_at' => 'datetime', + 'started_at' => 'datetime', + 'finished_at' => 'datetime', + ]; + + public function automation(): BelongsTo + { + return $this->belongsTo(Automation::class); + } + + public function triggerItem(): BelongsTo + { + return $this->belongsTo(AutomationTriggerItem::class, 'trigger_item_id'); + } + + public function generatedPost(): BelongsTo + { + return $this->belongsTo(Post::class, 'generated_post_id'); + } + + public function nodeRuns(): HasMany + { + return $this->hasMany(AutomationNodeRun::class, 'run_id'); + } + + /** + * Hides dry-run rows from user-facing history queries. Internal/analytics + * queries can ignore the scope to see every row. + */ + public function scopeExcludingDryRuns(Builder $query): Builder + { + return $query->where('is_dry_run', false); + } +} diff --git a/app/Models/AutomationTriggerItem.php b/app/Models/AutomationTriggerItem.php new file mode 100644 index 00000000..69953c75 --- /dev/null +++ b/app/Models/AutomationTriggerItem.php @@ -0,0 +1,32 @@ + 'array', + 'first_seen_at' => 'datetime', + ]; + + public function automation(): BelongsTo + { + return $this->belongsTo(Automation::class); + } + + public function run(): HasOne + { + return $this->hasOne(AutomationRun::class, 'trigger_item_id'); + } +} diff --git a/app/Observers/AutomationNodeRunObserver.php b/app/Observers/AutomationNodeRunObserver.php new file mode 100644 index 00000000..4c045ded --- /dev/null +++ b/app/Observers/AutomationNodeRunObserver.php @@ -0,0 +1,23 @@ +run); + } + + public function updated(AutomationNodeRun $nodeRun): void + { + if ($nodeRun->wasChanged(['status', 'output', 'error', 'finished_at'])) { + AutomationRunUpdated::dispatch($nodeRun->run); + } + } +} diff --git a/app/Observers/AutomationRunObserver.php b/app/Observers/AutomationRunObserver.php new file mode 100644 index 00000000..4872dbb0 --- /dev/null +++ b/app/Observers/AutomationRunObserver.php @@ -0,0 +1,25 @@ +wasChanged(['status', 'current_node_id', 'finished_at', 'next_action_at', 'error'])) { + AutomationRunUpdated::dispatch($run); + } + } +} diff --git a/app/Observers/PostObserver.php b/app/Observers/PostObserver.php new file mode 100644 index 00000000..f34b49a7 --- /dev/null +++ b/app/Observers/PostObserver.php @@ -0,0 +1,34 @@ +wasChanged('status')) { + return; + } + + $status = $post->status; + + if ($status === PostStatus::Published) { + ($this->dispatch)($post, TriggerType::PostPublished); + + return; + } + + if ($status === PostStatus::Scheduled) { + ($this->dispatch)($post, TriggerType::PostScheduled); + } + } +} diff --git a/app/Policies/AutomationPolicy.php b/app/Policies/AutomationPolicy.php new file mode 100644 index 00000000..736147e8 --- /dev/null +++ b/app/Policies/AutomationPolicy.php @@ -0,0 +1,46 @@ +currentWorkspace !== null; + } + + public function view(User $user, Automation $automation): bool + { + return $automation->workspace_id === $user->current_workspace_id; + } + + public function create(User $user): bool + { + return $user->currentWorkspace !== null; + } + + public function update(User $user, Automation $automation): bool + { + return $automation->workspace_id === $user->current_workspace_id; + } + + public function delete(User $user, Automation $automation): bool + { + return $automation->workspace_id === $user->current_workspace_id; + } + + public function activate(User $user, Automation $automation): bool + { + return $this->update($user, $automation); + } + + public function pause(User $user, Automation $automation): bool + { + return $this->update($user, $automation); + } +} diff --git a/app/Providers/AppServiceProvider.php b/app/Providers/AppServiceProvider.php index 3888f6ee..b902fbf2 100644 --- a/app/Providers/AppServiceProvider.php +++ b/app/Providers/AppServiceProvider.php @@ -8,6 +8,8 @@ use App\Models\AccessToken; use App\Models\Account; use App\Models\AiUsageLog; +use App\Models\AutomationNodeRun; +use App\Models\AutomationRun; use App\Models\Invite; use App\Models\Media; use App\Models\Notification; @@ -24,6 +26,9 @@ use App\Models\WorkspaceInvite; use App\Models\WorkspaceLabel; use App\Models\WorkspaceSignature; +use App\Observers\AutomationNodeRunObserver; +use App\Observers\AutomationRunObserver; +use App\Observers\PostObserver; use App\Services\PostHogService; use App\Services\PostTemplate\Registry as PostTemplateRegistry; use App\Socialite\InstagramProvider; @@ -84,6 +89,7 @@ public function boot(): void $this->configureRateLimiting(); $this->configureSocialite(); $this->configureStripeWebhooks(); + $this->configureObservers(); Cashier::useCustomerModel(Account::class); Cashier::useSubscriptionModel(Subscription::class); @@ -96,6 +102,13 @@ public function boot(): void $this->configurePassport(); } + protected function configureObservers(): void + { + Post::observe(PostObserver::class); + AutomationRun::observe(AutomationRunObserver::class); + AutomationNodeRun::observe(AutomationNodeRunObserver::class); + } + protected function configurePassport(): void { Passport::useTokenModel(AccessToken::class); diff --git a/app/Services/Automation/ExpressionResolver.php b/app/Services/Automation/ExpressionResolver.php new file mode 100644 index 00000000..a1997a31 --- /dev/null +++ b/app/Services/Automation/ExpressionResolver.php @@ -0,0 +1,42 @@ + $this->resolveVariable($matches[1], $context), + $template, + ); + } + + private function resolveVariable(string $path, array $context): string + { + if ($path === 'now') { + return Carbon::now()->toIso8601String(); + } + + if ($path === 'today') { + return Carbon::today()->toDateString(); + } + + $value = data_get($context, $path); + + if ($value === null) { + return ''; + } + + if (is_scalar($value)) { + return (string) $value; + } + + return json_encode($value); + } +} diff --git a/bootstrap/app.php b/bootstrap/app.php index 03691e8c..890904a1 100644 --- a/bootstrap/app.php +++ b/bootstrap/app.php @@ -54,4 +54,10 @@ ], 429)->withHeaders($e->getHeaders()); } }); + + $exceptions->render(function (DomainException $e, Request $request) { + if ($request->expectsJson()) { + return response()->json(['message' => $e->getMessage()], 422); + } + }); })->create(); diff --git a/config/horizon.php b/config/horizon.php index 3953f0e7..b6fcbfcc 100644 --- a/config/horizon.php +++ b/config/horizon.php @@ -255,6 +255,21 @@ 'tries' => 1, 'nice' => 0, ], + + 'automations' => [ + 'connection' => 'redis', + 'queue' => ['automations'], + 'balance' => 'auto', + 'autoScalingStrategy' => 'time', + 'minProcesses' => 1, + 'maxProcesses' => 3, + 'timeout' => 630, + 'maxTime' => 0, + 'maxJobs' => 0, + 'memory' => 256, + 'tries' => 1, + 'nice' => 0, + ], ], 'environments' => [ @@ -276,6 +291,12 @@ 'balanceMaxShift' => 1, 'balanceCooldown' => 3, ], + + 'automations' => [ + 'maxProcesses' => 5, + 'balanceMaxShift' => 1, + 'balanceCooldown' => 3, + ], ], 'local' => [ @@ -290,6 +311,10 @@ 'ai-assistant' => [ 'maxProcesses' => 2, ], + + 'automations' => [ + 'maxProcesses' => 2, + ], ], ], ]; diff --git a/database/factories/AutomationFactory.php b/database/factories/AutomationFactory.php new file mode 100644 index 00000000..18d5b629 --- /dev/null +++ b/database/factories/AutomationFactory.php @@ -0,0 +1,60 @@ + Workspace::factory(), + 'user_id' => User::factory(), + 'name' => fake()->sentence(3), + 'status' => Status::Draft, + 'nodes' => [], + 'connections' => [], + ]; + } + + public function active(): static + { + return $this->state(fn () => [ + 'status' => Status::Active, + 'activated_at' => now(), + ]); + } + + public function paused(): static + { + return $this->state(fn () => [ + 'status' => Status::Paused, + 'paused_at' => now(), + ]); + } + + public function withScheduleTrigger(string $cron = '0 9 * * *'): static + { + return $this->state(fn () => [ + 'nodes' => [ + [ + 'id' => 'trigger_1', + 'type' => 'trigger', + 'position' => ['x' => 0, 'y' => 0], + 'data' => [ + 'trigger_type' => 'schedule', + 'cron' => $cron, + ], + ], + ], + 'connections' => [], + ]); + } +} diff --git a/database/factories/AutomationNodeRunFactory.php b/database/factories/AutomationNodeRunFactory.php new file mode 100644 index 00000000..116af95d --- /dev/null +++ b/database/factories/AutomationNodeRunFactory.php @@ -0,0 +1,26 @@ + AutomationRun::factory(), + 'node_id' => 'node_'.fake()->randomNumber(6), + 'node_type' => NodeType::Generate, + 'status' => Status::Running, + 'input' => [], + 'started_at' => now(), + ]; + } +} diff --git a/database/factories/AutomationRunFactory.php b/database/factories/AutomationRunFactory.php new file mode 100644 index 00000000..cf29d6fb --- /dev/null +++ b/database/factories/AutomationRunFactory.php @@ -0,0 +1,49 @@ + Automation::factory(), + 'status' => Status::Pending, + 'is_manual' => false, + 'is_dry_run' => false, + 'context' => [], + ]; + } + + public function running(string $nodeId = 'node_1'): static + { + return $this->state(fn () => [ + 'status' => Status::Running, + 'current_node_id' => $nodeId, + 'started_at' => now(), + ]); + } + + public function waiting(\DateTimeInterface $until): static + { + return $this->state(fn () => [ + 'status' => Status::Waiting, + 'next_action_at' => $until, + ]); + } + + public function completed(): static + { + return $this->state(fn () => [ + 'status' => Status::Completed, + 'finished_at' => now(), + ]); + } +} diff --git a/database/factories/AutomationTriggerItemFactory.php b/database/factories/AutomationTriggerItemFactory.php new file mode 100644 index 00000000..93a5a2f3 --- /dev/null +++ b/database/factories/AutomationTriggerItemFactory.php @@ -0,0 +1,25 @@ + Automation::factory(), + 'item_key' => fake()->uuid(), + 'payload' => [ + 'title' => fake()->sentence(), + 'url' => fake()->url(), + ], + 'first_seen_at' => now(), + ]; + } +} diff --git a/database/migrations/2026_05_22_211640_create_automations_table.php b/database/migrations/2026_05_22_211640_create_automations_table.php new file mode 100644 index 00000000..173b9269 --- /dev/null +++ b/database/migrations/2026_05_22_211640_create_automations_table.php @@ -0,0 +1,37 @@ +uuid('id')->primary(); + $table->foreignUuid('workspace_id')->constrained('workspaces')->cascadeOnDelete(); + $table->foreignUuid('user_id')->nullable()->constrained('users')->nullOnDelete(); + $table->string('name'); + $table->string('status')->default('draft'); + $table->json('nodes')->nullable(); + $table->json('connections')->nullable(); + $table->timestamp('activated_at')->nullable(); + $table->timestamp('paused_at')->nullable(); + $table->timestamps(); + + $table->index(['workspace_id', 'status']); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('automations'); + } +}; diff --git a/database/migrations/2026_05_22_211641_create_automation_trigger_items_table.php b/database/migrations/2026_05_22_211641_create_automation_trigger_items_table.php new file mode 100644 index 00000000..6a573f0a --- /dev/null +++ b/database/migrations/2026_05_22_211641_create_automation_trigger_items_table.php @@ -0,0 +1,33 @@ +uuid('id')->primary(); + $table->foreignUuid('automation_id')->constrained('automations')->cascadeOnDelete(); + $table->string('item_key'); + $table->json('payload'); + $table->timestamp('first_seen_at'); + $table->timestamps(); + + $table->unique(['automation_id', 'item_key']); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('automation_trigger_items'); + } +}; diff --git a/database/migrations/2026_05_22_211642_create_automation_runs_table.php b/database/migrations/2026_05_22_211642_create_automation_runs_table.php new file mode 100644 index 00000000..ba7e45e2 --- /dev/null +++ b/database/migrations/2026_05_22_211642_create_automation_runs_table.php @@ -0,0 +1,42 @@ +uuid('id')->primary(); + $table->foreignUuid('automation_id')->constrained('automations')->cascadeOnDelete(); + $table->foreignUuid('trigger_item_id')->nullable()->constrained('automation_trigger_items')->nullOnDelete(); + $table->string('current_node_id')->nullable(); + $table->string('status')->default('pending'); + $table->boolean('is_manual')->default(false); + $table->boolean('is_dry_run')->default(false); + $table->timestamp('next_action_at')->nullable(); + $table->foreignUuid('generated_post_id')->nullable()->constrained('posts')->nullOnDelete(); + $table->json('context')->nullable(); + $table->json('error')->nullable(); + $table->timestamp('started_at')->nullable(); + $table->timestamp('finished_at')->nullable(); + $table->timestamps(); + + $table->index(['automation_id', 'status']); + $table->index(['status', 'next_action_at']); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('automation_runs'); + } +}; diff --git a/database/migrations/2026_05_22_211643_create_automation_node_runs_table.php b/database/migrations/2026_05_22_211643_create_automation_node_runs_table.php new file mode 100644 index 00000000..ad8e1bf2 --- /dev/null +++ b/database/migrations/2026_05_22_211643_create_automation_node_runs_table.php @@ -0,0 +1,39 @@ +uuid('id')->primary(); + $table->foreignUuid('run_id')->constrained('automation_runs')->cascadeOnDelete(); + $table->string('node_id'); + $table->string('node_type'); + $table->string('status')->default('running'); + $table->json('input')->nullable(); + $table->json('output')->nullable(); + $table->json('error')->nullable(); + $table->timestamp('started_at'); + $table->timestamp('finished_at')->nullable(); + $table->timestamps(); + + $table->unique(['run_id', 'node_id']); + $table->index(['run_id', 'created_at']); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('automation_node_runs'); + } +}; diff --git a/database/migrations/2026_05_23_164734_create_automation_node_states_table.php b/database/migrations/2026_05_23_164734_create_automation_node_states_table.php new file mode 100644 index 00000000..76730bc6 --- /dev/null +++ b/database/migrations/2026_05_23_164734_create_automation_node_states_table.php @@ -0,0 +1,32 @@ +uuid('id')->primary(); + $table->foreignUuid('automation_id')->constrained('automations')->cascadeOnDelete(); + $table->string('node_id'); + $table->json('data')->nullable(); + $table->timestamps(); + + $table->unique(['automation_id', 'node_id']); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('automation_node_states'); + } +}; diff --git a/lang/en/automations.php b/lang/en/automations.php new file mode 100644 index 00000000..24d1123c --- /dev/null +++ b/lang/en/automations.php @@ -0,0 +1,248 @@ + 'Automations', + 'default_name' => 'New automation', + + 'actions' => [ + 'new' => 'New automation', + 'edit' => 'Edit', + 'save' => 'Save', + 'activate' => 'Activate', + 'pause' => 'Pause', + 'delete' => 'Delete', + 'retry' => 'Retry', + 'add_node' => 'Add node', + 'test' => 'Test', + ], + + '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', + 'completed' => 'Completed', + 'failed' => 'Failed', + 'waiting' => 'Waiting', + 'close' => 'Close', + 'no_node_runs' => 'Waiting for the first node to start…', + 'node_input' => 'Input', + 'node_output' => 'Output', + 'node_error' => 'Error', + 'error_starting' => 'Could not start the test run.', + 'with_real_data' => 'With real data', + 'real_data_hint' => 'This test will publish posts, advance polling watermarks, and trigger external side effects.', + 'dry_badge' => 'Dry run', + ], + + 'status' => [ + 'draft' => 'Draft', + 'active' => 'Active', + 'paused' => 'Paused', + ], + + 'index' => [ + 'empty_title' => 'No automations yet', + 'empty_description' => 'Create your first automation to start publishing on autopilot.', + 'columns' => [ + 'name' => 'Name', + 'status' => 'Status', + 'created' => 'Created', + 'actions' => 'Actions', + ], + ], + + 'show' => [ + 'activated' => 'Activated', + 'tabs' => [ + 'overview' => 'Overview', + 'runs' => 'Runs', + 'trigger_items' => 'Trigger items', + ], + 'canvas_placeholder' => 'Canvas preview (read-only)', + 'empty_runs' => 'No runs yet.', + 'empty_trigger_items' => 'No trigger items yet.', + 'started' => 'Started', + 'run_label' => 'Run', + ], + + 'form' => [ + 'activate_error_fallback' => 'Could not activate automation.', + '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', + ], + + 'nodes' => [ + 'trigger' => 'Trigger', + 'generate' => 'Generate', + 'delay' => 'Delay', + 'condition' => 'Condition', + 'publish' => 'Publish', + 'webhook' => 'Webhook', + 'end' => 'End', + 'end_summary' => 'Stops the automation here', + 'fetch_rss' => 'Fetch RSS', + 'http_request' => 'HTTP Request', + ], + + 'config' => [ + 'select_placeholder' => 'Select…', + + 'trigger' => [ + 'type' => 'Trigger type', + 'types' => [ + 'schedule' => 'Schedule', + 'post_published' => 'When a post is published', + 'post_scheduled' => 'When a post is scheduled', + ], + 'post_published_hint' => 'Runs whenever any post in this workspace is published. The published post becomes available at {{ trigger.post }} for downstream nodes.', + 'post_scheduled_hint' => 'Runs whenever any post in this workspace is scheduled. The scheduled post is available at {{ trigger.post }}.', + + 'schedule' => [ + 'field' => 'Trigger interval', + 'fields' => [ + 'minutes' => 'Minutes', + 'hours' => 'Hours', + 'days' => 'Days', + 'weeks' => 'Weeks', + 'months' => 'Months', + 'custom' => 'Custom (Cron)', + ], + 'minutes_interval' => 'Minutes between triggers', + 'hours_interval' => 'Hours between triggers', + 'days_interval' => 'Days between triggers', + 'hour' => 'Trigger at hour', + 'minute' => 'Trigger at minute', + 'weekdays' => 'Trigger on weekdays', + 'day_of_month' => 'Day of month', + 'custom_cron' => 'Cron expression', + 'custom_cron_hint' => 'Format: minute hour day month weekday', + 'timezone_hint' => 'All times in :tz', + 'weekday_names' => [ + 'sun' => 'Sun', + 'mon' => 'Mon', + 'tue' => 'Tue', + 'wed' => 'Wed', + 'thu' => 'Thu', + 'fri' => 'Fri', + 'sat' => 'Sat', + ], + 'summary' => [ + 'every_n_minutes' => 'Runs every minute|Runs every :count minutes', + 'every_n_hours' => 'Runs every hour at minute :minute|Runs every :count hours at minute :minute', + 'every_n_days' => 'Runs every day at :time|Runs every :count days at :time', + 'weekly' => 'Runs every :days at :time', + 'monthly' => 'Runs on day :day of every month at :time', + ], + ], + ], + 'generate' => [ + 'social_accounts' => 'Social accounts', + 'social_accounts_empty' => 'No connected social accounts. Connect one first.', + 'target_slide_count' => 'Slides to generate (for carousel-capable platforms)', + 'prompt_template' => 'Prompt template', + 'image_source' => 'Image source', + 'image_sources' => [ + 'ai' => 'AI generated', + 'unsplash' => 'Unsplash', + 'none' => 'No image', + ], + ], + 'delay' => [ + 'duration' => 'Duration', + 'unit' => 'Unit', + 'units' => [ + 'minutes' => 'Minutes', + 'hours' => 'Hours', + 'days' => 'Days', + ], + ], + 'condition' => [ + 'field' => 'Field', + 'operator' => 'Operator', + 'operators' => [ + 'contains' => 'contains', + 'not_contains' => 'not contains', + 'equals' => 'equals', + 'not_equals' => 'not equals', + 'matches' => 'matches (regex)', + 'greater_than' => 'greater than', + 'less_than' => 'less than', + ], + 'value' => 'Value', + ], + 'publish' => [ + 'mode' => 'Mode', + 'modes' => [ + 'now' => 'Publish now', + 'scheduled' => 'Schedule', + 'draft' => 'Save as draft', + ], + 'scheduled_offset' => 'Offset from trigger (minutes)', + ], + 'webhook' => [ + 'url' => 'URL', + 'method' => 'Method', + 'payload_template' => 'Payload template (JSON)', + ], + 'end' => [ + 'reason' => 'Reason (optional)', + 'reason_placeholder' => 'e.g. Filtered out by condition', + ], + 'fetch_rss' => [ + 'feed_url' => 'Feed URL', + 'feed_url_hint' => 'On first run, the watermark is set to "now" so historical items don\'t flood downstream nodes. Subsequent runs only see items newer than the previous poll.', + ], + 'http_request' => [ + 'url' => 'URL', + 'method' => 'Method', + 'auth_type' => 'Authentication', + 'auth' => [ + 'none' => 'None (public)', + 'bearer' => 'Bearer token', + 'basic' => 'Basic auth', + 'api_key' => 'API key header', + ], + 'bearer_token' => 'Bearer token', + 'basic_username' => 'Username', + 'basic_password' => 'Password', + 'api_key_header' => 'Header name', + 'api_key_value' => 'API key', + 'body_template' => 'Body template (JSON)', + 'polling_section' => 'Polling (optional)', + 'polling_hint' => 'Leave blank to use the whole response as a single payload. Fill in to extract an array of items and spawn one run per item.', + 'items_path' => 'Items path', + 'item_key_path' => 'Item key path', + 'item_date_path' => 'Item date path (optional)', + 'item_date_path_hint' => 'JSON path to the item timestamp. When set, only items newer than the previous fetch are forwarded — prevents the first fetch from flooding downstream nodes.', + ], + ], + + 'delete' => [ + 'title' => 'Delete automation', + 'description' => 'Are you sure you want to delete this automation? All runs and trigger items will also be removed. This action cannot be undone.', + 'confirm' => 'Delete', + 'cancel' => 'Cancel', + ], + + 'flash' => [ + 'deleted' => 'Automation deleted successfully!', + ], + + 'errors' => [ + 'no_active_social_accounts' => 'No active social accounts configured for this automation.', + 'must_have_one_trigger' => 'Automation must have exactly one trigger node.', + 'trigger_must_be_connected' => 'Trigger node must be connected to at least one node.', + 'graph_contains_cycle' => 'Automation graph contains a cycle.', + 'only_failed_can_retry' => 'Only failed runs can be retried.', + 'no_generated_post' => 'No generated post found on run.', + 'webhook_server_error' => 'Webhook server error.', + '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 2d7ee4dc..2061e5f9 100644 --- a/lang/en/common.php +++ b/lang/en/common.php @@ -3,6 +3,8 @@ declare(strict_types=1); return [ + 'back' => 'Back', + 'confirm_modal' => [ 'cannot_be_undone' => 'This cannot be undone.', 'type' => 'Type', @@ -47,4 +49,9 @@ 'clear' => 'Clear', 'close' => 'Close', 'loading_more' => 'Loading more...', + + 'actions' => [ + 'copy' => 'Copy', + 'copied' => 'Copied', + ], ]; diff --git a/lang/en/sidebar.php b/lang/en/sidebar.php index 5ffc7392..28138b21 100644 --- a/lang/en/sidebar.php +++ b/lang/en/sidebar.php @@ -26,6 +26,7 @@ ], 'analytics' => 'Analytics', + 'automations' => 'Automations', 'settings' => 'Settings', 'posts' => [ diff --git a/lang/es/automations.php b/lang/es/automations.php new file mode 100644 index 00000000..65c3a857 --- /dev/null +++ b/lang/es/automations.php @@ -0,0 +1,248 @@ + 'Automatizaciones', + 'default_name' => 'Nueva automatización', + + 'actions' => [ + 'new' => 'Nueva automatización', + 'edit' => 'Editar', + 'save' => 'Guardar', + 'activate' => 'Activar', + 'pause' => 'Pausar', + 'delete' => 'Eliminar', + 'retry' => 'Reintentar', + 'add_node' => 'Agregar nodo', + 'test' => 'Probar', + ], + + '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', + 'completed' => 'Completado', + 'failed' => 'Fallido', + 'waiting' => 'Esperando', + 'close' => 'Cerrar', + 'no_node_runs' => 'Esperando que el primer nodo comience…', + 'node_input' => 'Entrada', + 'node_output' => 'Salida', + 'node_error' => 'Error', + 'error_starting' => 'No se pudo iniciar la ejecución de prueba.', + 'with_real_data' => 'Con datos reales', + 'real_data_hint' => 'Esta prueba publicará posts, avanzará marcadores de polling y disparará efectos secundarios externos.', + 'dry_badge' => 'Prueba seca', + ], + + 'status' => [ + 'draft' => 'Borrador', + 'active' => 'Activa', + 'paused' => 'Pausada', + ], + + 'index' => [ + 'empty_title' => 'Aún no hay automatizaciones', + 'empty_description' => 'Crea tu primera automatización para empezar a publicar en piloto automático.', + 'columns' => [ + 'name' => 'Nombre', + 'status' => 'Estado', + 'created' => 'Creada', + 'actions' => 'Acciones', + ], + ], + + 'show' => [ + 'activated' => 'Activada', + 'tabs' => [ + 'overview' => 'Resumen', + 'runs' => 'Ejecuciones', + 'trigger_items' => 'Elementos del disparador', + ], + 'canvas_placeholder' => 'Vista previa del canvas (solo lectura)', + 'empty_runs' => 'Aún no hay ejecuciones.', + 'empty_trigger_items' => 'Aún no hay elementos del disparador.', + 'started' => 'Iniciada', + 'run_label' => 'Ejecución', + ], + + 'form' => [ + 'activate_error_fallback' => 'No se pudo activar la automatización.', + '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', + ], + + 'nodes' => [ + 'trigger' => 'Disparador', + 'generate' => 'Generar', + 'delay' => 'Retraso', + 'condition' => 'Condición', + 'publish' => 'Publicar', + 'webhook' => 'Webhook', + 'end' => 'Terminar', + 'end_summary' => 'Termina la automatización aquí', + 'fetch_rss' => 'Obtener RSS', + 'http_request' => 'Petición HTTP', + ], + + 'config' => [ + 'select_placeholder' => 'Selecciona…', + + 'trigger' => [ + 'type' => 'Tipo de disparador', + 'types' => [ + 'schedule' => 'Programación', + 'post_published' => 'Cuando un post se publica', + 'post_scheduled' => 'Cuando un post se programa', + ], + 'post_published_hint' => 'Se ejecuta cada vez que un post en este workspace se publica. El post queda disponible en {{ trigger.post }} para los siguientes nodos.', + 'post_scheduled_hint' => 'Se ejecuta cada vez que un post en este workspace se programa. El post queda disponible en {{ trigger.post }}.', + + 'schedule' => [ + 'field' => 'Intervalo de disparo', + 'fields' => [ + 'minutes' => 'Minutos', + 'hours' => 'Horas', + 'days' => 'Días', + 'weeks' => 'Semanas', + 'months' => 'Meses', + 'custom' => 'Personalizado (Cron)', + ], + 'minutes_interval' => 'Minutos entre disparos', + 'hours_interval' => 'Horas entre disparos', + 'days_interval' => 'Días entre disparos', + 'hour' => 'Disparar a la hora', + 'minute' => 'Disparar al minuto', + 'weekdays' => 'Disparar en días', + 'day_of_month' => 'Día del mes', + 'custom_cron' => 'Expresión cron', + 'custom_cron_hint' => 'Formato: minuto hora día mes día-de-semana', + 'timezone_hint' => 'Todos los horarios en :tz', + 'weekday_names' => [ + 'sun' => 'Dom', + 'mon' => 'Lun', + 'tue' => 'Mar', + 'wed' => 'Mié', + 'thu' => 'Jue', + 'fri' => 'Vie', + 'sat' => 'Sáb', + ], + 'summary' => [ + 'every_n_minutes' => 'Se ejecuta cada minuto|Se ejecuta cada :count minutos', + 'every_n_hours' => 'Se ejecuta cada hora en el minuto :minute|Se ejecuta cada :count horas en el minuto :minute', + 'every_n_days' => 'Se ejecuta cada día a las :time|Se ejecuta cada :count días a las :time', + 'weekly' => 'Se ejecuta :days a las :time', + 'monthly' => 'Se ejecuta el día :day de cada mes a las :time', + ], + ], + ], + 'generate' => [ + 'social_accounts' => 'Cuentas sociales', + 'social_accounts_empty' => 'Sin cuentas sociales conectadas. Conecta una primero.', + 'target_slide_count' => 'Diapositivas a generar (para plataformas con carrusel)', + 'prompt_template' => 'Plantilla de prompt', + 'image_source' => 'Fuente de imagen', + 'image_sources' => [ + 'ai' => 'Generada con IA', + 'unsplash' => 'Unsplash', + 'none' => 'Sin imagen', + ], + ], + 'delay' => [ + 'duration' => 'Duración', + 'unit' => 'Unidad', + 'units' => [ + 'minutes' => 'Minutos', + 'hours' => 'Horas', + 'days' => 'Días', + ], + ], + 'condition' => [ + 'field' => 'Campo', + 'operator' => 'Operador', + 'operators' => [ + 'contains' => 'contiene', + 'not_contains' => 'no contiene', + 'equals' => 'es igual a', + 'not_equals' => 'no es igual a', + 'matches' => 'coincide (regex)', + 'greater_than' => 'mayor que', + 'less_than' => 'menor que', + ], + 'value' => 'Valor', + ], + 'publish' => [ + 'mode' => 'Modo', + 'modes' => [ + 'now' => 'Publicar ahora', + 'scheduled' => 'Programar', + 'draft' => 'Guardar como borrador', + ], + 'scheduled_offset' => 'Diferencia desde el disparador (minutos)', + ], + 'webhook' => [ + 'url' => 'URL', + 'method' => 'Método', + 'payload_template' => 'Plantilla de payload (JSON)', + ], + 'end' => [ + 'reason' => 'Razón (opcional)', + 'reason_placeholder' => 'p.ej. Filtrado por la condición', + ], + 'fetch_rss' => [ + 'feed_url' => 'URL del feed', + 'feed_url_hint' => 'En la primera ejecución, el watermark se fija en "ahora" para no inundar los siguientes nodos con ítems históricos. Ejecuciones siguientes solo ven ítems nuevos.', + ], + 'http_request' => [ + 'url' => 'URL', + 'method' => 'Método', + 'auth_type' => 'Autenticación', + 'auth' => [ + 'none' => 'Ninguna (pública)', + 'bearer' => 'Bearer token', + 'basic' => 'Basic auth', + 'api_key' => 'Header de API key', + ], + 'bearer_token' => 'Bearer token', + 'basic_username' => 'Usuario', + 'basic_password' => 'Contraseña', + 'api_key_header' => 'Nombre del header', + 'api_key_value' => 'API key', + 'body_template' => 'Plantilla del body (JSON)', + 'polling_section' => 'Polling (opcional)', + 'polling_hint' => 'Deja vacío para usar la respuesta completa como un solo payload. Rellena para extraer un array de ítems y disparar un run por ítem.', + 'items_path' => 'Ruta de ítems', + 'item_key_path' => 'Ruta de clave del ítem', + 'item_date_path' => 'Ruta de fecha del ítem (opcional)', + 'item_date_path_hint' => 'Ruta JSON al timestamp del ítem. Cuando se define, solo los ítems más nuevos que la última obtención se reenvían — evita que la primera obtención inunde los siguientes nodos.', + ], + ], + + 'delete' => [ + 'title' => 'Eliminar automatización', + 'description' => '¿Estás seguro de que deseas eliminar esta automatización? Todas las ejecuciones y elementos del disparador también serán eliminados. Esta acción no se puede deshacer.', + 'confirm' => 'Eliminar', + 'cancel' => 'Cancelar', + ], + + 'flash' => [ + 'deleted' => '¡Automatización eliminada correctamente!', + ], + + 'errors' => [ + 'no_active_social_accounts' => 'No hay cuentas sociales activas configuradas para esta automatización.', + 'must_have_one_trigger' => 'La automatización debe tener exactamente un nodo disparador.', + 'trigger_must_be_connected' => 'El nodo disparador debe estar conectado a al menos un nodo.', + 'graph_contains_cycle' => 'El grafo de la automatización contiene un ciclo.', + 'only_failed_can_retry' => 'Solo se pueden reintentar ejecuciones fallidas.', + 'no_generated_post' => 'No se encontró un post generado en la ejecución.', + 'webhook_server_error' => 'Error del servidor del webhook.', + '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 368fe2db..7015827a 100644 --- a/lang/es/common.php +++ b/lang/es/common.php @@ -3,6 +3,8 @@ declare(strict_types=1); return [ + 'back' => 'Volver', + 'confirm_modal' => [ 'cannot_be_undone' => 'Esta acción no se puede deshacer.', 'type' => 'Escribe', @@ -47,4 +49,9 @@ 'clear' => 'Limpiar', 'close' => 'Cerrar', 'loading_more' => 'Cargando más...', + + 'actions' => [ + 'copy' => 'Copiar', + 'copied' => 'Copiado', + ], ]; diff --git a/lang/es/sidebar.php b/lang/es/sidebar.php index 9bc137e9..e0705d32 100644 --- a/lang/es/sidebar.php +++ b/lang/es/sidebar.php @@ -26,6 +26,7 @@ ], 'analytics' => 'Analytics', + 'automations' => 'Automatizaciones', 'settings' => 'Configuración', 'posts' => [ diff --git a/lang/pt-BR/automations.php b/lang/pt-BR/automations.php new file mode 100644 index 00000000..41178384 --- /dev/null +++ b/lang/pt-BR/automations.php @@ -0,0 +1,248 @@ + 'Automações', + 'default_name' => 'Nova automação', + + 'actions' => [ + 'new' => 'Nova automação', + 'edit' => 'Editar', + 'save' => 'Salvar', + 'activate' => 'Ativar', + 'pause' => 'Pausar', + 'delete' => 'Excluir', + 'retry' => 'Tentar novamente', + 'add_node' => 'Adicionar nó', + 'test' => 'Testar', + ], + + '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', + 'completed' => 'Concluído', + 'failed' => 'Falhou', + 'waiting' => 'Aguardando', + 'close' => 'Fechar', + 'no_node_runs' => 'Aguardando o primeiro nó começar…', + 'node_input' => 'Entrada', + 'node_output' => 'Saída', + 'node_error' => 'Erro', + 'error_starting' => 'Não foi possível iniciar a execução de teste.', + 'with_real_data' => 'Com dados reais', + 'real_data_hint' => 'Este teste vai publicar posts, avançar watermarks e disparar efeitos colaterais externos.', + 'dry_badge' => 'Teste seco', + ], + + 'status' => [ + 'draft' => 'Rascunho', + 'active' => 'Ativa', + 'paused' => 'Pausada', + ], + + 'index' => [ + 'empty_title' => 'Nenhuma automação ainda', + 'empty_description' => 'Crie sua primeira automação para começar a publicar no piloto automático.', + 'columns' => [ + 'name' => 'Nome', + 'status' => 'Status', + 'created' => 'Criada em', + 'actions' => 'Ações', + ], + ], + + 'show' => [ + 'activated' => 'Ativada', + 'tabs' => [ + 'overview' => 'Visão geral', + 'runs' => 'Execuções', + 'trigger_items' => 'Itens do trigger', + ], + 'canvas_placeholder' => 'Pré-visualização do canvas (somente leitura)', + 'empty_runs' => 'Nenhuma execução ainda.', + 'empty_trigger_items' => 'Nenhum item de trigger ainda.', + 'started' => 'Iniciada', + 'run_label' => 'Execução', + ], + + 'form' => [ + 'activate_error_fallback' => 'Não foi possível ativar a automação.', + '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', + ], + + 'nodes' => [ + 'trigger' => 'Trigger', + 'generate' => 'Gerar', + 'delay' => 'Esperar', + 'condition' => 'Condição', + 'publish' => 'Publicar', + 'webhook' => 'Webhook', + 'end' => 'Encerrar', + 'end_summary' => 'Encerra a automação aqui', + 'fetch_rss' => 'Buscar RSS', + 'http_request' => 'Requisição HTTP', + ], + + 'config' => [ + 'select_placeholder' => 'Selecione…', + + 'trigger' => [ + 'type' => 'Tipo de trigger', + 'types' => [ + 'schedule' => 'Agendamento', + 'post_published' => 'Quando um post é publicado', + 'post_scheduled' => 'Quando um post é agendado', + ], + 'post_published_hint' => 'Roda toda vez que algum post nesta workspace é publicado. O post fica disponível em {{ trigger.post }} pros próximos nós.', + 'post_scheduled_hint' => 'Roda toda vez que algum post nesta workspace é agendado. O post fica disponível em {{ trigger.post }}.', + + 'schedule' => [ + 'field' => 'Intervalo de disparo', + 'fields' => [ + 'minutes' => 'Minutos', + 'hours' => 'Horas', + 'days' => 'Dias', + 'weeks' => 'Semanas', + 'months' => 'Meses', + 'custom' => 'Personalizado (Cron)', + ], + 'minutes_interval' => 'Minutos entre disparos', + 'hours_interval' => 'Horas entre disparos', + 'days_interval' => 'Dias entre disparos', + 'hour' => 'Disparar na hora', + 'minute' => 'Disparar no minuto', + 'weekdays' => 'Disparar nos dias', + 'day_of_month' => 'Dia do mês', + 'custom_cron' => 'Expressão cron', + 'custom_cron_hint' => 'Formato: minuto hora dia mês dia-da-semana', + 'timezone_hint' => 'Todos os horários em :tz', + 'weekday_names' => [ + 'sun' => 'Dom', + 'mon' => 'Seg', + 'tue' => 'Ter', + 'wed' => 'Qua', + 'thu' => 'Qui', + 'fri' => 'Sex', + 'sat' => 'Sáb', + ], + 'summary' => [ + 'every_n_minutes' => 'Roda a cada minuto|Roda a cada :count minutos', + 'every_n_hours' => 'Roda a cada hora no minuto :minute|Roda a cada :count horas no minuto :minute', + 'every_n_days' => 'Roda todo dia às :time|Roda a cada :count dias às :time', + 'weekly' => 'Roda :days às :time', + 'monthly' => 'Roda no dia :day de cada mês às :time', + ], + ], + ], + 'generate' => [ + 'social_accounts' => 'Contas sociais', + 'social_accounts_empty' => 'Nenhuma conta social conectada. Conecte uma primeiro.', + 'target_slide_count' => 'Slides a gerar (para plataformas com carrossel)', + 'prompt_template' => 'Template do prompt', + 'image_source' => 'Origem da imagem', + 'image_sources' => [ + 'ai' => 'Gerada por IA', + 'unsplash' => 'Unsplash', + 'none' => 'Sem imagem', + ], + ], + 'delay' => [ + 'duration' => 'Duração', + 'unit' => 'Unidade', + 'units' => [ + 'minutes' => 'Minutos', + 'hours' => 'Horas', + 'days' => 'Dias', + ], + ], + 'condition' => [ + 'field' => 'Campo', + 'operator' => 'Operador', + 'operators' => [ + 'contains' => 'contém', + 'not_contains' => 'não contém', + 'equals' => 'igual a', + 'not_equals' => 'diferente de', + 'matches' => 'corresponde (regex)', + 'greater_than' => 'maior que', + 'less_than' => 'menor que', + ], + 'value' => 'Valor', + ], + 'publish' => [ + 'mode' => 'Modo', + 'modes' => [ + 'now' => 'Publicar agora', + 'scheduled' => 'Agendar', + 'draft' => 'Salvar como rascunho', + ], + 'scheduled_offset' => 'Atraso a partir do trigger (minutos)', + ], + 'webhook' => [ + 'url' => 'URL', + 'method' => 'Método', + 'payload_template' => 'Template do payload (JSON)', + ], + 'end' => [ + 'reason' => 'Motivo (opcional)', + 'reason_placeholder' => 'ex: Filtrado pela condição', + ], + 'fetch_rss' => [ + 'feed_url' => 'URL do feed', + 'feed_url_hint' => 'Na primeira execução, o watermark é setado pra "agora" pra não inundar os próximos nós com items históricos. Execuções seguintes só veem items novos.', + ], + 'http_request' => [ + 'url' => 'URL', + 'method' => 'Método', + 'auth_type' => 'Autenticação', + 'auth' => [ + 'none' => 'Nenhuma (público)', + 'bearer' => 'Bearer token', + 'basic' => 'Basic auth', + 'api_key' => 'Header de API key', + ], + 'bearer_token' => 'Bearer token', + 'basic_username' => 'Usuário', + 'basic_password' => 'Senha', + 'api_key_header' => 'Nome do header', + 'api_key_value' => 'API key', + 'body_template' => 'Template do body (JSON)', + 'polling_section' => 'Polling (opcional)', + 'polling_hint' => 'Deixe vazio para usar a resposta inteira como payload único. Preencha para extrair um array de itens e disparar um run por item.', + 'items_path' => 'Caminho dos itens', + 'item_key_path' => 'Caminho da chave do item', + 'item_date_path' => 'Caminho da data do item (opcional)', + 'item_date_path_hint' => 'Caminho JSON pro timestamp do item. Quando definido, só items mais novos que a última busca são encaminhados — evita que a primeira busca inunde os próximos nós.', + ], + ], + + 'delete' => [ + 'title' => 'Excluir automação', + 'description' => 'Tem certeza que deseja excluir esta automação? Todas as execuções e itens de gatilho também serão removidos. Esta ação não pode ser desfeita.', + 'confirm' => 'Excluir', + 'cancel' => 'Cancelar', + ], + + 'flash' => [ + 'deleted' => 'Automação excluída com sucesso!', + ], + + 'errors' => [ + 'no_active_social_accounts' => 'Nenhuma conta social ativa configurada para esta automação.', + 'must_have_one_trigger' => 'A automação precisa ter exatamente um nó de trigger.', + 'trigger_must_be_connected' => 'O nó de trigger precisa estar conectado a pelo menos um nó.', + 'graph_contains_cycle' => 'O grafo da automação contém um ciclo.', + 'only_failed_can_retry' => 'Apenas execuções que falharam podem ser repetidas.', + 'no_generated_post' => 'Nenhum post gerado encontrado para esta execução.', + 'webhook_server_error' => 'Erro no servidor do webhook.', + '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 c3f33be6..57bc6217 100644 --- a/lang/pt-BR/common.php +++ b/lang/pt-BR/common.php @@ -3,6 +3,8 @@ declare(strict_types=1); return [ + 'back' => 'Voltar', + 'confirm_modal' => [ 'cannot_be_undone' => 'Esta ação não pode ser desfeita.', 'type' => 'Digite', @@ -47,4 +49,9 @@ 'clear' => 'Limpar', 'close' => 'Fechar', 'loading_more' => 'Carregando mais...', + + 'actions' => [ + 'copy' => 'Copiar', + 'copied' => 'Copiado', + ], ]; diff --git a/lang/pt-BR/sidebar.php b/lang/pt-BR/sidebar.php index f9a59830..73cbcad0 100644 --- a/lang/pt-BR/sidebar.php +++ b/lang/pt-BR/sidebar.php @@ -26,6 +26,7 @@ ], 'analytics' => 'Analytics', + 'automations' => 'Automações', 'settings' => 'Configurações', 'posts' => [ diff --git a/package-lock.json b/package-lock.json index 96a8f02c..3d94a694 100644 --- a/package-lock.json +++ b/package-lock.json @@ -4,17 +4,21 @@ "requires": true, "packages": { "": { - "name": "trypost", "dependencies": { "@inertiajs/vue3": "^3.0.0", "@tabler/icons-vue": "^3.36.1", "@tailwindcss/typography": "^0.5.19", + "@vue-flow/background": "^1.3.2", + "@vue-flow/controls": "^1.1.3", + "@vue-flow/core": "^1.48.2", + "@vue-flow/minimap": "^1.5.4", "@vueuse/core": "^12.8.2", "axios": "^1.13.2", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "dayjs": "^1.11.19", "embla-carousel-vue": "^8.6.0", + "highlight.js": "^11.11.1", "laravel-vite-plugin": "^2.0.0", "laravel-vue-i18n": "^2.8.0", "maska": "^3.2.0", @@ -3523,6 +3527,150 @@ "vscode-uri": "^3.0.8" } }, + "node_modules/@vue-flow/background": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/@vue-flow/background/-/background-1.3.2.tgz", + "integrity": "sha512-eJPhDcLj1wEo45bBoqTXw1uhl0yK2RaQGnEINqvvBsAFKh/camHJd5NPmOdS1w+M9lggc9igUewxaEd3iCQX2w==", + "license": "MIT", + "peerDependencies": { + "@vue-flow/core": "^1.23.0", + "vue": "^3.3.0" + } + }, + "node_modules/@vue-flow/controls": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@vue-flow/controls/-/controls-1.1.3.tgz", + "integrity": "sha512-XCf+G+jCvaWURdFlZmOjifZGw3XMhN5hHlfMGkWh9xot+9nH9gdTZtn+ldIJKtarg3B21iyHU8JjKDhYcB6JMw==", + "license": "MIT", + "peerDependencies": { + "@vue-flow/core": "^1.23.0", + "vue": "^3.3.0" + } + }, + "node_modules/@vue-flow/core": { + "version": "1.48.2", + "resolved": "https://registry.npmjs.org/@vue-flow/core/-/core-1.48.2.tgz", + "integrity": "sha512-raxhgKWE+G/mcEvXJjGFUDYW9rAI3GOtiHR3ZkNpwBWuIaCC1EYiBmKGwJOoNzVFgwO7COgErnK7i08i287AFA==", + "license": "MIT", + "dependencies": { + "@vueuse/core": "^10.5.0", + "d3-drag": "^3.0.0", + "d3-interpolate": "^3.0.1", + "d3-selection": "^3.0.0", + "d3-zoom": "^3.0.0" + }, + "peerDependencies": { + "vue": "^3.3.0" + } + }, + "node_modules/@vue-flow/core/node_modules/@types/web-bluetooth": { + "version": "0.0.20", + "resolved": "https://registry.npmjs.org/@types/web-bluetooth/-/web-bluetooth-0.0.20.tgz", + "integrity": "sha512-g9gZnnXVq7gM7v3tJCWV/qw7w+KeOlSHAhgF9RytFyifW6AF61hdT2ucrYhPq9hLs5JIryeupHV3qGk95dH9ow==", + "license": "MIT" + }, + "node_modules/@vue-flow/core/node_modules/@vueuse/core": { + "version": "10.11.1", + "resolved": "https://registry.npmjs.org/@vueuse/core/-/core-10.11.1.tgz", + "integrity": "sha512-guoy26JQktXPcz+0n3GukWIy/JDNKti9v6VEMu6kV2sYBsWuGiTU8OWdg+ADfUbHg3/3DlqySDe7JmdHrktiww==", + "license": "MIT", + "dependencies": { + "@types/web-bluetooth": "^0.0.20", + "@vueuse/metadata": "10.11.1", + "@vueuse/shared": "10.11.1", + "vue-demi": ">=0.14.8" + }, + "funding": { + "url": "https://github.com/sponsors/antfu" + } + }, + "node_modules/@vue-flow/core/node_modules/@vueuse/core/node_modules/vue-demi": { + "version": "0.14.10", + "resolved": "https://registry.npmjs.org/vue-demi/-/vue-demi-0.14.10.tgz", + "integrity": "sha512-nMZBOwuzabUO0nLgIcc6rycZEebF6eeUfaiQx9+WSk8e29IbLvPU9feI6tqW4kTo3hvoYAJkMh8n8D0fuISphg==", + "hasInstallScript": true, + "license": "MIT", + "bin": { + "vue-demi-fix": "bin/vue-demi-fix.js", + "vue-demi-switch": "bin/vue-demi-switch.js" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/antfu" + }, + "peerDependencies": { + "@vue/composition-api": "^1.0.0-rc.1", + "vue": "^3.0.0-0 || ^2.6.0" + }, + "peerDependenciesMeta": { + "@vue/composition-api": { + "optional": true + } + } + }, + "node_modules/@vue-flow/core/node_modules/@vueuse/metadata": { + "version": "10.11.1", + "resolved": "https://registry.npmjs.org/@vueuse/metadata/-/metadata-10.11.1.tgz", + "integrity": "sha512-IGa5FXd003Ug1qAZmyE8wF3sJ81xGLSqTqtQ6jaVfkeZ4i5kS2mwQF61yhVqojRnenVew5PldLyRgvdl4YYuSw==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/antfu" + } + }, + "node_modules/@vue-flow/core/node_modules/@vueuse/shared": { + "version": "10.11.1", + "resolved": "https://registry.npmjs.org/@vueuse/shared/-/shared-10.11.1.tgz", + "integrity": "sha512-LHpC8711VFZlDaYUXEBbFBCQ7GS3dVU9mjOhhMhXP6txTV4EhYQg/KGnQuvt/sPAtoUKq7VVUnL6mVtFoL42sA==", + "license": "MIT", + "dependencies": { + "vue-demi": ">=0.14.8" + }, + "funding": { + "url": "https://github.com/sponsors/antfu" + } + }, + "node_modules/@vue-flow/core/node_modules/@vueuse/shared/node_modules/vue-demi": { + "version": "0.14.10", + "resolved": "https://registry.npmjs.org/vue-demi/-/vue-demi-0.14.10.tgz", + "integrity": "sha512-nMZBOwuzabUO0nLgIcc6rycZEebF6eeUfaiQx9+WSk8e29IbLvPU9feI6tqW4kTo3hvoYAJkMh8n8D0fuISphg==", + "hasInstallScript": true, + "license": "MIT", + "bin": { + "vue-demi-fix": "bin/vue-demi-fix.js", + "vue-demi-switch": "bin/vue-demi-switch.js" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/antfu" + }, + "peerDependencies": { + "@vue/composition-api": "^1.0.0-rc.1", + "vue": "^3.0.0-0 || ^2.6.0" + }, + "peerDependenciesMeta": { + "@vue/composition-api": { + "optional": true + } + } + }, + "node_modules/@vue-flow/minimap": { + "version": "1.5.4", + "resolved": "https://registry.npmjs.org/@vue-flow/minimap/-/minimap-1.5.4.tgz", + "integrity": "sha512-l4C+XTAXnRxsRpUdN7cAVFBennC1sVRzq4bDSpVK+ag7tdMczAnhFYGgbLkUw3v3sY6gokyWwMl8CDonp8eB2g==", + "license": "MIT", + "dependencies": { + "d3-selection": "^3.0.0", + "d3-zoom": "^3.0.0" + }, + "peerDependencies": { + "@vue-flow/core": "^1.23.0", + "vue": "^3.3.0" + } + }, "node_modules/@vue/compiler-core": { "version": "3.5.26", "resolved": "https://registry.npmjs.org/@vue/compiler-core/-/compiler-core-3.5.26.tgz", @@ -4449,7 +4597,6 @@ "version": "3.1.0", "resolved": "https://registry.npmjs.org/d3-color/-/d3-color-3.1.0.tgz", "integrity": "sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==", - "dev": true, "license": "ISC", "engines": { "node": ">=12" @@ -4485,7 +4632,6 @@ "version": "3.0.1", "resolved": "https://registry.npmjs.org/d3-dispatch/-/d3-dispatch-3.0.1.tgz", "integrity": "sha512-rzUyPU/S7rwUflMyLc1ETDeBj0NRuHKKAcvukozwhshr6g6c5d8zh4c2gQjY2bZ0dXeGLWc1PF174P2tVvKhfg==", - "dev": true, "license": "ISC", "engines": { "node": ">=12" @@ -4495,7 +4641,6 @@ "version": "3.0.0", "resolved": "https://registry.npmjs.org/d3-drag/-/d3-drag-3.0.0.tgz", "integrity": "sha512-pWbUJLdETVA8lQNJecMxoXfH6x+mO2UQo8rSmZ+QqxcbyA3hfeprFgIT//HW2nlHChWeIIMwS2Fq+gEARkhTkg==", - "dev": true, "license": "ISC", "dependencies": { "d3-dispatch": "1 - 3", @@ -4535,7 +4680,6 @@ "version": "3.0.1", "resolved": "https://registry.npmjs.org/d3-ease/-/d3-ease-3.0.1.tgz", "integrity": "sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==", - "dev": true, "license": "BSD-3-Clause", "engines": { "node": ">=12" @@ -4628,7 +4772,6 @@ "version": "3.0.1", "resolved": "https://registry.npmjs.org/d3-interpolate/-/d3-interpolate-3.0.1.tgz", "integrity": "sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==", - "dev": true, "license": "ISC", "dependencies": { "d3-color": "1 - 3" @@ -4764,7 +4907,6 @@ "version": "3.0.0", "resolved": "https://registry.npmjs.org/d3-selection/-/d3-selection-3.0.0.tgz", "integrity": "sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ==", - "dev": true, "license": "ISC", "engines": { "node": ">=12" @@ -4813,7 +4955,6 @@ "version": "3.0.1", "resolved": "https://registry.npmjs.org/d3-timer/-/d3-timer-3.0.1.tgz", "integrity": "sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==", - "dev": true, "license": "ISC", "engines": { "node": ">=12" @@ -4823,7 +4964,6 @@ "version": "3.0.1", "resolved": "https://registry.npmjs.org/d3-transition/-/d3-transition-3.0.1.tgz", "integrity": "sha512-ApKvfjsSR6tg06xrL434C0WydLr7JewBB3V+/39RMHsaXTOG0zmt/OAXeng5M5LBm0ojmxJrpomQVZ1aPvBL4w==", - "dev": true, "license": "ISC", "dependencies": { "d3-color": "1 - 3", @@ -4843,7 +4983,6 @@ "version": "3.0.0", "resolved": "https://registry.npmjs.org/d3-zoom/-/d3-zoom-3.0.0.tgz", "integrity": "sha512-b8AmV3kfQaqWAuacbPuNbL6vahnOJflOhexLzMMNLga62+/nh0JzvJ0aO/5a5MVgUFGS7Hu1P9P03o3fJkDCyw==", - "dev": true, "license": "ISC", "dependencies": { "d3-dispatch": "1 - 3", @@ -6457,6 +6596,15 @@ "he": "bin/he" } }, + "node_modules/highlight.js": { + "version": "11.11.1", + "resolved": "https://registry.npmjs.org/highlight.js/-/highlight.js-11.11.1.tgz", + "integrity": "sha512-Xwwo44whKBVCYoliBQwaPvtd/2tYFkRQtXDWj1nackaV2JPXx3L0+Jvd8/qCJ2p+ML0/XVkJ2q+Mr+UVdpJK5w==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=12.0.0" + } + }, "node_modules/https-proxy-agent": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-5.0.1.tgz", diff --git a/package.json b/package.json index e9d1147b..0854b3c7 100644 --- a/package.json +++ b/package.json @@ -41,12 +41,17 @@ "@inertiajs/vue3": "^3.0.0", "@tabler/icons-vue": "^3.36.1", "@tailwindcss/typography": "^0.5.19", + "@vue-flow/background": "^1.3.2", + "@vue-flow/controls": "^1.1.3", + "@vue-flow/core": "^1.48.2", + "@vue-flow/minimap": "^1.5.4", "@vueuse/core": "^12.8.2", "axios": "^1.13.2", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "dayjs": "^1.11.19", "embla-carousel-vue": "^8.6.0", + "highlight.js": "^11.11.1", "laravel-vite-plugin": "^2.0.0", "laravel-vue-i18n": "^2.8.0", "maska": "^3.2.0", diff --git a/resources/css/app.css b/resources/css/app.css index 85c284a5..9c4fe736 100644 --- a/resources/css/app.css +++ b/resources/css/app.css @@ -164,3 +164,121 @@ .trypost-container { @apply relative max-w-7xl w-full flex-1 mx-auto flex flex-col justify-center; } + +/* Automations canvas nodes — TryPost brutalist identity: + thick ink borders, hard offset shadow, warm card background, colored header + strip that fills the top of the card with the accent tint. */ +.automation-node { + position: relative; + min-width: 230px; + background: var(--card); + border: 2px solid var(--foreground); + border-radius: 14px; + box-shadow: 3px 3px 0 var(--foreground); + transition: transform 120ms ease, box-shadow 120ms ease; +} + +.automation-node:hover { + transform: translate(-1px, -1px); + box-shadow: 4px 4px 0 var(--foreground); +} + +.automation-node--wide { + min-width: 260px; +} + +.automation-node.is-selected { + transform: translate(-2px, -2px); + box-shadow: 5px 5px 0 #7c3aed; +} + +.automation-node__header { + display: flex; + align-items: center; + gap: 0.625rem; + padding: 0.625rem 0.875rem; + border-bottom: 2px solid var(--foreground); + border-top-left-radius: 12px; + border-top-right-radius: 12px; +} + +.automation-node__icon-tile { + display: inline-flex; + align-items: center; + justify-content: center; + width: 28px; + height: 28px; + border: 2px solid var(--foreground); + border-radius: 8px; + flex-shrink: 0; + transform: rotate(-3deg); +} + +/* Header accent tint mirrors the icon tile color, applied to the whole header + strip so the node type is identifiable at a glance from the canvas. */ +.automation-node__icon-tile--violet { background: #ede9fe; color: #5b21b6; } +.automation-node__icon-tile--blue { background: #dbeafe; color: #1d4ed8; } +.automation-node__icon-tile--amber { background: #fef3c7; color: #92400e; } +.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--accent-violet .automation-node__header { background: #f5f3ff; } +.automation-node--accent-blue .automation-node__header { background: #eff6ff; } +.automation-node--accent-amber .automation-node__header { background: #fffbeb; } +.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__title { + font-weight: 700; + font-size: 0.875rem; + color: var(--foreground); + line-height: 1.2; + letter-spacing: -0.005em; +} + +.automation-node__summary { + padding: 0.625rem 0.875rem 0.75rem 0.875rem; + font-size: 0.75rem; + font-weight: 500; + color: color-mix(in srgb, var(--foreground) 70%, transparent); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + border-bottom-left-radius: 12px; + border-bottom-right-radius: 12px; +} + +.automation-node__branches { + display: flex; + align-items: center; + justify-content: space-between; + padding: 0.375rem 0.875rem 0.625rem 0.875rem; + font-size: 0.625rem; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.06em; +} + +.automation-node__branch--yes { color: #047857; } +.automation-node__branch--no { color: #be123c; } + +/* JsonViewer — GitHub Light palette for highlight.js JSON output. */ + +.json-viewer__body { + font-family: ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, "Liberation Mono", monospace; + margin: 0; + background: #ffffff; + color: #24292f; +} + +.json-viewer .hljs-attr { color: #0550ae; } +.json-viewer .hljs-string { color: #0a3069; } +.json-viewer .hljs-number { color: #0550ae; } +.json-viewer .hljs-literal { color: #cf222e; } +.json-viewer .hljs-punctuation { color: #57606a; } +.json-viewer .hljs-comment { color: #6e7781; font-style: italic; } + diff --git a/resources/js/components/AppSidebar.vue b/resources/js/components/AppSidebar.vue index a1a81495..dca75aee 100644 --- a/resources/js/components/AppSidebar.vue +++ b/resources/js/components/AppSidebar.vue @@ -2,6 +2,7 @@ import { Link, router, usePage } from '@inertiajs/vue3'; import { IconAffiliate, + IconBolt, IconCalendar, IconChartBar, IconChevronRight, @@ -45,6 +46,7 @@ import { useFeatureAccess } from '@/composables/useFeatureAccess'; import { useUpgradeDialog } from '@/composables/useUpgradeDialog'; import { accounts, analytics, calendar, settings as settingsHub } from '@/routes/app'; import { index as assets } from '@/routes/app/assets'; +import { index as automations } from '@/routes/app/automations'; import { index as labels } from '@/routes/app/labels'; import { index as signatures } from '@/routes/app/signatures'; import { create as createWorkspaceRoute, switchMethod } from '@/routes/app/workspaces'; @@ -71,6 +73,11 @@ const mainNavItems = computed(() => [ href: analytics.url(), icon: IconChartBar, }, + { + title: trans('sidebar.automations'), + href: automations.url(), + icon: IconBolt, + }, ]); const postsNavItems = computed(() => [ diff --git a/resources/js/components/JsonViewer.vue b/resources/js/components/JsonViewer.vue new file mode 100644 index 00000000..e53289bf --- /dev/null +++ b/resources/js/components/JsonViewer.vue @@ -0,0 +1,51 @@ + + + diff --git a/resources/js/components/automations/AutomationConnectionLine.vue b/resources/js/components/automations/AutomationConnectionLine.vue new file mode 100644 index 00000000..77d32fbf --- /dev/null +++ b/resources/js/components/automations/AutomationConnectionLine.vue @@ -0,0 +1,54 @@ + + + diff --git a/resources/js/components/automations/TestRunPanel.vue b/resources/js/components/automations/TestRunPanel.vue new file mode 100644 index 00000000..fa42b65e --- /dev/null +++ b/resources/js/components/automations/TestRunPanel.vue @@ -0,0 +1,225 @@ + + + diff --git a/resources/js/components/automations/config/ConditionNodeConfig.vue b/resources/js/components/automations/config/ConditionNodeConfig.vue new file mode 100644 index 00000000..97c8f5df --- /dev/null +++ b/resources/js/components/automations/config/ConditionNodeConfig.vue @@ -0,0 +1,68 @@ + + + diff --git a/resources/js/components/automations/config/DelayNodeConfig.vue b/resources/js/components/automations/config/DelayNodeConfig.vue new file mode 100644 index 00000000..654b8746 --- /dev/null +++ b/resources/js/components/automations/config/DelayNodeConfig.vue @@ -0,0 +1,56 @@ + + + diff --git a/resources/js/components/automations/config/EndNodeConfig.vue b/resources/js/components/automations/config/EndNodeConfig.vue new file mode 100644 index 00000000..dfb8fa42 --- /dev/null +++ b/resources/js/components/automations/config/EndNodeConfig.vue @@ -0,0 +1,33 @@ + + +