diff --git a/app/Actions/Automation/Automation/ActivateAutomation.php b/app/Actions/Automation/Automation/ActivateAutomation.php new file mode 100644 index 00000000..7b83b918 --- /dev/null +++ b/app/Actions/Automation/Automation/ActivateAutomation.php @@ -0,0 +1,57 @@ +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')); + } + + foreach ($nodes as $node) { + if (data_get($node, 'type') !== NodeType::Generate->value) { + continue; + } + + $issue = $this->generateValidator->issueFor((array) data_get($node, 'data', [])); + + if ($issue !== null) { + throw new \DomainException($issue); + } + } + } +} diff --git a/app/Actions/Automation/Automation/CreateAutomation.php b/app/Actions/Automation/Automation/CreateAutomation.php new file mode 100644 index 00000000..59db041a --- /dev/null +++ b/app/Actions/Automation/Automation/CreateAutomation.php @@ -0,0 +1,52 @@ + $workspace->id, + 'user_id' => $user->id, + 'name' => $name ?: __('automations.default_name'), + 'status' => Status::Draft, + 'nodes' => [$this->defaultTriggerNode()], + 'connections' => [], + ]); + } + + /** + * Every automation has exactly one trigger — its entry point — so we seed it + * on creation. The trigger can't be added or deleted from the editor; only + * its type (schedule / post published / post scheduled) is configurable. + * + * @return array + */ + private function defaultTriggerNode(): array + { + return [ + 'id' => (string) Str::uuid(), + 'type' => 'trigger', + 'position' => ['x' => 0, 'y' => 0], + 'data' => [ + 'trigger_type' => TriggerType::Schedule->value, + 'cron' => '0 9 * * *', + 'schedule_field' => 'days', + 'schedule_days_interval' => 1, + 'schedule_hour' => 9, + 'schedule_minute' => 0, + 'schedule_timezone' => config('app.timezone'), + ], + ]; + } +} diff --git a/app/Actions/Automation/Automation/DeleteAutomation.php b/app/Actions/Automation/Automation/DeleteAutomation.php new file mode 100644 index 00000000..1d4d4719 --- /dev/null +++ b/app/Actions/Automation/Automation/DeleteAutomation.php @@ -0,0 +1,15 @@ +delete(); + } +} diff --git a/app/Actions/Automation/Automation/GetAutomationDetails.php b/app/Actions/Automation/Automation/GetAutomationDetails.php new file mode 100644 index 00000000..af4424a2 --- /dev/null +++ b/app/Actions/Automation/Automation/GetAutomationDetails.php @@ -0,0 +1,27 @@ +, + * triggerItems: Collection, + * } + */ + public function __invoke(Automation $automation): array + { + return [ + 'runs' => $automation->runs()->excludingDryRuns()->latest()->take(50)->get(), + 'triggerItems' => $automation->triggerItems()->with('run')->latest()->take(50)->get(), + ]; + } +} diff --git a/app/Actions/Automation/Automation/GetAutomationEditorData.php b/app/Actions/Automation/Automation/GetAutomationEditorData.php new file mode 100644 index 00000000..157e4ee1 --- /dev/null +++ b/app/Actions/Automation/Automation/GetAutomationEditorData.php @@ -0,0 +1,60 @@ +, + * pinterestBoards: SupportCollection>, + * tiktokCreatorInfos: SupportCollection, + * } + */ + public function __invoke(Automation $automation): array + { + $socialAccounts = $automation->workspace->socialAccounts()->active()->get(); + + $pinterestBoards = $socialAccounts + ->where('platform', Platform::Pinterest) + ->mapWithKeys(fn ($account) => [ + $account->id => rescue( + fn () => $this->pinterestPublisher->getBoards($account), + [], + report: false, + ), + ]); + + $tiktokCreatorInfos = $socialAccounts + ->where('platform', Platform::TikTok) + ->mapWithKeys(fn ($account) => [ + $account->id => rescue( + fn () => $this->tikTokCreatorInfo->fetch($account), + null, + report: false, + ), + ]) + ->filter(); + + return [ + 'socialAccounts' => $socialAccounts, + 'pinterestBoards' => $pinterestBoards, + 'tiktokCreatorInfos' => $tiktokCreatorInfos, + ]; + } +} diff --git a/app/Actions/Automation/Automation/ListAutomations.php b/app/Actions/Automation/Automation/ListAutomations.php new file mode 100644 index 00000000..8b8fff3c --- /dev/null +++ b/app/Actions/Automation/Automation/ListAutomations.php @@ -0,0 +1,20 @@ +where('workspace_id', $workspace->id) + ->orderByDesc('created_at') + ->paginate($perPage ?? (int) config('app.pagination.default')); + } +} 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..02195e78 --- /dev/null +++ b/app/Actions/Automation/Automation/UpdateAutomation.php @@ -0,0 +1,66 @@ +detectCycles($data['nodes'] ?? [], $data['connections'] ?? []); + + $automation->update([ + 'name' => $data['name'] ?? $automation->name, + 'nodes' => $data['nodes'] ?? $automation->nodes, + 'connections' => $data['connections'] ?? $automation->connections, + 'variables' => $data['variables'] ?? $automation->variables, + ]); + + 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..34935f8e --- /dev/null +++ b/app/Actions/Automation/Node/RunConditionNode.php @@ -0,0 +1,62 @@ +resolverContext(); + $field = $this->resolver->resolve(data_get($config, 'field', ''), $context); + $operator = Operator::from(data_get($config, 'operator', 'equals')); + $value = $this->resolver->resolve((string) data_get($config, 'value', ''), $context); + + $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..c66a0a5e --- /dev/null +++ b/app/Actions/Automation/Node/RunFetchRssNode.php @@ -0,0 +1,219 @@ +resolver->resolve((string) data_get($config, 'feed_url', ''), $run->resolverContext()); + + if ($feedUrl === '') { + return NodeRunResult::failed('Fetch RSS node missing feed_url.'); + } + + try { + $this->safeHttp->guardAgainstSsrf($feedUrl); + } catch (RuntimeException) { + return NodeRunResult::failed(__('automations.errors.url_not_allowed'), [ + 'reason' => 'url_not_allowed', + 'url' => $feedUrl, + ]); + } + + $response = Http::get($feedUrl); + + if (! $response->successful()) { + 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 && ! $run->is_manual) { + $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'] ?? self::ITEM_HANDLE) === self::ITEM_HANDLE); + + 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..286c66c8 --- /dev/null +++ b/app/Actions/Automation/Node/RunGenerateNode.php @@ -0,0 +1,329 @@ +resolverContext(); + $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', []), + ]; + } + + $includeImage = (bool) data_get($config, 'include_image', true); + + $brandAccount = $platforms !== [] + ? $activeAccounts->get(data_get($platforms[0], 'social_account_id')) + : null; + + $contentType = $platforms !== [] + ? ContentType::tryFrom((string) data_get($platforms[0], 'content_type')) + : null; + + $imageCount = $this->intendedImageCount($format, $slideCount, $includeImage, $structured, $brandAccount); + + // 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, + 'image_count' => $imageCount, + ], + ]); + } + + $media = []; + + if ($brandAccount) { + if ($format === 'carousel') { + $media = app(PostImagePipeline::class)->forCarousel($workspace, $brandAccount, $structured, $contentType); + } elseif ($includeImage) { + $media = app(PostImagePipeline::class)->forSingle($workspace, $brandAccount, $structured, $contentType); + } + } + + $post = CreatePost::execute($workspace, $user, [ + 'content' => $content, + 'media' => $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_feed (Instagram feed carousel = multi-image feed post) + * - 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 + { + // Multi-image capability comes from the same ContentType rules the + // publish flow uses — never a hardcoded list — so facebook_post, + // tiktok_photo, linkedin_carousel etc. are all recognised. We cap the + // slide count at MAX_GENERATED_IMAGES and at the tightest selected + // account's limit. + $maxImagesAcross = 0; + foreach ($accountsConfig as $entry) { + $contentType = ContentType::tryFrom((string) data_get($entry, 'content_type')); + if ($contentType instanceof ContentType && $contentType->supportsImage() && $contentType->maxMediaCount() > 1) { + $maxImagesAcross = max($maxImagesAcross, $contentType->maxMediaCount()); + } + } + + $targetSlideCount = (int) data_get($config, 'target_slide_count', 1); + + if ($maxImagesAcross > 1 && $targetSlideCount > 1) { + $cap = min(GenerateNodeValidator::MAX_GENERATED_IMAGES, $maxImagesAcross); + + return ['format' => 'carousel', 'slide_count' => min($targetSlideCount, $cap)]; + } + + return ['format' => 'single', 'slide_count' => 1]; + } + + /** + * Number of images that would be attached for the resolved format. Used as + * the dry-run indicator and mirrors the non-dry image generation branches: + * one per slide for carousels, one for single posts when images are enabled. + * + * @param array $structured + */ + private function intendedImageCount(string $format, int $slideCount, bool $includeImage, array $structured, ?SocialAccount $brandAccount): int + { + if (! $brandAccount) { + return 0; + } + + if ($format === 'carousel') { + $slides = data_get($structured, 'slides', []); + + return is_array($slides) ? count($slides) : $slideCount; + } + + return $includeImage ? 1 : 0; + } + + 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..f5a42ab1 --- /dev/null +++ b/app/Actions/Automation/Node/RunHttpRequestNode.php @@ -0,0 +1,316 @@ +current_node_id; + $context = $run->resolverContext(); + + if ($url === '') { + return NodeRunResult::failed('HTTP request node missing url.'); + } + + $resolvedUrl = $this->resolver->resolve($url, $context); + + try { + $this->safeHttp->guardAgainstSsrf($resolvedUrl); + } catch (RuntimeException) { + return NodeRunResult::failed(__('automations.errors.url_not_allowed'), [ + 'reason' => 'url_not_allowed', + 'url' => $resolvedUrl, + ]); + } + + $request = $this->buildRequest($config, $context); + $body = $this->buildJsonBody($method, $config, $context); + + 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 && ! $run->is_manual) { + $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->withUserAgent(config('trypost.user_agent')); + } + + /** + * @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'] ?? self::ITEM_HANDLE) === self::ITEM_HANDLE); + + 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..18ee13ff --- /dev/null +++ b/app/Actions/Automation/Node/RunWebhookNode.php @@ -0,0 +1,82 @@ +resolverContext(); + $url = $this->resolver->resolve($config['url'] ?? '', $context); + $method = strtoupper($config['method'] ?? 'POST'); + + try { + $this->safeHttp->guardAgainstSsrf($url); + } catch (RuntimeException) { + return NodeRunResult::failed(__('automations.errors.url_not_allowed'), [ + 'reason' => 'url_not_allowed', + 'url' => $url, + ]); + } + $headers = []; + + foreach ($config['headers'] ?? [] as $k => $v) { + $headers[$k] = $this->resolver->resolve((string) $v, $context); + } + + $payloadJson = $this->resolver->resolve($config['payload_template'] ?? '{}', $context); + $trimmedPayload = trim($payloadJson); + + if ($trimmedPayload !== '' && $trimmedPayload !== 'null') { + $decoded = json_decode($payloadJson, true); + + if (json_last_error() !== JSON_ERROR_NONE) { + return NodeRunResult::failed(__('automations.errors.webhook_invalid_payload_json'), [ + 'reason' => 'invalid_payload_json', + ]); + } + + $payload = $decoded ?? []; + } else { + $payload = []; + } + + if ($run->is_dry_run) { + return NodeRunResult::completed(output: [ + 'webhook' => ['method' => $method, 'url' => $url, 'dry_run' => true], + ]); + } + + $response = Http::withHeaders($headers) + ->withUserAgent(config('trypost.user_agent')) + ->send($method, $url, ['json' => $payload]); + + 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..c6d2802c --- /dev/null +++ b/app/Actions/Automation/Run/AdvanceAutomationRun.php @@ -0,0 +1,37 @@ +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, + 'error' => [ + 'reason' => 'no_matching_edge', + 'handle' => $handle, + 'node_id' => $fromNodeId, + ], + ]); + + 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..b6a740c1 --- /dev/null +++ b/app/Actions/Automation/Run/TestAutomation.php @@ -0,0 +1,122 @@ +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 008ace8a..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,8 +62,15 @@ public static function execute(Workspace $workspace, User $user, array $data): P $updates['content_type'] = $contentType; } - if (($meta = data_get($platformData, 'meta')) !== null) { - $updates['meta'] = $meta; + $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() 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..3cae03f0 --- /dev/null +++ b/app/Console/Commands/Automation/ProcessAutomationDelays.php @@ -0,0 +1,48 @@ +where('status', Status::Waiting) + ->where('next_action_at', '<=', now()) + ->where(fn ($query) => $query + ->where('is_manual', true) + ->orWhereHas('automation', fn ($inner) => $inner->where('status', AutomationStatus::Active))) + ->chunkById(50, function ($runs) use ($advance) { + foreach ($runs as $run) { + $claimed = AutomationRun::query() + ->whereKey($run->id) + ->where('status', Status::Waiting) + ->update([ + 'status' => Status::Running, + 'next_action_at' => null, + ]); + + if ($claimed === 0) { + continue; + } + + $run->refresh(); + $advance($run, $run->current_node_id); + } + }); + + return self::SUCCESS; + } +} diff --git a/app/Console/Commands/Automation/PruneDryRunAutomationRuns.php b/app/Console/Commands/Automation/PruneDryRunAutomationRuns.php new file mode 100644 index 00000000..a8ee71af --- /dev/null +++ b/app/Console/Commands/Automation/PruneDryRunAutomationRuns.php @@ -0,0 +1,30 @@ +where('is_dry_run', true) + ->whereNotNull('finished_at') + ->where('finished_at', '<=', now()->subMinutes(self::GRACE_MINUTES)) + ->delete(); + + $this->info("Pruned {$count} dry-run automation runs."); + + return self::SUCCESS; + } +} diff --git a/app/Console/Commands/Automation/RecoverStuckAutomationRuns.php b/app/Console/Commands/Automation/RecoverStuckAutomationRuns.php new file mode 100644 index 00000000..53951c42 --- /dev/null +++ b/app/Console/Commands/Automation/RecoverStuckAutomationRuns.php @@ -0,0 +1,32 @@ +whereIn('status', [Status::Running, Status::Pending]) + ->where('updated_at', '<=', now()->subHour()) + ->update([ + 'status' => Status::Failed, + 'error' => ['reason' => 'stuck'], + 'finished_at' => now(), + ]); + + $this->info("Recovered {$count} stuck automation runs."); + + 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..d3bd414b --- /dev/null +++ b/app/Http/Controllers/App/AutomationController.php @@ -0,0 +1,168 @@ +user()->currentWorkspace; + + $automations = Inertia::scroll(fn () => AutomationResource::collection( + $list($workspace) + )); + + return Inertia::render('automations/Index', [ + 'automations' => $automations, + ]); + } + + public function store(StoreAutomationRequest $request, CreateAutomation $create): RedirectResponse + { + $automation = $create( + $request->user()->currentWorkspace, + $request->user(), + ); + + return redirect()->route('app.automations.edit', $automation->id); + } + + public function edit(Automation $automation, GetAutomationEditorData $editorData): Response + { + $this->authorize('update', $automation); + + ['socialAccounts' => $socialAccounts, 'pinterestBoards' => $pinterestBoards, 'tiktokCreatorInfos' => $tiktokCreatorInfos] = $editorData($automation); + + $platformConfigs = $socialAccounts->mapWithKeys(fn ($account) => [ + $account->id => new PlatformConfigResource($account), + ]); + + return Inertia::render('automations/Form', [ + 'automation' => AutomationResource::make($automation), + 'socialAccounts' => SocialAccountResource::collection($socialAccounts), + 'platformConfigs' => $platformConfigs, + 'pinterestBoards' => $pinterestBoards, + 'tiktokCreatorInfos' => $tiktokCreatorInfos, + ]); + } + + public function show(Automation $automation, GetAutomationDetails $details): Response + { + $this->authorize('view', $automation); + + ['runs' => $runs, 'triggerItems' => $triggerItems] = $details($automation); + + return Inertia::render('automations/Show', [ + 'automation' => AutomationResource::make($automation), + 'runs' => AutomationRunResource::collection($runs), + 'triggerItems' => AutomationTriggerItemResource::collection($triggerItems), + ]); + } + + 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, DeleteAutomation $delete): RedirectResponse + { + $this->authorize('delete', $automation); + $delete($automation); + + 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..5a80a595 --- /dev/null +++ b/app/Http/Requests/App/Automations/StoreAutomationRequest.php @@ -0,0 +1,23 @@ + + */ + public function rules(): array + { + return []; + } +} 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..689c85dc --- /dev/null +++ b/app/Http/Requests/App/Automations/UpdateAutomationRequest.php @@ -0,0 +1,174 @@ + + */ + public function rules(): array + { + $rules = [ + 'name' => ['sometimes', 'string', 'max:120'], + 'nodes' => ['sometimes', 'array'], + 'nodes.*.id' => ['required', 'string'], + 'nodes.*.type' => ['required', 'string', Rule::in(array_column(NodeType::cases(), 'value'))], + 'nodes.*.position' => ['required', 'array'], + 'nodes.*.position.x' => ['required', 'numeric'], + 'nodes.*.position.y' => ['required', 'numeric'], + 'nodes.*.data' => ['required', 'array'], + 'connections' => ['sometimes', 'array'], + 'connections.*.id' => ['required', 'string'], + 'connections.*.source' => ['required', 'string'], + 'connections.*.target' => ['required', 'string'], + 'connections.*.source_handle' => ['nullable', 'string'], + 'connections.*.target_handle' => ['nullable', 'string'], + 'variables' => ['sometimes', 'array', 'max:50'], + 'variables.*.key' => ['required', 'string', 'max:60', 'regex:/^[A-Za-z_][A-Za-z0-9_]*$/', 'distinct'], + 'variables.*.value' => ['nullable', 'string'], + ]; + + // Per-node data validation. We build these dynamically so each node's + // type drives the shape of its `data` payload, and so errors come back + // with full paths like `nodes.2.data.feed_url` for the frontend to map. + $nodes = $this->input('nodes', []); + if (is_array($nodes)) { + foreach ($nodes as $i => $node) { + $type = data_get($node, 'type'); + foreach ($this->dataRulesForNodeType($type) as $field => $fieldRules) { + $rules["nodes.{$i}.data.{$field}"] = $fieldRules; + } + } + } + + return $rules; + } + + /** + * Block saving a Generate node whose intended image count doesn't fit a + * selected account's content-type (mirrors the inline frontend validation), + * keyed so the frontend surfaces it under that node's accounts field. + */ + public function withValidator(Validator $validator): void + { + $validator->after(function (Validator $validator): void { + $nodes = $this->input('nodes', []); + + if (! is_array($nodes)) { + return; + } + + $generateValidator = app(GenerateNodeValidator::class); + + foreach ($nodes as $i => $node) { + if (data_get($node, 'type') !== NodeType::Generate->value) { + continue; + } + + $issue = $generateValidator->issueFor((array) data_get($node, 'data', [])); + + if ($issue !== null) { + $validator->errors()->add("nodes.{$i}.data.accounts", $issue); + } + } + }); + } + + /** + * @return array + */ + public function attributes(): array + { + return [ + 'nodes.*.data.feed_url' => 'Feed URL', + 'nodes.*.data.url' => 'URL', + 'nodes.*.data.cron' => 'cron expression', + 'nodes.*.data.duration' => 'duration', + 'nodes.*.data.unit' => 'unit', + 'nodes.*.data.field' => 'field', + 'nodes.*.data.operator' => 'operator', + 'nodes.*.data.mode' => 'mode', + 'nodes.*.data.method' => 'method', + 'nodes.*.data.trigger_type' => 'trigger type', + 'nodes.*.data.prompt_template' => 'prompt template', + 'nodes.*.data.accounts' => 'accounts', + ]; + } + + /** + * @return array> + */ + 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'], + 'target_slide_count' => ['nullable', 'integer', 'min:1', 'max:'.GenerateNodeValidator::MAX_GENERATED_IMAGES], + ], + 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..d696fd69 --- /dev/null +++ b/app/Http/Resources/AutomationResource.php @@ -0,0 +1,74 @@ + + */ + 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 ?? [], + 'variables' => $this->maskVariables($this->variables ?? []), + '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; + } + + /** + * Replace stored variable values with the placeholder so secrets never + * leave the server. The frontend references variables by key + * (`{{ variables.KEY }}`), so the masked value doesn't hinder reuse, and + * re-saving the placeholder keeps the stored ciphertext. + * + * @param array> $variables + * @return array> + */ + private function maskVariables(array $variables): array + { + foreach ($variables as &$variable) { + if (data_get($variable, 'value') !== null && data_get($variable, 'value') !== '') { + $variable['value'] = Automation::SENSITIVE_PLACEHOLDER; + } + } + + return $variables; + } +} diff --git a/app/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/Ai/StreamPostCreation.php b/app/Jobs/Ai/StreamPostCreation.php index 1406c11c..2ea08d98 100644 --- a/app/Jobs/Ai/StreamPostCreation.php +++ b/app/Jobs/Ai/StreamPostCreation.php @@ -7,8 +7,6 @@ use App\Actions\Post\CreatePost; use App\Ai\Agents\PostContentGenerator; use App\Ai\Agents\PostContentHumanizer; -use App\Enums\Media\Source; -use App\Enums\Media\Type as MediaType; use App\Enums\Notification\Channel as NotificationChannel; use App\Enums\Notification\Type as NotificationType; use App\Enums\PostPlatform\ContentType; @@ -18,17 +16,14 @@ use App\Models\SocialAccount; use App\Models\User; use App\Models\Workspace; -use App\Services\Ai\AiImageClient; use App\Services\Ai\RecordAiUsage; -use App\Services\Image\BrandColorMapper; -use App\Services\Image\TemplateImageGenerator; +use App\Services\Image\PostImagePipeline; use Illuminate\Bus\Queueable; use Illuminate\Contracts\Queue\ShouldQueue; use Illuminate\Foundation\Bus\Dispatchable; use Illuminate\Queue\InteractsWithQueue; use Illuminate\Queue\SerializesModels; use Illuminate\Support\Facades\Log; -use Illuminate\Support\Facades\Storage; class StreamPostCreation implements ShouldQueue { @@ -101,32 +96,6 @@ public function handle(): void } } - /** - * Run the structured generator output through the humanizer pass and merge - * the humanized text fields back over the original structure (preserving - * image_keywords and slide order/count). Failures are logged and the - * original structure is returned so generation never breaks because of the - * polish step. - * - * @param array $structured - * @return array - */ - /** - * Look up the AI image dimensions for the current format. Falls back to - * the generator's defaults (4:5 portrait) if the format string isn't a - * known ContentType case. - * - * @return array{width: int, height: int} - */ - private function dimensionsForFormat(): array - { - $type = $this->resolvedContentType(); - - return $type - ? $type->aiImageDimensions() - : ['width' => TemplateImageGenerator::DEFAULT_WIDTH, 'height' => TemplateImageGenerator::DEFAULT_HEIGHT]; - } - /** * The stored content type for the requested generation format. The carousel * generation format is persisted as an Instagram feed post. @@ -140,6 +109,16 @@ private function resolvedContentType(): ?ContentType return ContentType::tryFrom($this->format); } + /** + * Run the structured generator output through the humanizer pass and merge + * the humanized text fields back over the original structure (preserving + * image_keywords and slide order/count). Failures are logged and the + * original structure is returned so generation never breaks because of the + * polish step. + * + * @param array $structured + * @return array + */ private function humanize(Workspace $workspace, array $structured, string $format): array { try { @@ -207,29 +186,16 @@ private function humanize(Workspace $workspace, array $structured, string $forma private function handleCarousel(Workspace $workspace, ?SocialAccount $socialAccount, array $structured): void { $caption = (string) data_get($structured, 'caption', ''); - $slides = data_get($structured, 'slides', []); $media = []; if ($socialAccount) { - $generator = new TemplateImageGenerator(new BrandColorMapper, new AiImageClient); - ['width' => $width, 'height' => $height] = $this->dimensionsForFormat(); - - foreach ($slides as $slide) { - $rendered = $generator->render( - workspace: $workspace, - socialAccount: $socialAccount, - title: data_get($slide, 'title', ''), - body: data_get($slide, 'body', ''), - imageKeywords: data_get($slide, 'image_keywords', []), - width: $width, - height: $height, - ); - - if ($rendered) { - $media[] = $this->buildAiMediaItem($workspace, $rendered); - } - } + $media = app(PostImagePipeline::class)->forCarousel( + workspace: $workspace, + account: $socialAccount, + structured: $structured, + contentType: $this->resolvedContentType(), + ); } $post = $this->createPost($workspace, $caption, $media, $socialAccount); @@ -246,29 +212,16 @@ private function handleSingle(Workspace $workspace, ?SocialAccount $socialAccoun $supportsCaption = $contentType?->supportsCaption() ?? true; $rawContent = (string) data_get($structured, 'content', data_get($structured, 'text', '')); - $imageTitle = (string) data_get($structured, 'image_title', ''); - $imageBody = (string) data_get($structured, 'image_body', ''); - $keywords = data_get($structured, 'image_keywords', []); $media = []; if ($this->imageCount > 0 && $socialAccount) { - $generator = new TemplateImageGenerator(new BrandColorMapper, new AiImageClient); - ['width' => $width, 'height' => $height] = $this->dimensionsForFormat(); - - $rendered = $generator->render( + $media = app(PostImagePipeline::class)->forSingle( workspace: $workspace, - socialAccount: $socialAccount, - title: $imageTitle, - body: $imageBody, - imageKeywords: $keywords, - width: $width, - height: $height, + account: $socialAccount, + structured: $structured, + contentType: $this->resolvedContentType(), ); - - if ($rendered) { - $media[] = $this->buildAiMediaItem($workspace, $rendered); - } } $caption = $supportsCaption ? $rawContent : ''; @@ -345,31 +298,4 @@ private function aspectRatioFor(ContentType $type): ?string default => null, }; } - - /** - * @param array{path: string, source_meta: array} $rendered - * @return array - */ - private function buildAiMediaItem(Workspace $workspace, array $rendered): array - { - $media = $workspace->media()->create([ - 'collection' => 'ai-generated', - 'type' => MediaType::Image, - 'path' => $rendered['path'], - 'original_filename' => basename($rendered['path']), - 'mime_type' => 'image/webp', - 'size' => Storage::size($rendered['path']), - 'order' => 0, - ]); - - return [ - 'id' => $media->id, - 'path' => $media->path, - 'url' => $media->url, - 'type' => 'image', - 'mime_type' => 'image/webp', - 'source' => Source::Ai->value, - 'source_meta' => $rendered['source_meta'], - ]; - } } diff --git a/app/Jobs/Automation/DispatchPostTriggerAutomationsJob.php b/app/Jobs/Automation/DispatchPostTriggerAutomationsJob.php new file mode 100644 index 00000000..7982630d --- /dev/null +++ b/app/Jobs/Automation/DispatchPostTriggerAutomationsJob.php @@ -0,0 +1,33 @@ +onQueue('automations'); + } + + public function handle(DispatchPostTriggerAutomations $dispatch): void + { + $dispatch($this->post, $this->triggerType); + } +} diff --git a/app/Jobs/Automation/ProcessAutomationNode.php b/app/Jobs/Automation/ProcessAutomationNode.php new file mode 100644 index 00000000..d59d2844 --- /dev/null +++ b/app/Jobs/Automation/ProcessAutomationNode.php @@ -0,0 +1,155 @@ +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; + } + + if (! $this->run->is_manual && $this->run->automation->status !== AutomationStatus::Active) { + 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); + } + + public function failed(?Throwable $e): void + { + $this->run->refresh(); + + if (in_array($this->run->status, [RunStatus::Completed, RunStatus::Failed], true)) { + return; + } + + $this->run->update([ + 'status' => RunStatus::Failed, + 'error' => ['message' => $e?->getMessage() ?? 'job failed', 'node_id' => $this->nodeId], + 'finished_at' => now(), + ]); + } + + 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..b81fd554 --- /dev/null +++ b/app/Models/Automation.php @@ -0,0 +1,198 @@ + Status::class, + 'nodes' => 'array', + 'connections' => 'array', + 'variables' => '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') ?? [], + ); + $automation->variables = self::encryptVariables( + $automation->variables ?? [], + $automation->getOriginal('variables') ?? [], + ); + }); + } + + /** + * Workflow variables decrypted into a `key => value` map for use during a + * run (e.g. `{{ variables.API_KEY }}` resolution). Encrypted at rest and + * never returned to the frontend in plain text. + * + * @return array + */ + public function resolvedVariables(): array + { + $resolved = []; + + foreach ($this->variables ?? [] as $variable) { + $key = data_get($variable, 'key'); + if (! is_string($key) || $key === '') { + continue; + } + $resolved[$key] = self::decryptValue((string) data_get($variable, 'value', '')); + } + + return $resolved; + } + + public function workspace(): BelongsTo + { + return $this->belongsTo(Workspace::class); + } + + 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; + } + + /** + * Reconciles workflow variable values exactly like node credentials, matched + * by variable `key`: a PLACEHOLDER value keeps the existing ciphertext, + * plaintext gets encrypted, already-encrypted strings pass through. + * + * @param array> $incoming + * @param array>|string $original + * @return array> + */ + private static function encryptVariables(array $incoming, array|string $original): array + { + $original = is_array($original) ? $original : (json_decode($original, true) ?: []); + $originalByKey = collect($original)->keyBy('key'); + + foreach ($incoming as &$variable) { + $value = data_get($variable, 'value'); + if (! is_string($value) || $value === '') { + continue; + } + if ($value === self::SENSITIVE_PLACEHOLDER) { + $variable['value'] = (string) data_get($originalByKey->get($variable['key'] ?? null), 'value', ''); + + continue; + } + if (self::looksEncrypted($value)) { + continue; + } + $variable['value'] = Crypt::encryptString($value); + } + + return $incoming; + } + + private static function decryptValue(string $value): string + { + if ($value === '') { + return ''; + } + + try { + return Crypt::decryptString($value); + } catch (Throwable) { + return $value; + } + } + + /** + * Quick check for Laravel's `Crypt::encryptString` output without paying + * the cost of a full decrypt attempt. Laravel wraps payloads as base64 + * 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..efdda37a --- /dev/null +++ b/app/Models/AutomationNodeRun.php @@ -0,0 +1,38 @@ + 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..619a026e --- /dev/null +++ b/app/Models/AutomationNodeState.php @@ -0,0 +1,40 @@ + '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..968ac3da --- /dev/null +++ b/app/Models/AutomationRun.php @@ -0,0 +1,81 @@ + 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); + } + + /** + * Context for template (`{{ ... }}`) resolution: the run context plus the + * automation's workflow variables, merged in-memory. Variables are NEVER + * persisted into the run context (they're encrypted at rest and would + * otherwise leak in plaintext via the run/node-run API), so we compute this + * on demand at resolve time only. + * + * @return array + */ + public function resolverContext(): array + { + return array_merge( + $this->context ?? [], + ['variables' => $this->automation->resolvedVariables()], + ); + } + + public function triggerItem(): BelongsTo + { + return $this->belongsTo(AutomationTriggerItem::class, 'trigger_item_id'); + } + + 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..0c9abfa5 --- /dev/null +++ b/app/Models/AutomationTriggerItem.php @@ -0,0 +1,34 @@ + '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/Models/Post.php b/app/Models/Post.php index 04f0c9fe..eb7cffa6 100644 --- a/app/Models/Post.php +++ b/app/Models/Post.php @@ -7,7 +7,9 @@ use App\DataTransferObjects\MediaItem; use App\Enums\Media\Type; use App\Enums\Post\Status as PostStatus; +use App\Observers\PostObserver; use Database\Factories\PostFactory; +use Illuminate\Database\Eloquent\Attributes\ObservedBy; use Illuminate\Database\Eloquent\Builder; use Illuminate\Database\Eloquent\Casts\Attribute; use Illuminate\Database\Eloquent\Concerns\HasUuids; @@ -19,6 +21,7 @@ use Illuminate\Support\Collection; use Illuminate\Support\Facades\DB; +#[ObservedBy([PostObserver::class])] class Post extends Model { /** @use HasFactory */ 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..33456b27 --- /dev/null +++ b/app/Observers/PostObserver.php @@ -0,0 +1,32 @@ +wasChanged('status')) { + return; + } + + $triggerType = match ($post->status) { + PostStatus::Published => TriggerType::PostPublished, + PostStatus::Scheduled => TriggerType::PostScheduled, + default => null, + }; + + if ($triggerType === null) { + return; + } + + DispatchPostTriggerAutomationsJob::dispatch($post, $triggerType)->afterCommit(); + } +} 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/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/app/Services/Automation/GenerateNodeValidator.php b/app/Services/Automation/GenerateNodeValidator.php new file mode 100644 index 00000000..750dbf92 --- /dev/null +++ b/app/Services/Automation/GenerateNodeValidator.php @@ -0,0 +1,92 @@ + $config + */ + public function issueFor(array $config): ?string + { + $accounts = data_get($config, 'accounts'); + + if (! is_array($accounts) || $accounts === []) { + return null; + } + + $imageCount = $this->intendedImageCount($config, $accounts); + + foreach ($accounts as $entry) { + $contentType = ContentType::tryFrom((string) data_get($entry, 'content_type')); + + if (! $contentType instanceof ContentType) { + continue; + } + + $issue = $this->issueForAccount($contentType, $imageCount); + + if ($issue !== null) { + return $issue; + } + } + + return null; + } + + /** + * Mirrors the frontend: the carousel slide count when any selected account + * supports multiple images, otherwise a single image when enabled. + * + * @param array $config + * @param array $accounts + */ + private function intendedImageCount(array $config, array $accounts): int + { + foreach ($accounts as $entry) { + $contentType = ContentType::tryFrom((string) data_get($entry, 'content_type')); + + if ($contentType instanceof ContentType && $contentType->supportsImage() && $contentType->maxMediaCount() > 1) { + return (int) data_get($config, 'target_slide_count', 2); + } + } + + return (bool) data_get($config, 'include_image', true) ? 1 : 0; + } + + private function issueForAccount(ContentType $contentType, int $imageCount): ?string + { + if ($contentType->requiresMedia() && $imageCount === 0) { + return __('posts.edit.compliance.requires_media'); + } + + if ($imageCount > 0 && ! $contentType->supportsImage()) { + return __('posts.edit.compliance.no_images'); + } + + $max = min(self::MAX_GENERATED_IMAGES, $contentType->maxMediaCount()); + + if ($imageCount > $max) { + return __('posts.edit.compliance.too_many_files', ['max' => (string) $max]); + } + + return null; + } +} diff --git a/app/Services/Image/PostImagePipeline.php b/app/Services/Image/PostImagePipeline.php new file mode 100644 index 00000000..d09d1815 --- /dev/null +++ b/app/Services/Image/PostImagePipeline.php @@ -0,0 +1,120 @@ + $structured + * @return array> + */ + public function forSingle(Workspace $workspace, SocialAccount $account, array $structured, ?ContentType $contentType): array + { + ['width' => $width, 'height' => $height] = $this->dimensionsForContentType($contentType); + + $rendered = $this->generator->render( + workspace: $workspace, + socialAccount: $account, + title: (string) data_get($structured, 'image_title', ''), + body: (string) data_get($structured, 'image_body', ''), + imageKeywords: data_get($structured, 'image_keywords', []), + width: $width, + height: $height, + ); + + if (! $rendered) { + return []; + } + + return [$this->buildAiMediaItem($workspace, $rendered)]; + } + + /** + * Render one AI image per slide in the structured carousel output. Slides + * that render nothing are skipped. + * + * @param array $structured + * @return array> + */ + public function forCarousel(Workspace $workspace, SocialAccount $account, array $structured, ?ContentType $contentType): array + { + ['width' => $width, 'height' => $height] = $this->dimensionsForContentType($contentType); + + $media = []; + + foreach (data_get($structured, 'slides', []) as $slide) { + $rendered = $this->generator->render( + workspace: $workspace, + socialAccount: $account, + title: (string) data_get($slide, 'title', ''), + body: (string) data_get($slide, 'body', ''), + imageKeywords: data_get($slide, 'image_keywords', []), + width: $width, + height: $height, + ); + + if ($rendered) { + $media[] = $this->buildAiMediaItem($workspace, $rendered); + } + } + + return $media; + } + + /** + * Resolve the AI image dimensions for the given content type, falling back + * to the generator defaults (4:5 portrait) when no content type is known. + * + * @return array{width: int, height: int} + */ + private function dimensionsForContentType(?ContentType $contentType): array + { + return $contentType + ? $contentType->aiImageDimensions() + : ['width' => TemplateImageGenerator::DEFAULT_WIDTH, 'height' => TemplateImageGenerator::DEFAULT_HEIGHT]; + } + + /** + * @param array{path: string, source_meta: array} $rendered + * @return array + */ + private function buildAiMediaItem(Workspace $workspace, array $rendered): array + { + $media = $workspace->media()->create([ + 'collection' => 'ai-generated', + 'type' => MediaType::Image, + 'path' => $rendered['path'], + 'original_filename' => basename($rendered['path']), + 'mime_type' => 'image/webp', + 'size' => Storage::size($rendered['path']), + 'order' => 0, + ]); + + return [ + 'id' => $media->id, + 'path' => $media->path, + 'url' => $media->url, + 'type' => 'image', + 'mime_type' => 'image/webp', + 'source' => Source::Ai->value, + 'source_meta' => $rendered['source_meta'], + ]; + } +} diff --git a/bootstrap/app.php b/bootstrap/app.php index 03691e8c..996308de 100644 --- a/bootstrap/app.php +++ b/bootstrap/app.php @@ -1,5 +1,7 @@ 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/config/trypost.php b/config/trypost.php index a541cd47..082e1357 100644 --- a/config/trypost.php +++ b/config/trypost.php @@ -59,6 +59,19 @@ | */ + /* + |-------------------------------------------------------------------------- + | Outbound User-Agent + |-------------------------------------------------------------------------- + | + | Branded User-Agent applied to outbound HTTP from automation nodes + | (webhook + http_request) so recipients know the request came from + | TryPost.it. Self-hosters can override it. + | + */ + + 'user_agent' => env('TRYPOST_USER_AGENT', 'TryPost.it/1.0 (+https://trypost.it)'), + 'google_auth_enabled' => env('GOOGLE_AUTH_ENABLED', false), 'github_auth_enabled' => env('GITHUB_AUTH_ENABLED', false), diff --git a/database/factories/AutomationFactory.php b/database/factories/AutomationFactory.php new file mode 100644 index 00000000..1dcaed65 --- /dev/null +++ b/database/factories/AutomationFactory.php @@ -0,0 +1,62 @@ + 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..e68c6313 --- /dev/null +++ b/database/factories/AutomationNodeRunFactory.php @@ -0,0 +1,28 @@ + AutomationRun::factory(), + 'node_id' => 'node_'.fake()->randomNumber(6), + 'node_type' => NodeType::Generate, + 'status' => Status::Running, + 'input' => [], + 'started_at' => now(), + ]; + } +} diff --git a/database/factories/AutomationNodeStateFactory.php b/database/factories/AutomationNodeStateFactory.php new file mode 100644 index 00000000..92ba1fc5 --- /dev/null +++ b/database/factories/AutomationNodeStateFactory.php @@ -0,0 +1,23 @@ + Automation::factory(), + 'node_id' => 'node_'.fake()->randomNumber(6), + 'data' => [], + ]; + } +} diff --git a/database/factories/AutomationRunFactory.php b/database/factories/AutomationRunFactory.php new file mode 100644 index 00000000..f6bfbec4 --- /dev/null +++ b/database/factories/AutomationRunFactory.php @@ -0,0 +1,51 @@ + 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..ca370207 --- /dev/null +++ b/database/factories/AutomationTriggerItemFactory.php @@ -0,0 +1,27 @@ + 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..23f5d423 --- /dev/null +++ b/database/migrations/2026_05_22_211640_create_automations_table.php @@ -0,0 +1,39 @@ +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..64016a4b --- /dev/null +++ b/database/migrations/2026_05_22_211641_create_automation_trigger_items_table.php @@ -0,0 +1,35 @@ +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..70c65da7 --- /dev/null +++ b/database/migrations/2026_05_22_211642_create_automation_runs_table.php @@ -0,0 +1,44 @@ +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..e285911e --- /dev/null +++ b/database/migrations/2026_05_22_211643_create_automation_node_runs_table.php @@ -0,0 +1,41 @@ +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..a830cb42 --- /dev/null +++ b/database/migrations/2026_05_23_164734_create_automation_node_states_table.php @@ -0,0 +1,34 @@ +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/database/migrations/2026_06_11_181832_add_variables_to_automations_table.php b/database/migrations/2026_06_11_181832_add_variables_to_automations_table.php new file mode 100644 index 00000000..cd9f748b --- /dev/null +++ b/database/migrations/2026_06_11_181832_add_variables_to_automations_table.php @@ -0,0 +1,28 @@ +json('variables')->nullable()->after('connections'); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('automations', function (Blueprint $table) { + $table->dropColumn('variables'); + }); + } +}; diff --git a/database/seeders/DatabaseSeeder.php b/database/seeders/DatabaseSeeder.php index 2400f150..b1f2022a 100644 --- a/database/seeders/DatabaseSeeder.php +++ b/database/seeders/DatabaseSeeder.php @@ -15,7 +15,7 @@ public function run(): void { $this->call([ PlanSeeder::class, - PassportSeeder::class + PassportSeeder::class, ]); } } diff --git a/lang/en/automations.php b/lang/en/automations.php new file mode 100644 index 00000000..8e7eef71 --- /dev/null +++ b/lang/en/automations.php @@ -0,0 +1,279 @@ + 'Automations', + 'default_name' => 'New automation', + + 'actions' => [ + 'new' => 'New automation', + 'edit' => 'Edit', + 'save' => 'Save', + 'activate' => 'Activate', + 'pause' => 'Pause', + 'delete' => 'Delete', + 'retry' => 'Retry', + ], + + 'tabs' => [ + 'build' => 'Build', + 'variables' => 'Variables', + 'test' => 'Test', + ], + + 'categories' => [ + 'sources' => 'Sources', + 'content' => 'Content', + 'flow' => 'Flow', + 'output' => 'Output', + ], + + 'variables' => [ + 'title' => 'Workflow variables', + 'hint' => 'Reusable values referenced anywhere with {{ variables.KEY }}. Stored encrypted.', + 'empty' => 'No variables yet.', + 'key' => 'Key', + 'value' => 'Value', + 'key_placeholder' => 'API_KEY', + 'value_placeholder' => 'Value', + 'add' => 'New variable', + ], + + 'test' => [ + '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', + 'no_new_items' => 'No new items — nothing downstream ran.', + 'error_starting' => 'Could not start the test run.', + 'with_real_data' => 'With real data', + 'run' => 'Run test', + 'idle_hint' => 'Hit Run test to execute the automation end-to-end.', + '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', + ], + ], + + '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.', + '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', + 'handles' => [ + 'items' => 'has items', + 'no_items' => 'no items', + ], + ], + + 'config' => [ + 'select_placeholder' => 'Select…', + 'invalid_json' => 'This isn’t valid JSON yet.', + + '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', + 'prompt_template' => 'Prompt template', + 'include_image' => 'Include image', + 'include_image_hint' => 'Generate an AI image for this post', + ], + '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)', + 'headers' => 'Headers', + 'header_name' => 'Header name', + 'header_value' => 'Value', + 'add_header' => 'Add header', + '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.', + 'webhook_invalid_payload_json' => 'The payload template is not valid JSON.', + 'url_not_allowed' => 'The request URL points to a private or unreachable address and was blocked.', + 'node_no_longer_exists' => 'Node :node_id no longer exists in the automation.', + 'no_trigger_connection' => 'No node connected to the Trigger node.', + ], +]; diff --git a/lang/en/common.php b/lang/en/common.php index 2d7ee4dc..c348d641 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,10 @@ 'clear' => 'Clear', 'close' => 'Close', 'loading_more' => 'Loading more...', + + 'actions' => [ + 'copy' => 'Copy', + 'copied' => 'Copied', + 'copy_failed' => 'Failed to copy to clipboard', + ], ]; diff --git a/lang/en/sidebar.php b/lang/en/sidebar.php index 5ffc7392..2892a5f6 100644 --- a/lang/en/sidebar.php +++ b/lang/en/sidebar.php @@ -1,5 +1,7 @@ 'Workspaces', 'select_workspace' => 'Select workspace', @@ -26,6 +28,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..2b66500a --- /dev/null +++ b/lang/es/automations.php @@ -0,0 +1,279 @@ + 'Automatizaciones', + 'default_name' => 'Nueva automatización', + + 'actions' => [ + 'new' => 'Nueva automatización', + 'edit' => 'Editar', + 'save' => 'Guardar', + 'activate' => 'Activar', + 'pause' => 'Pausar', + 'delete' => 'Eliminar', + 'retry' => 'Reintentar', + ], + + 'tabs' => [ + 'build' => 'Construir', + 'variables' => 'Variables', + 'test' => 'Probar', + ], + + 'categories' => [ + 'sources' => 'Fuentes', + 'content' => 'Contenido', + 'flow' => 'Flujo', + 'output' => 'Salida', + ], + + 'variables' => [ + 'title' => 'Variables del workflow', + 'hint' => 'Valores reutilizables referenciados en cualquier lugar con {{ variables.KEY }}. Almacenados cifrados.', + 'empty' => 'Aún no hay variables.', + 'key' => 'Clave', + 'value' => 'Valor', + 'key_placeholder' => 'API_KEY', + 'value_placeholder' => 'Valor', + 'add' => 'Nueva variable', + ], + + 'test' => [ + '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', + 'no_new_items' => 'Sin elementos nuevos — no se ejecutó nada después.', + 'error_starting' => 'No se pudo iniciar la ejecución de prueba.', + 'with_real_data' => 'Con datos reales', + 'run' => 'Ejecutar prueba', + 'idle_hint' => 'Pulsa Ejecutar prueba para correr la automatización de principio a fin.', + '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', + ], + ], + + '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.', + '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', + 'handles' => [ + 'items' => 'con elementos', + 'no_items' => 'sin elementos', + ], + ], + + 'config' => [ + 'select_placeholder' => 'Selecciona…', + 'invalid_json' => 'Esto aún no es un JSON válido.', + + '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', + 'prompt_template' => 'Plantilla de prompt', + 'include_image' => 'Incluir imagen', + 'include_image_hint' => 'Generar una imagen con IA para esta publicación', + ], + '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)', + 'headers' => 'Headers', + 'header_name' => 'Nombre del header', + 'header_value' => 'Valor', + 'add_header' => 'Agregar header', + '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.', + 'webhook_invalid_payload_json' => 'La plantilla de payload no es un JSON válido.', + 'url_not_allowed' => 'La URL de la petición apunta a una dirección privada o inaccesible y fue bloqueada.', + 'node_no_longer_exists' => 'El nodo :node_id ya no existe en la automatización.', + 'no_trigger_connection' => 'Ningún nodo está conectado al nodo disparador.', + ], +]; diff --git a/lang/es/common.php b/lang/es/common.php index 368fe2db..261d31e3 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,10 @@ 'clear' => 'Limpiar', 'close' => 'Cerrar', 'loading_more' => 'Cargando más...', + + 'actions' => [ + 'copy' => 'Copiar', + 'copied' => 'Copiado', + 'copy_failed' => 'No se pudo copiar al portapapeles', + ], ]; diff --git a/lang/es/sidebar.php b/lang/es/sidebar.php index 9bc137e9..291ec360 100644 --- a/lang/es/sidebar.php +++ b/lang/es/sidebar.php @@ -1,5 +1,7 @@ 'Workspaces', 'select_workspace' => 'Seleccionar workspace', @@ -26,6 +28,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..95ff93e7 --- /dev/null +++ b/lang/pt-BR/automations.php @@ -0,0 +1,279 @@ + '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', + ], + + 'tabs' => [ + 'build' => 'Montar', + 'variables' => 'Variáveis', + 'test' => 'Testar', + ], + + 'categories' => [ + 'sources' => 'Fontes', + 'content' => 'Conteúdo', + 'flow' => 'Fluxo', + 'output' => 'Saída', + ], + + 'variables' => [ + 'title' => 'Variáveis do workflow', + 'hint' => 'Valores reutilizáveis referenciados em qualquer lugar com {{ variables.KEY }}. Armazenados encriptados.', + 'empty' => 'Nenhuma variável ainda.', + 'key' => 'Chave', + 'value' => 'Valor', + 'key_placeholder' => 'API_KEY', + 'value_placeholder' => 'Valor', + 'add' => 'Nova variável', + ], + + 'test' => [ + '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', + 'no_new_items' => 'Nenhum item novo — nada foi executado adiante.', + 'error_starting' => 'Não foi possível iniciar a execução de teste.', + 'with_real_data' => 'Com dados reais', + 'run' => 'Rodar teste', + 'idle_hint' => 'Clique em Rodar teste para executar a automação de ponta a ponta.', + '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', + ], + ], + + '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.', + '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', + 'handles' => [ + 'items' => 'tem itens', + 'no_items' => 'sem itens', + ], + ], + + 'config' => [ + 'select_placeholder' => 'Selecione…', + 'invalid_json' => 'Isto ainda não é um JSON válido.', + + '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', + 'prompt_template' => 'Template do prompt', + 'include_image' => 'Incluir imagem', + 'include_image_hint' => 'Gerar uma imagem com IA para esta publicação', + ], + '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)', + 'headers' => 'Headers', + 'header_name' => 'Nome do header', + 'header_value' => 'Valor', + 'add_header' => 'Adicionar header', + '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.', + 'webhook_invalid_payload_json' => 'O template do payload não é um JSON válido.', + 'url_not_allowed' => 'A URL da requisição aponta para um endereço privado ou inacessível e foi bloqueada.', + 'node_no_longer_exists' => 'O nó :node_id não existe mais nesta automação.', + 'no_trigger_connection' => 'Nenhum nó conectado ao nó de trigger.', + ], +]; diff --git a/lang/pt-BR/common.php b/lang/pt-BR/common.php index c3f33be6..0b0c2c51 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,10 @@ 'clear' => 'Limpar', 'close' => 'Fechar', 'loading_more' => 'Carregando mais...', + + 'actions' => [ + 'copy' => 'Copiar', + 'copied' => 'Copiado', + 'copy_failed' => 'Falha ao copiar para a área de transferência', + ], ]; diff --git a/lang/pt-BR/sidebar.php b/lang/pt-BR/sidebar.php index f9a59830..e4178c6e 100644 --- a/lang/pt-BR/sidebar.php +++ b/lang/pt-BR/sidebar.php @@ -1,5 +1,7 @@ 'Espaços de trabalho', 'select_workspace' => 'Selecionar workspace', @@ -26,6 +28,7 @@ ], 'analytics' => 'Analytics', + 'automations' => 'Automações', 'settings' => 'Configurações', 'posts' => [ diff --git a/package-lock.json b/package-lock.json index bbf85de6..58bfa129 100644 --- a/package-lock.json +++ b/package-lock.json @@ -5,15 +5,25 @@ "packages": { "": { "dependencies": { + "@codemirror/commands": "^6.10.3", + "@codemirror/lang-json": "^6.0.2", + "@codemirror/state": "^6.5.4", + "@codemirror/view": "^6.39.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", + "codemirror": "^6.0.2", "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", @@ -209,6 +219,97 @@ "node": ">=6.9.0" } }, + "node_modules/@codemirror/autocomplete": { + "version": "6.20.3", + "resolved": "https://registry.npmjs.org/@codemirror/autocomplete/-/autocomplete-6.20.3.tgz", + "integrity": "sha512-tlosUqb+3BbxCxZdu4tKeRghPFC+QM7q4X5YhKV2eCmPG+1r2F3f4AaSz5sCrFqUtX4Jh20VFTKecl16MgiV9g==", + "license": "MIT", + "dependencies": { + "@codemirror/language": "^6.0.0", + "@codemirror/state": "^6.0.0", + "@codemirror/view": "^6.17.0", + "@lezer/common": "^1.0.0" + } + }, + "node_modules/@codemirror/commands": { + "version": "6.10.3", + "resolved": "https://registry.npmjs.org/@codemirror/commands/-/commands-6.10.3.tgz", + "integrity": "sha512-JFRiqhKu+bvSkDLI+rUhJwSxQxYb759W5GBezE8Uc8mHLqC9aV/9aTC7yJSqCtB3F00pylrLCwnyS91Ap5ej4Q==", + "license": "MIT", + "dependencies": { + "@codemirror/language": "^6.0.0", + "@codemirror/state": "^6.6.0", + "@codemirror/view": "^6.27.0", + "@lezer/common": "^1.1.0" + } + }, + "node_modules/@codemirror/lang-json": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/@codemirror/lang-json/-/lang-json-6.0.2.tgz", + "integrity": "sha512-x2OtO+AvwEHrEwR0FyyPtfDUiloG3rnVTSZV1W8UteaLL8/MajQd8DpvUb2YVzC+/T18aSDv0H9mu+xw0EStoQ==", + "license": "MIT", + "dependencies": { + "@codemirror/language": "^6.0.0", + "@lezer/json": "^1.0.0" + } + }, + "node_modules/@codemirror/language": { + "version": "6.12.3", + "resolved": "https://registry.npmjs.org/@codemirror/language/-/language-6.12.3.tgz", + "integrity": "sha512-QwCZW6Tt1siP37Jet9Tb02Zs81TQt6qQrZR2H+eGMcFsL1zMrk2/b9CLC7/9ieP1fjIUMgviLWMmgiHoJrj+ZA==", + "license": "MIT", + "dependencies": { + "@codemirror/state": "^6.0.0", + "@codemirror/view": "^6.23.0", + "@lezer/common": "^1.5.0", + "@lezer/highlight": "^1.0.0", + "@lezer/lr": "^1.0.0", + "style-mod": "^4.0.0" + } + }, + "node_modules/@codemirror/lint": { + "version": "6.9.7", + "resolved": "https://registry.npmjs.org/@codemirror/lint/-/lint-6.9.7.tgz", + "integrity": "sha512-28/+iWLYxKxsvGYhSYL7zaCZqLz5+FFFDq9tVsvGv9kv8RY4fFAchJ5WX9M3YrrRlTIsECjsXPqeNgnSmNP2dg==", + "license": "MIT", + "dependencies": { + "@codemirror/state": "^6.0.0", + "@codemirror/view": "^6.42.0", + "crelt": "^1.0.5" + } + }, + "node_modules/@codemirror/search": { + "version": "6.7.0", + "resolved": "https://registry.npmjs.org/@codemirror/search/-/search-6.7.0.tgz", + "integrity": "sha512-ZvGm99wc/s2cITtMT15LFdn8aH/aS+V+DqyGq/N5ZlV5vWtH+nILvC2nw0zX7ByNoHHDZ2IxxdW38O0tc5nVHg==", + "license": "MIT", + "dependencies": { + "@codemirror/state": "^6.0.0", + "@codemirror/view": "^6.37.0", + "crelt": "^1.0.5" + } + }, + "node_modules/@codemirror/state": { + "version": "6.6.0", + "resolved": "https://registry.npmjs.org/@codemirror/state/-/state-6.6.0.tgz", + "integrity": "sha512-4nbvra5R5EtiCzr9BTHiTLc+MLXK2QGiAVYMyi8PkQd3SR+6ixar/Q/01Fa21TBIDOZXgeWV4WppsQolSreAPQ==", + "license": "MIT", + "dependencies": { + "@marijn/find-cluster-break": "^1.0.0" + } + }, + "node_modules/@codemirror/view": { + "version": "6.43.1", + "resolved": "https://registry.npmjs.org/@codemirror/view/-/view-6.43.1.tgz", + "integrity": "sha512-+BIjw/AG3tDQ4pJgTLPYdAW25eDE66YsvM4LKyVPgGzVgZ4a9Wj1SRX8kPVKgBDdPt8oHtZ15F0qx7p0oOHdHw==", + "license": "MIT", + "dependencies": { + "@codemirror/state": "^6.6.0", + "crelt": "^1.0.6", + "style-mod": "^4.1.0", + "w3c-keyname": "^2.2.4" + } + }, "node_modules/@emnapi/core": { "version": "1.8.1", "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.8.1.tgz", @@ -1183,6 +1284,41 @@ "dev": true, "license": "MIT" }, + "node_modules/@lezer/common": { + "version": "1.5.2", + "resolved": "https://registry.npmjs.org/@lezer/common/-/common-1.5.2.tgz", + "integrity": "sha512-sxQE460fPZyU3sdc8lafxiPwJHBzZRy/udNFynGQky1SePYBdhkBl1kOagA9uT3pxR8K09bOrmTUqA9wb/PjSQ==", + "license": "MIT" + }, + "node_modules/@lezer/highlight": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@lezer/highlight/-/highlight-1.2.3.tgz", + "integrity": "sha512-qXdH7UqTvGfdVBINrgKhDsVTJTxactNNxLk7+UMwZhU13lMHaOBlJe9Vqp907ya56Y3+ed2tlqzys7jDkTmW0g==", + "license": "MIT", + "dependencies": { + "@lezer/common": "^1.3.0" + } + }, + "node_modules/@lezer/json": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@lezer/json/-/json-1.0.3.tgz", + "integrity": "sha512-BP9KzdF9Y35PDpv04r0VeSTKDeox5vVr3efE7eBbx3r4s3oNLfunchejZhjArmeieBH+nVOpgIiBJpEAv8ilqQ==", + "license": "MIT", + "dependencies": { + "@lezer/common": "^1.2.0", + "@lezer/highlight": "^1.0.0", + "@lezer/lr": "^1.0.0" + } + }, + "node_modules/@lezer/lr": { + "version": "1.4.10", + "resolved": "https://registry.npmjs.org/@lezer/lr/-/lr-1.4.10.tgz", + "integrity": "sha512-rnCpTIBafOx4mRp43xOxDJbFipJm/c0cia/V5TiGlhmMa+wsSdoGmUN3w5Bqrks/09Q/D4tNAmWaT8p6NRi77A==", + "license": "MIT", + "dependencies": { + "@lezer/common": "^1.0.0" + } + }, "node_modules/@mapbox/geojson-rewind": { "version": "0.5.2", "resolved": "https://registry.npmjs.org/@mapbox/geojson-rewind/-/geojson-rewind-0.5.2.tgz", @@ -1254,6 +1390,12 @@ "node": ">=6.0.0" } }, + "node_modules/@marijn/find-cluster-break": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@marijn/find-cluster-break/-/find-cluster-break-1.0.2.tgz", + "integrity": "sha512-l0h88YhZFyKdXIFNfSWpyjStDjGHwZ/U7iobcK1cQQD8sejsONdQtTVU+1wVN1PBw40PiiHB1vA5S7VTfQiP9g==", + "license": "MIT" + }, "node_modules/@napi-rs/wasm-runtime": { "version": "0.2.12", "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-0.2.12.tgz", @@ -3522,6 +3664,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", @@ -4184,6 +4470,21 @@ "node": ">=6" } }, + "node_modules/codemirror": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/codemirror/-/codemirror-6.0.2.tgz", + "integrity": "sha512-VhydHotNW5w1UGK0Qj96BwSk/Zqbp9WbnyK2W/eVMv4QyF41INRGpjUhFJY7/uDNuudSc33a/PKr4iDqRduvHw==", + "license": "MIT", + "dependencies": { + "@codemirror/autocomplete": "^6.0.0", + "@codemirror/commands": "^6.0.0", + "@codemirror/language": "^6.0.0", + "@codemirror/lint": "^6.0.0", + "@codemirror/search": "^6.0.0", + "@codemirror/state": "^6.0.0", + "@codemirror/view": "^6.0.0" + } + }, "node_modules/color-convert": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", @@ -4303,6 +4604,12 @@ "node": ">= 6" } }, + "node_modules/crelt": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/crelt/-/crelt-1.0.6.tgz", + "integrity": "sha512-VQ2MBenTq1fWZUH9DJNGti7kKv6EeAuYr3cLwxUWhIu1baTaXh4Ib5W2CqHVqib4/MqbYGJqiL3Zb8GJZr3l4g==", + "license": "MIT" + }, "node_modules/cross-spawn": { "version": "7.0.6", "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", @@ -4448,7 +4755,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" @@ -4484,7 +4790,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" @@ -4494,7 +4799,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", @@ -4534,7 +4838,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" @@ -4627,7 +4930,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" @@ -4763,7 +5065,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" @@ -4812,7 +5113,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" @@ -4822,7 +5122,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", @@ -4842,7 +5141,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", @@ -6456,6 +6754,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", @@ -8992,6 +9299,12 @@ "dev": true, "license": "MIT" }, + "node_modules/style-mod": { + "version": "4.1.3", + "resolved": "https://registry.npmjs.org/style-mod/-/style-mod-4.1.3.tgz", + "integrity": "sha512-i/n8VsZydrugj3Iuzll8+x/00GH2vnYsk1eomD8QiRrSAeW6ItbCQDtfXCeJHd0iwiNagqjQkvpvREEPtW3IoQ==", + "license": "MIT" + }, "node_modules/stylis": { "version": "4.2.0", "resolved": "https://registry.npmjs.org/stylis/-/stylis-4.2.0.tgz", @@ -9798,6 +10111,12 @@ "typescript": ">=5.0.0" } }, + "node_modules/w3c-keyname": { + "version": "2.2.8", + "resolved": "https://registry.npmjs.org/w3c-keyname/-/w3c-keyname-2.2.8.tgz", + "integrity": "sha512-dpojBhNsCNN7T82Tm7k26A6G9ML3NkhDsnw9n/eoxSRlVBB4CEtIQ/KTCLI2Fwf3ataSXRhYFkQi3SlnFwPvPQ==", + "license": "MIT" + }, "node_modules/web-vitals": { "version": "5.2.0", "resolved": "https://registry.npmjs.org/web-vitals/-/web-vitals-5.2.0.tgz", diff --git a/package.json b/package.json index e9d1147b..8486bf4f 100644 --- a/package.json +++ b/package.json @@ -38,15 +38,25 @@ "vue-tsc": "^2.2.4" }, "dependencies": { + "@codemirror/commands": "^6.10.3", + "@codemirror/lang-json": "^6.0.2", + "@codemirror/state": "^6.5.4", + "@codemirror/view": "^6.39.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", + "codemirror": "^6.0.2", "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..92db7297 100644 --- a/resources/css/app.css +++ b/resources/css/app.css @@ -164,3 +164,129 @@ .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; + max-width: 260px; + 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; + max-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__icon-tile--cyan { background: #cffafe; color: #155e75; } + +.automation-node--accent-violet .automation-node__header { background: #f5f3ff; } +.automation-node--accent-blue .automation-node__header { background: #eff6ff; } +.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--accent-cyan .automation-node__header { background: #ecfeff; } + +.automation-node__title { + min-width: 0; + font-weight: 700; + font-size: 0.875rem; + color: var(--foreground); + line-height: 1.2; + letter-spacing: -0.005em; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.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/ChannelConfigurator.vue b/resources/js/components/ChannelConfigurator.vue new file mode 100644 index 00000000..8df2a0fb --- /dev/null +++ b/resources/js/components/ChannelConfigurator.vue @@ -0,0 +1,173 @@ + + + diff --git a/resources/js/components/CodeEditor.vue b/resources/js/components/CodeEditor.vue new file mode 100644 index 00000000..7aaa605d --- /dev/null +++ b/resources/js/components/CodeEditor.vue @@ -0,0 +1,174 @@ + + + diff --git a/resources/js/components/JsonViewer.vue b/resources/js/components/JsonViewer.vue new file mode 100644 index 00000000..f7906c40 --- /dev/null +++ b/resources/js/components/JsonViewer.vue @@ -0,0 +1,42 @@ + + + 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/BuildPanel.vue b/resources/js/components/automations/BuildPanel.vue new file mode 100644 index 00000000..18c303d2 --- /dev/null +++ b/resources/js/components/automations/BuildPanel.vue @@ -0,0 +1,88 @@ + + + diff --git a/resources/js/components/automations/EditorSidebar.vue b/resources/js/components/automations/EditorSidebar.vue new file mode 100644 index 00000000..eaa1d87d --- /dev/null +++ b/resources/js/components/automations/EditorSidebar.vue @@ -0,0 +1,71 @@ + + + diff --git a/resources/js/components/automations/TestRunPanel.vue b/resources/js/components/automations/TestRunPanel.vue new file mode 100644 index 00000000..c25d0df7 --- /dev/null +++ b/resources/js/components/automations/TestRunPanel.vue @@ -0,0 +1,235 @@ + + + diff --git a/resources/js/components/automations/VariablesPanel.vue b/resources/js/components/automations/VariablesPanel.vue new file mode 100644 index 00000000..aec0cc31 --- /dev/null +++ b/resources/js/components/automations/VariablesPanel.vue @@ -0,0 +1,89 @@ + + + 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 @@ + + +